Dispatcharr/apps/m3u/utils.py
None 40ec6b6339 fix(channels): align the auto-sync rename preview and live rename on the regex module
The Find and Replace preview did not correctly reflect the rename the sync performs, and the rename engine differed from the preview engine.

- The preview rendered the literal $1 instead of the substituted capture group, because the replacement was passed straight into the regex engine, which honors \1, not the JS-style $1 the field accepts.
- The preview compiled patterns with the regex module while the live rename used stdlib re, so patterns valid in regex but not re (for example ^*) previewed a transform the sync silently skipped.
- A rename that expanded a name past the Channel.name column length aborted the whole bulk_create sync, while the preview showed the full name.
- Convert JS-style $1 backreferences to \1 via a shared helper used by both the preview and the live rename.
- Switch the live rename from re.sub to regex.sub, matching the preview engine and the sync's own include/exclude filters, with a timeout to bound catastrophic backtracking on user patterns.
- Cap the rename result at the Channel.name column length in both paths, so an over-length result cannot abort the sync.
- Add unit, integration, and differential parity tests covering the above.
2026-06-27 17:45:42 -05:00

131 lines
4.7 KiB
Python

# apps/m3u/utils.py
import regex
import threading
import logging
from django.db import models
lock = threading.Lock()
# Dictionary to track usage: {m3u_account_id: current_usage}
active_streams_map = {}
logger = logging.getLogger(__name__)
def convert_js_numbered_backreferences(replacement):
"""Translate JS-style ``$1``/``$2`` backreferences to Python ``\\1``/``\\2``.
Auto-sync replace patterns are authored in JS regex syntax, but Python's
regex engines honor backslash backreferences, not ``$1``. The live rename
and the UI preview must convert identically, so both call this single
helper and cannot drift apart (otherwise the preview promises an output
the sync would never produce).
"""
return regex.sub(r"\$(\d+)", r"\\\1", replacement)
def normalize_stream_url(url):
"""
Normalize stream URLs for compatibility with FFmpeg.
Handles VLC-specific syntax like udp://@239.0.0.1:1234 by removing the @ symbol.
FFmpeg doesn't recognize the @ prefix for multicast addresses.
Args:
url (str): The stream URL to normalize
Returns:
str: The normalized URL
"""
if not url:
return url
# Handle VLC-style UDP multicast URLs: udp://@239.0.0.1:1234 -> udp://239.0.0.1:1234
# The @ symbol in VLC means "listen on all interfaces" but FFmpeg doesn't use this syntax
if url.startswith('udp://@'):
normalized = url.replace('udp://@', 'udp://', 1)
logger.debug(f"Normalized VLC-style UDP URL: {url} -> {normalized}")
return normalized
# Could add other normalizations here in the future (rtp://@, etc.)
return url
def increment_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
current_usage += 1
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def decrement_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
if current_usage > 0:
current_usage -= 1
if current_usage == 0:
del active_streams_map[account.id]
else:
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def calculate_tuner_count(minimum=1, unlimited_default=10):
"""
Calculate tuner/connection count from active M3U profiles and custom streams.
This is the centralized function used by both HDHR and XtreamCodes APIs.
Args:
minimum (int): Minimum number to return (default: 1)
unlimited_default (int): Default value when unlimited profiles exist (default: 10)
Returns:
int: Calculated tuner/connection count
"""
try:
from apps.m3u.models import M3UAccountProfile
from apps.channels.models import Stream
# Calculate tuner count from active profiles from active M3U accounts (excluding default "custom Default" profile)
profiles = M3UAccountProfile.objects.filter(
is_active=True,
m3u_account__is_active=True, # Only include profiles from enabled M3U accounts
).exclude(id=1)
# 1. Check if any profile has unlimited streams (max_streams=0)
has_unlimited = profiles.filter(max_streams=0).exists()
# 2. Calculate tuner count from limited profiles
limited_tuners = 0
if not has_unlimited:
limited_tuners = (
profiles.filter(max_streams__gt=0)
.aggregate(total=models.Sum("max_streams"))
.get("total", 0)
or 0
)
# 3. Add custom stream count to tuner count
custom_stream_count = Stream.objects.filter(is_custom=True).count()
logger.debug(f"Found {custom_stream_count} custom streams")
# 4. Calculate final tuner count
if has_unlimited:
# If there are unlimited profiles, start with unlimited_default plus custom streams
tuner_count = unlimited_default + custom_stream_count
else:
# Otherwise use the limited profile sum plus custom streams
tuner_count = limited_tuners + custom_stream_count
# 5. Ensure minimum number
tuner_count = max(minimum, tuner_count)
logger.debug(
f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})"
)
return tuner_count
except Exception as e:
logger.error(f"Error calculating tuner count: {e}")
return minimum # Fallback to minimum value