From d89f63421337e15bb31b58c027a62e0b2de275d7 Mon Sep 17 00:00:00 2001 From: Goldenfreddy0703 <62456796+Goldenfreddy0703@users.noreply.github.com> Date: Wed, 20 May 2026 03:30:48 -0400 Subject: [PATCH 01/11] Enforce shared credential connection pools across M3U accounts (#1137) Group M3U/XC accounts and profiles that share the same provider login into auto-assigned ServerGroups keyed by credential fingerprint. Enforce combined Redis limits for live TV and VOD via apps/m3u/connection_pool.py. - Per-profile fingerprinting (XC transforms and STD stream URLs) - VOD profile selection tries alternates when default credential pool is full (fixes live then VOD failure) - Stats UI shows provider login from active stream URL - Tests: apps/m3u/tests/test_connection_pool.py (11 tests, all passing) Co-authored-by: Cursor --- apps/channels/models.py | 137 ++++---- apps/m3u/connection_pool.py | 299 ++++++++++++++++++ .../0020_servergroup_max_streams.py | 21 ++ ...0021_servergroup_credential_fingerprint.py | 102 ++++++ apps/m3u/models.py | 32 ++ apps/m3u/serializers.py | 2 +- apps/m3u/tests/test_connection_pool.py | 247 +++++++++++++++ apps/proxy/live_proxy/channel_status.py | 22 +- apps/proxy/live_proxy/url_utils.py | 79 +++-- .../multi_worker_connection_manager.py | 80 ++--- apps/proxy/vod_proxy/views.py | 34 +- .../components/cards/StreamConnectionCard.jsx | 22 +- .../components/cards/VodConnectionCard.jsx | 7 + 13 files changed, 917 insertions(+), 167 deletions(-) create mode 100644 apps/m3u/connection_pool.py create mode 100644 apps/m3u/migrations/0020_servergroup_max_streams.py create mode 100644 apps/m3u/migrations/0021_servergroup_credential_fingerprint.py create mode 100644 apps/m3u/tests/test_connection_pool.py diff --git a/apps/channels/models.py b/apps/channels/models.py index 59af3e00..59a2bc3d 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -4,7 +4,7 @@ from django.conf import settings from core.models import StreamProfile, CoreSettings from core.utils import RedisClient from apps.proxy.live_proxy.redis_keys import RedisKeys -from apps.proxy.live_proxy.constants import ChannelMetadataField +from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState import logging import uuid from django.utils import timezone @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) # If you have an M3UAccount model in apps.m3u, you can still import it: from apps.m3u.models import M3UAccount +from apps.m3u.connection_pool import reserve_profile_slot, release_profile_slot # Add fallback functions if Redis isn't available @@ -231,17 +232,8 @@ class Stream(models.Model): if profile.is_active == False: continue - # Atomic slot reservation: INCR first, check, rollback if over capacity - if profile.max_streams == 0: - reserved = True - else: - profile_connections_key = f"profile_connections:{profile.id}" - new_count = redis_client.incr(profile_connections_key) - if new_count <= profile.max_streams: - reserved = True - else: - redis_client.decr(profile_connections_key) - reserved = False + # Atomic slot reservation via shared connection pool helper + reserved, _count = reserve_profile_slot(profile, redis_client) if reserved: redis_client.set(f"channel_stream:{self.id}", self.id) @@ -277,12 +269,7 @@ class Stream(models.Model): f"Stream {stream_id}: found profile_id={profile_id}" ) - profile_connections_key = f"profile_connections:{profile_id}" - - # Only decrement if the profile had a max_connections limit - current_count = int(redis_client.get(profile_connections_key) or 0) - if current_count > 0: - redis_client.decr(profile_connections_key) + release_profile_slot(profile_id, redis_client) return True @@ -599,37 +586,29 @@ class Channel(models.Model): return victim_id - def _check_and_reserve_profile_slot(self, profile, redis_client): - """ - Atomically check and reserve a connection slot for the given profile. + def _channel_proxy_is_active(self, redis_client) -> bool: + """True when live proxy metadata shows this channel is still running.""" + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + if not redis_client.exists(metadata_key): + return False + state = redis_client.hget(metadata_key, ChannelMetadataField.STATE) + if state is None: + return False + if isinstance(state, bytes): + state = state.decode() + return state in ( + ChannelState.ACTIVE, + ChannelState.WAITING_FOR_CLIENTS, + ChannelState.BUFFERING, + ChannelState.INITIALIZING, + ChannelState.CONNECTING, + ) - Uses an INCR-first-then-check pattern to eliminate the TOCTOU race - condition where separate GET + check + INCR operations could allow - concurrent requests to both pass the capacity check. - - For profiles with max_streams=0 (unlimited), no reservation is needed. - - Args: - profile: M3UAccountProfile instance - redis_client: Redis client instance - - Returns: - tuple: (reserved: bool, current_count: int) - """ - if profile.max_streams == 0: - return (True, 0) - - profile_connections_key = f"profile_connections:{profile.id}" - - # Atomically increment first — this is a single Redis command - new_count = redis_client.incr(profile_connections_key) - - if new_count <= profile.max_streams: - return (True, new_count) - - # Over capacity — roll back the increment - redis_client.decr(profile_connections_key) - return (False, new_count - 1) + def _clear_stream_assignment_keys(self, redis_client, stream_id=None) -> None: + """Remove channel/stream profile assignment keys from Redis.""" + redis_client.delete(f"channel_stream:{self.id}") + if stream_id is not None: + redis_client.delete(f"stream_profile:{stream_id}") def get_stream(self, requester=None): """ @@ -646,24 +625,40 @@ class Channel(models.Model): error_reason = "No streams assigned to channel" return None, None, error_reason - # Check if a stream is already active for this channel + # Reuse assignment only when this channel is still active in the proxy. + # Stale channel_stream keys after stop/disconnect caused every tune to + # reuse the default profile without re-running rotation or INCR. stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") if stream_id_bytes: try: stream_id = int(stream_id_bytes) - profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") - if profile_id_bytes: - try: - profile_id = int(profile_id_bytes) - return stream_id, profile_id, None - except (ValueError, TypeError): - logger.debug( - f"Invalid profile ID retrieved from Redis: {profile_id_bytes}" - ) except (ValueError, TypeError): logger.debug( f"Invalid stream ID retrieved from Redis: {stream_id_bytes}" ) + stream_id = None + + if stream_id is not None: + if self._channel_proxy_is_active(redis_client): + profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + try: + profile_id = int(profile_id_bytes) + logger.debug( + f"Channel {self.uuid}: reusing active assignment " + f"stream={stream_id} profile={profile_id}" + ) + return stream_id, profile_id, None + except (ValueError, TypeError): + logger.debug( + f"Invalid profile ID retrieved from Redis: {profile_id_bytes}" + ) + else: + logger.info( + f"Channel {self.uuid}: clearing stale stream assignment " + f"(stream={stream_id}, proxy not active)" + ) + self._clear_stream_assignment_keys(redis_client, stream_id) # No existing active stream, attempt to assign a new one has_streams_but_maxed_out = False @@ -697,14 +692,16 @@ class Channel(models.Model): has_active_profiles = True # Atomically check and reserve a slot (INCR-first pattern) - reserved, current_count = self._check_and_reserve_profile_slot( - profile, redis_client - ) + reserved, current_count = reserve_profile_slot(profile, redis_client) if reserved: # Slot reserved — assign stream to this channel redis_client.set(f"channel_stream:{self.id}", stream.id) redis_client.set(f"stream_profile:{stream.id}", profile.id) + logger.info( + f"Channel {self.uuid}: assigned stream {stream.id} " + f"profile {profile.id} ({profile.name})" + ) return ( stream.id, @@ -782,12 +779,11 @@ class Channel(models.Model): ChannelMetadataField.M3U_PROFILE, ) - profile_connections_key = f"profile_connections:{profile_id}" - current_count = int( - redis_client.get(profile_connections_key) or 0 - ) - if current_count > 0: - redis_client.decr(profile_connections_key) + stream = Stream.objects.select_related("m3u_account").filter( + id=stream_id + ).first() + m3u_account = stream.m3u_account if stream else None + release_profile_slot(profile_id, redis_client) return True logger.debug( @@ -832,12 +828,7 @@ class Channel(models.Model): f"stream {stream_id}" ) - profile_connections_key = f"profile_connections:{profile_id}" - - # Only decrement if the profile had a max_connections limit - current_count = int(redis_client.get(profile_connections_key) or 0) - if current_count > 0: - redis_client.decr(profile_connections_key) + release_profile_slot(profile_id, redis_client) # Clear metadata fields so duplicate release_stream() calls # (e.g. from _clean_redis_keys or ChannelService.stop_channel) diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py new file mode 100644 index 00000000..b06aff10 --- /dev/null +++ b/apps/m3u/connection_pool.py @@ -0,0 +1,299 @@ +""" +Shared connection pool enforcement for M3U accounts with identical credentials. + +All Redis INCR/DECR for profile and server-group limits should go through this +module so live TV and VOD stay consistent. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + +PROFILE_CONNECTIONS_KEY = "profile_connections:{profile_id}" +SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}" +EXCLUDE_FROM_POOL_KEY = "exclude_from_credential_pool" + +_XC_URL_CREDENTIALS_RE = re.compile( + r"/(?:live|movie|series)/([^/]+)/([^/]+)/", + re.IGNORECASE, +) + + +def profile_connections_key(profile_id: int) -> str: + return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id) + + +def server_group_connections_key(group_id: int) -> str: + return SERVER_GROUP_CONNECTIONS_KEY.format(group_id=group_id) + + +def compute_credential_fingerprint(username: str, password: str) -> Optional[str]: + """Return a stable hash for grouping accounts with the same IPTV login.""" + if not username or not password: + return None + normalized = f"{username.strip().lower()}\0{password.strip()}" + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def extract_credentials_from_stream_url(url: str) -> Tuple[Optional[str], Optional[str]]: + """Parse username/password embedded in an Xtream-style stream URL.""" + if not url: + return None, None + match = _XC_URL_CREDENTIALS_RE.search(url) + if not match: + return None, None + return match.group(1), match.group(2) + + +def _fingerprint_from_profile_stream_url(profile) -> Optional[str]: + """STD/M3U: fingerprint from a sample stream URL after profile rewrite.""" + from apps.channels.models import Stream + + sample_url = ( + Stream.objects.filter(m3u_account=profile.m3u_account) + .exclude(url="") + .values_list("url", flat=True) + .first() + ) + if not sample_url: + return None + + try: + from apps.proxy.live_proxy.url_utils import transform_url + + transformed = transform_url( + sample_url, + profile.search_pattern or "", + profile.replace_pattern or "", + ) + url_user, url_pass = extract_credentials_from_stream_url( + transformed or sample_url + ) + return compute_credential_fingerprint(url_user or "", url_pass or "") + except Exception as exc: + logger.debug( + "Could not derive profile %s fingerprint from stream URL: %s", + profile.pk, + exc, + ) + return None + + +def get_profile_credential_fingerprint(profile) -> Optional[str]: + """Fingerprint for credentials this profile uses at playback time.""" + m3u_account = profile.m3u_account + + if m3u_account.account_type == "XC": + try: + from apps.m3u.tasks import get_transformed_credentials + + _url, username, password = get_transformed_credentials(m3u_account, profile) + fingerprint = compute_credential_fingerprint(username or "", password or "") + if fingerprint: + return fingerprint + except Exception as exc: + logger.debug( + "Could not resolve transformed credentials for profile %s: %s", + profile.pk, + exc, + ) + + fingerprint = _fingerprint_from_profile_stream_url(profile) + if fingerprint: + return fingerprint + + return compute_credential_fingerprint( + m3u_account.username or "", + m3u_account.password or "", + ) + + +def account_excluded_from_pool(m3u_account) -> bool: + props = m3u_account.custom_properties or {} + return bool(props.get(EXCLUDE_FROM_POOL_KEY)) + + +def _get_pool_for_fingerprint(fingerprint: str): + """Return the auto credential pool ServerGroup for a fingerprint, if configured.""" + if not fingerprint: + return None + from apps.m3u.models import ServerGroup + + group = ServerGroup.objects.filter(credential_fingerprint=fingerprint).first() + if not group or group.max_streams == 0: + return None + return group + + +def get_enforced_server_group_for_profile(profile): + """Return the shared pool for this profile's effective provider login.""" + if account_excluded_from_pool(profile.m3u_account): + return None + + group = _get_pool_for_fingerprint(get_profile_credential_fingerprint(profile)) + if group: + return group + + manual = profile.m3u_account.server_group + if manual and not manual.credential_fingerprint and manual.max_streams > 0: + return manual + return None + + +def pool_has_capacity_for_profile(profile, redis_client) -> bool: + """Non-mutating check for the profile's credential pool.""" + group = get_enforced_server_group_for_profile(profile) + if not group: + return True + key = server_group_connections_key(group.id) + current = int(redis_client.get(key) or 0) + return current < group.max_streams + + +def _reserve_server_group_slot_for_profile(profile, redis_client) -> bool: + group = get_enforced_server_group_for_profile(profile) + if not group: + return True + key = server_group_connections_key(group.id) + group_count = redis_client.incr(key) + if group_count <= group.max_streams: + return True + redis_client.decr(key) + return False + + +def _release_server_group_slot_for_profile(profile, redis_client) -> None: + group = get_enforced_server_group_for_profile(profile) + if not group: + return + key = server_group_connections_key(group.id) + new_count = redis_client.decr(key) + if new_count < 0: + redis_client.set(key, 0) + + +def reserve_profile_slot(profile, redis_client) -> Tuple[bool, int]: + """ + Atomically reserve profile + shared pool slots (INCR-first). + + Returns (reserved, profile_count_after_attempt). + """ + if profile.max_streams == 0: + if _reserve_server_group_slot_for_profile(profile, redis_client): + return True, 0 + return False, 0 + + profile_key = profile_connections_key(profile.id) + new_count = redis_client.incr(profile_key) + + if new_count <= profile.max_streams: + if _reserve_server_group_slot_for_profile(profile, redis_client): + return True, new_count + redis_client.decr(profile_key) + return False, new_count - 1 + + redis_client.decr(profile_key) + return False, new_count - 1 + + +def release_profile_slot(profile_id: int, redis_client) -> None: + """Release profile and shared pool slots after a stream ends.""" + from apps.m3u.models import M3UAccountProfile + + try: + profile = M3UAccountProfile.objects.get(id=profile_id) + except M3UAccountProfile.DoesNotExist: + profile = None + + profile_key = profile_connections_key(profile_id) + if profile is None or profile.max_streams > 0: + current = int(redis_client.get(profile_key) or 0) + if current > 0: + redis_client.decr(profile_key) + + if profile: + _release_server_group_slot_for_profile(profile, redis_client) + + +def recompute_pool_max_streams(server_group) -> None: + """Set pool max_streams to the minimum positive limit across members and profiles.""" + from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup + + if not server_group.credential_fingerprint: + return + + fp = server_group.credential_fingerprint + limits = list( + M3UAccount.objects.filter( + server_group=server_group, max_streams__gt=0 + ).values_list("max_streams", flat=True) + ) + + for profile in M3UAccountProfile.objects.filter(is_active=True).select_related( + "m3u_account" + ): + if account_excluded_from_pool(profile.m3u_account): + continue + if get_profile_credential_fingerprint(profile) != fp: + continue + if profile.max_streams > 0: + limits.append(profile.max_streams) + + new_max = min(limits) if limits else 0 + if server_group.max_streams != new_max: + ServerGroup.objects.filter(pk=server_group.pk).update(max_streams=new_max) + + +def _ensure_pool_for_fingerprint(fingerprint: str): + from apps.m3u.models import ServerGroup + + group, _created = ServerGroup.objects.get_or_create( + credential_fingerprint=fingerprint, + defaults={ + "name": f"credential-pool-{fingerprint[:16]}", + "max_streams": 0, + }, + ) + recompute_pool_max_streams(group) + return group + + +def sync_account_credential_pool(m3u_account) -> None: + """ + Ensure auto credential pools exist for each distinct login on this account. + + Sets M3UAccount.server_group only when every active profile shares one login. + """ + from apps.m3u.models import M3UAccount, M3UAccountProfile + + if account_excluded_from_pool(m3u_account): + return + + if m3u_account.server_group_id: + existing = m3u_account.server_group + if existing and not existing.credential_fingerprint: + return + + profile_fps = set() + for profile in M3UAccountProfile.objects.filter( + m3u_account=m3u_account, is_active=True + ): + fp = get_profile_credential_fingerprint(profile) + if not fp: + continue + profile_fps.add(fp) + _ensure_pool_for_fingerprint(fp) + + if len(profile_fps) == 1: + group = _get_pool_for_fingerprint(next(iter(profile_fps))) + if group and m3u_account.server_group_id != group.id: + M3UAccount.objects.filter(pk=m3u_account.pk).update(server_group=group) + elif m3u_account.server_group_id: + existing = m3u_account.server_group + if existing and existing.credential_fingerprint: + M3UAccount.objects.filter(pk=m3u_account.pk).update(server_group=None) diff --git a/apps/m3u/migrations/0020_servergroup_max_streams.py b/apps/m3u/migrations/0020_servergroup_max_streams.py new file mode 100644 index 00000000..ce17d830 --- /dev/null +++ b/apps/m3u/migrations/0020_servergroup_max_streams.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.3 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("m3u", "0019_m3uaccountprofile_exp_date"), + ] + + operations = [ + migrations.AddField( + model_name="servergroup", + name="max_streams", + field=models.PositiveIntegerField( + default=0, + help_text="Maximum number of concurrent streams shared across all accounts in this group (0 for unlimited)", + ), + ), + ] diff --git a/apps/m3u/migrations/0021_servergroup_credential_fingerprint.py b/apps/m3u/migrations/0021_servergroup_credential_fingerprint.py new file mode 100644 index 00000000..8d958945 --- /dev/null +++ b/apps/m3u/migrations/0021_servergroup_credential_fingerprint.py @@ -0,0 +1,102 @@ +# Generated manually for shared credential connection pools (#1137) + +from django.db import migrations, models + +from apps.m3u.connection_pool import ( + compute_credential_fingerprint, + extract_credentials_from_stream_url, +) + + +def _historical_account_fingerprint(M3UAccount, M3UAccountProfile, Stream, account): + """Resolve fingerprint for migration using historical models only.""" + fingerprint = compute_credential_fingerprint( + account.username or "", + account.password or "", + ) + if fingerprint: + return fingerprint + + sample_url = ( + Stream.objects.filter(m3u_account_id=account.pk) + .exclude(url="") + .values_list("url", flat=True) + .first() + ) + if sample_url: + url_user, url_pass = extract_credentials_from_stream_url(sample_url) + return compute_credential_fingerprint(url_user or "", url_pass or "") + + return None + + +def backfill_credential_pools(apps, schema_editor): + from django.db.models import Min + + M3UAccount = apps.get_model("m3u", "M3UAccount") + ServerGroup = apps.get_model("m3u", "ServerGroup") + M3UAccountProfile = apps.get_model("m3u", "M3UAccountProfile") + Stream = apps.get_model("dispatcharr_channels", "Stream") + + seen = {} + for account in M3UAccount.objects.all(): + props = account.custom_properties or {} + if props.get("exclude_from_credential_pool"): + continue + if account.server_group_id: + group = ServerGroup.objects.filter(pk=account.server_group_id).first() + if group and not group.credential_fingerprint: + continue + + fingerprint = _historical_account_fingerprint( + M3UAccount, M3UAccountProfile, Stream, account + ) + if not fingerprint: + continue + + if fingerprint not in seen: + short = fingerprint[:16] + group, _ = ServerGroup.objects.get_or_create( + credential_fingerprint=fingerprint, + defaults={ + "name": f"credential-pool-{short}", + "max_streams": 0, + }, + ) + seen[fingerprint] = group + else: + group = seen[fingerprint] + + M3UAccount.objects.filter(pk=account.pk).update(server_group_id=group.pk) + + for group in seen.values(): + agg = M3UAccount.objects.filter( + server_group_id=group.pk, max_streams__gt=0 + ).aggregate(min_limit=Min("max_streams")) + new_max = agg["min_limit"] or 0 + if group.max_streams != new_max: + ServerGroup.objects.filter(pk=group.pk).update(max_streams=new_max) + + +class Migration(migrations.Migration): + + dependencies = [ + ("m3u", "0020_servergroup_max_streams"), + ("dispatcharr_channels", "0037_auto_sync_overhaul"), + ] + + operations = [ + migrations.AddField( + model_name="servergroup", + name="credential_fingerprint", + field=models.CharField( + blank=True, + db_index=True, + help_text="Auto-assigned hash for accounts sharing the same IPTV credentials", + max_length=64, + null=True, + unique=True, + ), + ), + migrations.RunPython(backfill_credential_pools, migrations.RunPython.noop), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 6fc48bec..4b1325d5 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -199,6 +199,18 @@ class ServerGroup(models.Model): name = models.CharField( max_length=100, unique=True, help_text="Unique name for this server group." ) + max_streams = models.PositiveIntegerField( + default=0, + help_text="Maximum number of concurrent streams shared across all accounts in this group (0 for unlimited)", + ) + credential_fingerprint = models.CharField( + max_length=64, + null=True, + blank=True, + unique=True, + db_index=True, + help_text="Auto-assigned hash for accounts sharing the same IPTV credentials", + ) def __str__(self): return self.name @@ -334,6 +346,26 @@ class M3UAccountProfile(models.Model): return None +@receiver(models.signals.post_save, sender=M3UAccount) +def assign_credential_pool_for_m3u_account(sender, instance, **kwargs): + """Link accounts with identical credentials into a shared connection pool.""" + if kwargs.get("raw"): + return + from apps.m3u.connection_pool import sync_account_credential_pool + + sync_account_credential_pool(instance) + + +@receiver(models.signals.post_save, sender=M3UAccountProfile) +def assign_credential_pool_for_m3u_profile(sender, instance, **kwargs): + """Re-sync pools when profile URL transforms change.""" + if kwargs.get("raw"): + return + from apps.m3u.connection_pool import sync_account_credential_pool + + sync_account_credential_pool(instance.m3u_account) + + @receiver(models.signals.post_save, sender=M3UAccount) def create_profile_for_m3u_account(sender, instance, created, **kwargs): """Automatically create an M3UAccountProfile when M3UAccount is created.""" diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 485a3687..29c4157f 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -408,4 +408,4 @@ class ServerGroupSerializer(serializers.ModelSerializer): class Meta: model = ServerGroup - fields = ["id", "name"] + fields = ["id", "name", "max_streams"] diff --git a/apps/m3u/tests/test_connection_pool.py b/apps/m3u/tests/test_connection_pool.py new file mode 100644 index 00000000..79285484 --- /dev/null +++ b/apps/m3u/tests/test_connection_pool.py @@ -0,0 +1,247 @@ +"""Tests for shared credential connection pools (#1137).""" + +from django.test import TestCase +from unittest.mock import patch + +from apps.m3u.connection_pool import ( + compute_credential_fingerprint, + extract_credentials_from_stream_url, + get_enforced_server_group_for_profile, + get_profile_credential_fingerprint, + pool_has_capacity_for_profile, + release_profile_slot, + reserve_profile_slot, + server_group_connections_key, + sync_account_credential_pool, +) +from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup + + +class FakeRedis: + """Minimal in-memory Redis stand-in for counter tests.""" + + def __init__(self): + self._data = {} + + def get(self, key): + val = self._data.get(key) + if val is None: + return None + return str(val).encode() + + def set(self, key, value, ex=None): + self._data[key] = int(value) + + def incr(self, key): + self._data[key] = self._data.get(key, 0) + 1 + return self._data[key] + + def decr(self, key): + self._data[key] = self._data.get(key, 0) - 1 + return self._data[key] + + +class CredentialFingerprintTests(TestCase): + def test_same_credentials_same_fingerprint(self): + fp1 = compute_credential_fingerprint("User", "pass") + fp2 = compute_credential_fingerprint("user", "pass") + self.assertEqual(fp1, fp2) + self.assertIsNotNone(fp1) + + def test_different_password_different_fingerprint(self): + fp1 = compute_credential_fingerprint("user", "pass1") + fp2 = compute_credential_fingerprint("user", "pass2") + self.assertNotEqual(fp1, fp2) + + def test_empty_credentials_returns_none(self): + self.assertIsNone(compute_credential_fingerprint("", "pass")) + self.assertIsNone(compute_credential_fingerprint("user", "")) + + def test_extract_credentials_from_xc_style_url(self): + url = "http://example.com/live/alice/secret123/99999.ts" + user, password = extract_credentials_from_stream_url(url) + self.assertEqual(user, "alice") + self.assertEqual(password, "secret123") + + +class AutoAssignTests(TestCase): + def test_accounts_with_same_credentials_share_server_group(self): + account1 = M3UAccount.objects.create( + name="Provider A", + account_type="XC", + username="user1", + password="secret", + server_url="http://a.example.com", + max_streams=2, + ) + account2 = M3UAccount.objects.create( + name="Provider B", + account_type="XC", + username="user1", + password="secret", + server_url="http://b.example.com", + max_streams=3, + ) + + account1.refresh_from_db() + account2.refresh_from_db() + + self.assertIsNotNone(account1.server_group_id) + self.assertEqual(account1.server_group_id, account2.server_group_id) + self.assertTrue(account1.server_group.credential_fingerprint) + self.assertEqual(account1.server_group.max_streams, 2) + + def test_exclude_from_pool_opt_out(self): + account1 = M3UAccount.objects.create( + name="Pooled", + account_type="XC", + username="shared", + password="secret", + max_streams=1, + ) + account2 = M3UAccount.objects.create( + name="Opt-out", + account_type="XC", + username="shared", + password="secret", + custom_properties={"exclude_from_credential_pool": True}, + max_streams=1, + ) + + account1.refresh_from_db() + account2.refresh_from_db() + + self.assertIsNotNone(account1.server_group_id) + self.assertIsNone(account2.server_group_id) + + +class MultiProfilePoolTests(TestCase): + def test_profiles_with_different_credentials_get_separate_pools(self): + account = M3UAccount.objects.create( + name="Multi-login XC", + account_type="XC", + username="xc_user_a", + password="xc_pass_a", + server_url="http://xc.example.com", + max_streams=1, + ) + base = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + base.search_pattern = r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$" + base.replace_pattern = r"http://xc.example.com/live/xc_user_a/xc_pass_a/\1" + base.save() + + p2 = M3UAccountProfile.objects.create( + m3u_account=account, + name="login_b", + is_default=False, + is_active=True, + max_streams=1, + search_pattern=r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$", + replace_pattern=r"http://xc.example.com/live/xc_user_b/xc_pass_b/\1", + ) + + sync_account_credential_pool(account) + + fp1 = get_profile_credential_fingerprint(base) + fp2 = get_profile_credential_fingerprint(p2) + self.assertNotEqual(fp1, fp2) + + g1 = get_enforced_server_group_for_profile(base) + g2 = get_enforced_server_group_for_profile(p2) + self.assertIsNotNone(g1) + self.assertIsNotNone(g2) + self.assertNotEqual(g1.id, g2.id) + self.assertIsNone(account.server_group_id) + + +class PoolEnforcementTests(TestCase): + def setUp(self): + self.redis = FakeRedis() + self.group = ServerGroup.objects.create( + name="test-pool", + max_streams=1, + ) + self.account = M3UAccount.objects.create( + name="Test Account", + account_type="XC", + username="user", + password="pass", + server_group=self.group, + max_streams=5, + ) + self.profile = M3UAccountProfile.objects.get( + m3u_account=self.account, is_default=True + ) + + def test_reserve_and_release_pool_counter(self): + reserved, count = reserve_profile_slot(self.profile, self.redis) + self.assertTrue(reserved) + self.assertEqual(count, 1) + + group_key = server_group_connections_key(self.group.id) + self.assertEqual(self.redis._data[group_key], 1) + + release_profile_slot(self.profile.id, self.redis) + self.assertEqual(self.redis._data[group_key], 0) + + def test_reserve_fails_when_pool_at_capacity(self): + group_key = server_group_connections_key(self.group.id) + self.redis.set(group_key, 1) + + reserved, _count = reserve_profile_slot(self.profile, self.redis) + self.assertFalse(reserved) + self.assertFalse(pool_has_capacity_for_profile(self.profile, self.redis)) + + def test_live_slot_blocks_second_reserve_same_profile(self): + reserved1, _ = reserve_profile_slot(self.profile, self.redis) + self.assertTrue(reserved1) + + reserved2, _ = reserve_profile_slot(self.profile, self.redis) + self.assertFalse(reserved2) + + +class VodProfileSelectionTests(TestCase): + """VOD must try alternate profiles when the default pool is full (live TV).""" + + def test_get_m3u_profile_skips_default_when_pool_full(self): + from apps.proxy.vod_proxy.views import _get_m3u_profile + + account = M3UAccount.objects.create( + name="VOD multi-login", + account_type="XC", + username="xc_user_a", + password="xc_pass_a", + server_url="http://xc.example.com", + max_streams=1, + ) + default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + default.search_pattern = ( + r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$" + ) + default.replace_pattern = ( + r"http://xc.example.com/live/xc_user_a/xc_pass_a/\1" + ) + default.save() + + alt = M3UAccountProfile.objects.create( + m3u_account=account, + name="login_b", + is_default=False, + is_active=True, + max_streams=1, + search_pattern=r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$", + replace_pattern=r"http://xc.example.com/live/xc_user_b/xc_pass_b/\1", + ) + + sync_account_credential_pool(account) + + redis = FakeRedis() + reserved, _ = reserve_profile_slot(default, redis) + self.assertTrue(reserved) + + with patch("core.utils.RedisClient.get_client", return_value=redis): + result = _get_m3u_profile(account, None, None) + + self.assertIsNotNone(result) + selected, _connections = result + self.assertEqual(selected.id, alt.id) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 3f75693e..6180f317 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -390,11 +390,12 @@ class ChannelStatus: created_at = float(init_time_bytes) uptime = time.time() - created_at if created_at > 0 else 0 + stream_url = metadata.get(ChannelMetadataField.URL, "") or "" # Simplified info info = { 'channel_id': channel_id, 'state': metadata.get(ChannelMetadataField.STATE), - 'url': metadata.get(ChannelMetadataField.URL, ""), + 'url': stream_url, 'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ""), 'owner': metadata.get(ChannelMetadataField.OWNER), 'buffer_index': int(buffer_index_value) if buffer_index_value else 0, @@ -402,6 +403,15 @@ class ChannelStatus: 'uptime': uptime, 'started_at': created_at if created_at > 0 else None, } + if stream_url: + try: + from apps.m3u.connection_pool import extract_credentials_from_stream_url + + provider_user, _ = extract_credentials_from_stream_url(stream_url) + if provider_user: + info['provider_username'] = provider_user + except Exception as e: + logger.debug(f"Could not parse provider username from stream URL: {e}") channel_name = metadata.get(ChannelMetadataField.CHANNEL_NAME) if channel_name: @@ -498,7 +508,15 @@ class ChannelStatus: m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE) if m3u_profile_id: try: - info['m3u_profile_id'] = int(m3u_profile_id) + profile_id_int = int(m3u_profile_id) + info['m3u_profile_id'] = profile_id_int + try: + from apps.m3u.models import M3UAccountProfile + m3u_profile = M3UAccountProfile.objects.filter(id=profile_id_int).first() + if m3u_profile: + info['m3u_profile_name'] = m3u_profile.name + except Exception as e: + logger.warning(f"Failed to get M3U profile name for ID {profile_id_int}: {e}") except ValueError: logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}") diff --git a/apps/proxy/live_proxy/url_utils.py b/apps/proxy/live_proxy/url_utils.py index 5227a3fa..083b0fd1 100644 --- a/apps/proxy/live_proxy/url_utils.py +++ b/apps/proxy/live_proxy/url_utils.py @@ -8,6 +8,7 @@ from typing import Optional, Tuple, List from django.shortcuts import get_object_or_404 from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccount, M3UAccountProfile +from apps.m3u.connection_pool import pool_has_capacity_for_profile from core.models import UserAgent, CoreSettings, StreamProfile from .utils import get_logger from uuid import UUID @@ -15,6 +16,35 @@ import requests logger = get_logger() + +def _resolve_live_stream_url(stream, m3u_account, m3u_profile): + """ + Build the upstream URL for live playback. + + XC accounts use current transformed credentials plus provider stream_id so + playback matches the account login (not a stale stream.url from an old sync). + STD/M3U accounts keep using the URL stored on the stream row. + """ + if ( + m3u_account.account_type == M3UAccount.Types.XC + and stream.stream_id + ): + from apps.m3u.tasks import get_transformed_credentials + + server_url, username, password = get_transformed_credentials( + m3u_account, m3u_profile + ) + if server_url and username and password: + base = server_url.rstrip("/") + return f"{base}/live/{username}/{password}/{stream.stream_id}.ts" + + return transform_url( + stream.url or "", + m3u_profile.search_pattern, + m3u_profile.replace_pattern, + ) + + def get_stream_object(id: str): try: logger.info(f"Fetching channel ID {id}") @@ -63,7 +93,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern) + stream_url = _resolve_live_stream_url(stream, m3u_account, profile) stream_profile = stream.get_stream_profile() logger.debug(f"Using stream profile: {stream_profile.name}") @@ -106,9 +136,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - # Generate stream URL based on the selected profile - input_url = stream.url - stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) + stream_url = _resolve_live_stream_url(stream, m3u_account, m3u_profile) # Check if transcoding is needed stream_profile = channel.get_stream_profile() @@ -204,35 +232,41 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] selected_profile = None for profile in profiles: - - # Check connection availability if redis_client: + if not pool_has_capacity_for_profile(profile, redis_client): + logger.debug( + f"Profile {profile.id} credential pool at capacity for stream switch" + ) + continue + profile_connections_key = f"profile_connections:{profile.id}" current_connections = int(redis_client.get(profile_connections_key) or 0) - # Check if this channel is already using this profile channel_using_profile = False existing_stream_id = redis_client.get(f"channel_stream:{channel.id}") if existing_stream_id: - # Decode bytes to string/int for proper Redis key lookup - existing_stream_id = existing_stream_id - existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}") + existing_profile_id = redis_client.get( + f"stream_profile:{existing_stream_id}" + ) if existing_profile_id and int(existing_profile_id) == profile.id: channel_using_profile = True - logger.debug(f"Channel {channel.id} already using profile {profile.id}") - # Calculate effective connections (subtract 1 if channel already using this profile) - effective_connections = current_connections - (1 if channel_using_profile else 0) + effective_connections = current_connections - ( + 1 if channel_using_profile else 0 + ) - # Check if profile has available slots if profile.max_streams == 0 or effective_connections < profile.max_streams: selected_profile = profile - logger.debug(f"Selected profile {profile.id} with {effective_connections}/{profile.max_streams} effective connections (current: {current_connections}, already using: {channel_using_profile})") + logger.debug( + f"Selected profile {profile.id} with " + f"{effective_connections}/{profile.max_streams} effective connections" + ) break - else: - logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})") + logger.debug( + f"Profile {profile.id} at max connections: " + f"{effective_connections}/{profile.max_streams}" + ) else: - # No Redis available, assume first active profile is okay selected_profile = profile break @@ -260,8 +294,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] # Get the user agent from the M3U account user_agent = m3u_account.get_user_agent().user_agent - # Generate URL using the transform function directly - stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern) + stream_url = _resolve_live_stream_url(stream, m3u_account, profile) # Get transcode info from the channel's stream profile stream_profile = channel.get_stream_profile() @@ -365,6 +398,12 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No # Check if profile has available slots if profile.max_streams == 0 or effective_connections < profile.max_streams: + if not pool_has_capacity_for_profile(profile, redis_client): + logger.debug( + f"Credential pool at capacity for profile " + f"{profile.id} (account {m3u_account.id})" + ) + continue selected_profile = profile logger.debug(f"Found available profile {profile.id} for stream {stream.id}: {effective_connections}/{profile.max_streams} effective (current: {current_connections}, already using: {channel_using_profile})") break diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 7d367531..77db3b1b 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -735,68 +735,33 @@ class MultiWorkerVODConnectionManager: """Get Redis key for tracking connections per profile - STANDARDIZED with TS proxy""" return f"profile_connections:{profile_id}" - def _check_profile_limits(self, m3u_profile) -> bool: - """Check if profile has available connection slots""" - if m3u_profile.max_streams == 0: # Unlimited - return True - - try: - profile_connections_key = self._get_profile_connections_key(m3u_profile.id) - current_connections = int(self.redis_client.get(profile_connections_key) or 0) - - logger.info(f"[PROFILE-CHECK] Profile {m3u_profile.id} has {current_connections}/{m3u_profile.max_streams} connections") - return current_connections < m3u_profile.max_streams - - except Exception as e: - logger.error(f"Error checking profile limits: {e}") - return False - def _check_and_reserve_profile_slot(self, m3u_profile) -> bool: """ Atomically check and reserve a connection slot for the given profile. - Uses an INCR-first-then-check pattern to eliminate the TOCTOU race - condition where separate GET > check > INCR operations could allow - concurrent requests to both pass the capacity check. - - For profiles with max_streams=0 (unlimited), no reservation is needed. - Returns: bool: True if slot was reserved (or unlimited), False if at capacity """ - if m3u_profile.max_streams == 0: # Unlimited - return True + from apps.m3u.connection_pool import reserve_profile_slot try: - profile_connections_key = self._get_profile_connections_key(m3u_profile.id) - - # Atomically increment first — single Redis command eliminates race window - new_count = self.redis_client.incr(profile_connections_key) - - if new_count <= m3u_profile.max_streams: - logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") - return True - - # Over capacity — roll back the increment - self.redis_client.decr(profile_connections_key) - logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") - return False + reserved, new_count = reserve_profile_slot(m3u_profile, self.redis_client) + if reserved: + logger.info( + f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: " + f"{new_count}/{m3u_profile.max_streams}" + ) + else: + logger.info( + f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: " + f"{new_count}/{m3u_profile.max_streams}" + ) + return reserved except Exception as e: logger.error(f"Error reserving profile slot: {e}") return False - def _increment_profile_connections(self, m3u_profile): - """Increment profile connection count""" - try: - profile_connections_key = self._get_profile_connections_key(m3u_profile.id) - new_count = self.redis_client.incr(profile_connections_key) - logger.info(f"[PROFILE-INCR] Profile {m3u_profile.id} connections: {new_count}") - return new_count - except Exception as e: - logger.error(f"Error incrementing profile connections: {e}") - return None - def _trigger_vod_stats_update(self): """Trigger a VOD stats WebSocket update in a background thread.""" threading.Thread(target=self._do_vod_stats_update, daemon=True).start() @@ -859,21 +824,14 @@ class MultiWorkerVODConnectionManager: logger.error(f"Failed to trigger VOD stats update: {e}") def _decrement_profile_connections(self, m3u_profile_id: int): - """Decrement profile connection count. + """Decrement profile and shared pool connection counters.""" + from apps.m3u.connection_pool import release_profile_slot - Uses a single atomic DECR (no GET-before-DECR) to avoid the race condition - where two concurrent decrements both pass a >0 guard and both fire, sending - the counter negative. If the counter would go below zero it is clamped to 0. - """ try: - profile_connections_key = self._get_profile_connections_key(m3u_profile_id) - new_count = self.redis_client.decr(profile_connections_key) - if new_count < 0: - self.redis_client.set(profile_connections_key, 0) - new_count = 0 - logger.warning(f"[PROFILE-DECR] Profile {m3u_profile_id} counter went negative, clamped to 0") - else: - logger.info(f"[PROFILE-DECR] Profile {m3u_profile_id} connections: {new_count}") + release_profile_slot(m3u_profile_id, self.redis_client) + profile_key = self._get_profile_connections_key(m3u_profile_id) + new_count = int(self.redis_client.get(profile_key) or 0) + logger.info(f"[PROFILE-DECR] Profile {m3u_profile_id} connections: {new_count}") return new_count except Exception as e: logger.error(f"Error decrementing profile connections: {e}") diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index f73f48b4..a7995107 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -178,6 +178,7 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): """ try: from core.utils import RedisClient + from apps.m3u.connection_pool import pool_has_capacity_for_profile redis_client = RedisClient.get_client() if not redis_client: @@ -214,7 +215,11 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): except Exception as e: logger.warning(f"[PROFILE-SELECTION] Error checking existing profile for session {session_id}: {e}") else: - logger.debug(f"[PROFILE-SELECTION] Session {session_id} exists but has no profile ID stored") # If specific profile requested, try to use it + logger.debug( + f"[PROFILE-SELECTION] Session {session_id} exists but has no profile ID stored" + ) + + # If specific profile requested, try to use it if profile_id: try: profile = M3UAccountProfile.objects.get( @@ -226,9 +231,11 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): profile_connections_key = f"profile_connections:{profile.id}" current_connections = int(redis_client.get(profile_connections_key) or 0) - if profile.max_streams == 0 or current_connections < profile.max_streams: + if (profile.max_streams == 0 or current_connections < profile.max_streams) and pool_has_capacity_for_profile(profile, redis_client): logger.info(f"[PROFILE-SELECTION] Using requested profile {profile.id}: {current_connections}/{profile.max_streams} connections") return (profile, current_connections) + elif not pool_has_capacity_for_profile(profile, redis_client): + logger.warning(f"[PROFILE-SELECTION] Shared credential pool at capacity for account {m3u_account.id}") else: logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}") except M3UAccountProfile.DoesNotExist: @@ -253,9 +260,15 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): current_connections = int(redis_client.get(profile_connections_key) or 0) # Check if profile has available connection slots - if profile.max_streams == 0 or current_connections < profile.max_streams: + if (profile.max_streams == 0 or current_connections < profile.max_streams) and pool_has_capacity_for_profile(profile, redis_client): logger.info(f"[PROFILE-SELECTION] Selected profile {profile.id} ({profile.name}): {current_connections}/{profile.max_streams} connections") return (profile, current_connections) + elif not pool_has_capacity_for_profile(profile, redis_client): + logger.debug( + f"[PROFILE-SELECTION] Credential pool at capacity for profile " + f"{profile.id}, trying next profile" + ) + continue else: logger.debug(f"[PROFILE-SELECTION] Profile {profile.id} at capacity: {current_connections}/{profile.max_streams}") @@ -791,17 +804,30 @@ def build_vod_stats_data(redis_client): # Get M3U profile information m3u_profile_info = {} m3u_profile_id = combined_data.get('m3u_profile_id') + stream_url_for_creds = ( + combined_data.get('final_url') + or combined_data.get('stream_url') + or '' + ) if m3u_profile_id: try: from apps.m3u.models import M3UAccountProfile + from apps.m3u.connection_pool import extract_credentials_from_stream_url + profile = M3UAccountProfile.objects.select_related('m3u_account').get(id=m3u_profile_id) m3u_profile_info = { 'profile_name': profile.name, 'account_name': profile.m3u_account.name, 'account_id': profile.m3u_account.id, 'max_streams': profile.m3u_account.max_streams, - 'm3u_profile_id': int(m3u_profile_id) + 'm3u_profile_id': int(m3u_profile_id), } + if stream_url_for_creds: + provider_user, _ = extract_credentials_from_stream_url( + stream_url_for_creds + ) + if provider_user: + m3u_profile_info['provider_username'] = provider_user except Exception as e: logger.warning(f"Could not fetch M3U profile {m3u_profile_id}: {e}") diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 30509a86..0e6e221b 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -547,6 +547,7 @@ const StreamConnectionCard = ({ channel.m3u_profile?.name || channel.m3u_profile_name || 'Unknown M3U Profile'; + const providerUsername = channel.provider_username; // Create select options for available streams const streamOptions = getStreamOptions(availableStreams, m3uAccountsMap); @@ -648,12 +649,21 @@ const StreamConnectionCard = ({ {/* M3U Profile on right - absolutely positioned */} - - - - {m3uProfileName} - - + + + + + {m3uProfileName} + + + {providerUsername && ( + + + Login: {providerUsername} + + + )} + {/* Channel Name on left */} diff --git a/frontend/src/components/cards/VodConnectionCard.jsx b/frontend/src/components/cards/VodConnectionCard.jsx index 90b4c034..6539426a 100644 --- a/frontend/src/components/cards/VodConnectionCard.jsx +++ b/frontend/src/components/cards/VodConnectionCard.jsx @@ -349,6 +349,13 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { {connection.m3u_profile.profile_name || 'Default Profile'} + {connection.m3u_profile.provider_username && ( + + + Login: {connection.m3u_profile.provider_username} + + + )} From bb9b95b4a4605674a6b8919997537bc08eb5ba0a Mon Sep 17 00:00:00 2001 From: Goldenfreddy0703 <62456796+Goldenfreddy0703@users.noreply.github.com> Date: Sat, 23 May 2026 20:18:45 -0400 Subject: [PATCH 02/11] Refactor connection pools to manual ServerGroups with profile rotation. Drop auto-fingerprint migration and restore per-profile selection for live/VOD. Enforce shared limits on reserve using login-scoped group counters, and add Server Groups UI for manual account assignment per maintainer feedback (#1137). Co-authored-by: Cursor --- apps/channels/models.py | 48 ++-- apps/m3u/connection_pool.py | 201 +++++--------- ...0021_servergroup_credential_fingerprint.py | 102 -------- apps/m3u/models.py | 28 -- apps/m3u/tests/test_connection_pool.py | 245 +++++++++--------- apps/proxy/live_proxy/url_utils.py | 65 ++--- apps/proxy/live_proxy/views.py | 17 +- apps/proxy/vod_proxy/views.py | 26 +- frontend/src/api.js | 54 ++++ frontend/src/components/forms/M3U.jsx | 24 ++ frontend/src/components/forms/ServerGroup.jsx | 90 +++++++ .../components/tables/ServerGroupsTable.jsx | 203 +++++++++++++++ frontend/src/pages/Settings.jsx | 16 ++ frontend/src/store/auth.jsx | 2 + frontend/src/store/serverGroups.jsx | 40 +++ frontend/src/utils/forms/M3uUtils.js | 4 + 16 files changed, 701 insertions(+), 464 deletions(-) delete mode 100644 apps/m3u/migrations/0021_servergroup_credential_fingerprint.py create mode 100644 frontend/src/components/forms/ServerGroup.jsx create mode 100644 frontend/src/components/tables/ServerGroupsTable.jsx create mode 100644 frontend/src/store/serverGroups.jsx diff --git a/apps/channels/models.py b/apps/channels/models.py index 59a2bc3d..38ebbbc7 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -210,13 +210,14 @@ class Stream(models.Model): Finds an available profile for this stream and reserves a connection slot. Returns: - Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + Tuple[Optional[int], Optional[int], Optional[str], bool]: + (stream_id, profile_id, error_reason, slot_reserved) """ redis_client = RedisClient.get_client() profile_id = redis_client.get(f"stream_profile:{self.id}") if profile_id: profile_id = int(profile_id) - return self.id, profile_id, None + return self.id, profile_id, None, False # Retrieve the M3U account associated with the stream. m3u_account = self.m3u_account @@ -238,9 +239,9 @@ class Stream(models.Model): if reserved: redis_client.set(f"channel_stream:{self.id}", self.id) redis_client.set(f"stream_profile:{self.id}", profile.id) - return self.id, profile.id, None + return self.id, profile.id, None, True - return None, None, "All active M3U profiles have reached maximum connection limits" + return None, None, "All active M3U profiles have reached maximum connection limits", False def release_stream(self): """ @@ -615,7 +616,8 @@ class Channel(models.Model): Finds an available stream for the requested channel and returns the selected stream and profile. Returns: - Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + Tuple[Optional[int], Optional[int], Optional[str], bool]: + (stream_id, profile_id, error_reason, slot_reserved) """ redis_client = RedisClient.get_client() error_reason = None @@ -623,11 +625,11 @@ class Channel(models.Model): # Check if this channel has any streams if not self.streams.exists(): error_reason = "No streams assigned to channel" - return None, None, error_reason + return None, None, error_reason, False # Reuse assignment only when this channel is still active in the proxy. - # Stale channel_stream keys after stop/disconnect caused every tune to - # reuse the default profile without re-running rotation or INCR. + # Stale channel_stream keys after stop/disconnect skip INCR and break pool + # accounting, which lets a second stream reach the provider and fail validation. stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") if stream_id_bytes: try: @@ -648,7 +650,7 @@ class Channel(models.Model): f"Channel {self.uuid}: reusing active assignment " f"stream={stream_id} profile={profile_id}" ) - return stream_id, profile_id, None + return stream_id, profile_id, None, False except (ValueError, TypeError): logger.debug( f"Invalid profile ID retrieved from Redis: {profile_id_bytes}" @@ -707,6 +709,7 @@ class Channel(models.Model): stream.id, profile.id, None, + True, ) # Return newly assigned stream and matched profile else: # At capacity: try to preempt a lower-impact channel on this profile @@ -735,7 +738,7 @@ class Channel(models.Model): else: error_reason = "No active profiles found for any assigned stream" - return None, None, error_reason + return None, None, error_reason, False def release_stream(self): """ @@ -779,10 +782,6 @@ class Channel(models.Model): ChannelMetadataField.M3U_PROFILE, ) - stream = Stream.objects.select_related("m3u_account").filter( - id=stream_id - ).first() - m3u_account = stream.m3u_account if stream else None release_profile_slot(profile_id, redis_client) return True @@ -874,10 +873,27 @@ class Channel(models.Model): if current_profile_id == new_profile_id: return True + from apps.m3u.models import M3UAccountProfile + from apps.m3u.connection_pool import ( + get_enforced_server_group_for_profile, + profile_connections_key, + ) + + new_profile = M3UAccountProfile.objects.get(id=new_profile_id) + if get_enforced_server_group_for_profile(new_profile): + # Shared group pool: one active stream uses one group slot regardless + # of which profile supplies the URL. + redis_client.set(f"stream_profile:{stream_id}", new_profile_id) + logger.info( + f"Updated stream {stream_id} profile from {current_profile_id} to " + f"{new_profile_id} (shared group pool unchanged)" + ) + return True + # Use pipeline for atomic profile switch to prevent counter drift # if an exception occurs between DECR and INCR - old_profile_connections_key = f"profile_connections:{current_profile_id}" - new_profile_connections_key = f"profile_connections:{new_profile_id}" + old_profile_connections_key = profile_connections_key(current_profile_id) + new_profile_connections_key = profile_connections_key(new_profile_id) old_count = int(redis_client.get(old_profile_connections_key) or 0) pipe = redis_client.pipeline() diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py index b06aff10..9817d86c 100644 --- a/apps/m3u/connection_pool.py +++ b/apps/m3u/connection_pool.py @@ -1,8 +1,10 @@ """ -Shared connection pool enforcement for M3U accounts with identical credentials. +Shared connection pool enforcement for M3U accounts in the same ServerGroup. -All Redis INCR/DECR for profile and server-group limits should go through this -module so live TV and VOD stay consistent. +Profile selection rotates across M3UAccountProfile rows using each profile's own +Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup +with max_streams > 0, the group counter is scoped by provider login fingerprint +so profiles that rewrite to different IPTV credentials keep independent limits. """ from __future__ import annotations @@ -15,8 +17,7 @@ from typing import Optional, Tuple logger = logging.getLogger(__name__) PROFILE_CONNECTIONS_KEY = "profile_connections:{profile_id}" -SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}" -EXCLUDE_FROM_POOL_KEY = "exclude_from_credential_pool" +SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}:{fingerprint}" _XC_URL_CREDENTIALS_RE = re.compile( r"/(?:live|movie|series)/([^/]+)/([^/]+)/", @@ -28,8 +29,10 @@ def profile_connections_key(profile_id: int) -> str: return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id) -def server_group_connections_key(group_id: int) -> str: - return SERVER_GROUP_CONNECTIONS_KEY.format(group_id=group_id) +def server_group_connections_key(group_id: int, fingerprint: Optional[str] = None) -> str: + """Redis key for a manual ServerGroup slot, scoped by provider login.""" + fp = (fingerprint or "unknown")[:16] + return SERVER_GROUP_CONNECTIONS_KEY.format(group_id=group_id, fingerprint=fp) def compute_credential_fingerprint(username: str, password: str) -> Optional[str]: @@ -113,53 +116,58 @@ def get_profile_credential_fingerprint(profile) -> Optional[str]: ) -def account_excluded_from_pool(m3u_account) -> bool: - props = m3u_account.custom_properties or {} - return bool(props.get(EXCLUDE_FROM_POOL_KEY)) - - -def _get_pool_for_fingerprint(fingerprint: str): - """Return the auto credential pool ServerGroup for a fingerprint, if configured.""" - if not fingerprint: - return None - from apps.m3u.models import ServerGroup - - group = ServerGroup.objects.filter(credential_fingerprint=fingerprint).first() - if not group or group.max_streams == 0: - return None - return group - - def get_enforced_server_group_for_profile(profile): - """Return the shared pool for this profile's effective provider login.""" - if account_excluded_from_pool(profile.m3u_account): - return None - - group = _get_pool_for_fingerprint(get_profile_credential_fingerprint(profile)) - if group: + """Return the shared ServerGroup limit for this profile's account, if configured.""" + group = profile.m3u_account.server_group + if group and group.max_streams > 0: return group - - manual = profile.m3u_account.server_group - if manual and not manual.credential_fingerprint and manual.max_streams > 0: - return manual return None -def pool_has_capacity_for_profile(profile, redis_client) -> bool: - """Non-mutating check for the profile's credential pool.""" +def _group_counter_key(profile, group) -> str: + return server_group_connections_key( + group.id, + get_profile_credential_fingerprint(profile), + ) + + +def get_profile_connection_count(profile, redis_client) -> int: + return int(redis_client.get(profile_connections_key(profile.id)) or 0) + + +def get_group_connection_count(profile, redis_client) -> int: + group = get_enforced_server_group_for_profile(profile) + if not group: + return 0 + return int(redis_client.get(_group_counter_key(profile, group)) or 0) + + +def profile_has_capacity_for_selection(profile, redis_client) -> bool: + """Per-profile capacity check used when rotating across profiles on one account.""" + if profile.max_streams == 0: + return True + return get_profile_connection_count(profile, redis_client) < profile.max_streams + + +def group_has_capacity_for_profile(profile, redis_client) -> bool: group = get_enforced_server_group_for_profile(profile) if not group: return True - key = server_group_connections_key(group.id) - current = int(redis_client.get(key) or 0) - return current < group.max_streams + return get_group_connection_count(profile, redis_client) < group.max_streams + + +def pool_has_capacity_for_profile(profile, redis_client) -> bool: + """Non-mutating check before reserve: profile slot and group slot if applicable.""" + return profile_has_capacity_for_selection(profile, redis_client) and group_has_capacity_for_profile( + profile, redis_client + ) def _reserve_server_group_slot_for_profile(profile, redis_client) -> bool: group = get_enforced_server_group_for_profile(profile) if not group: return True - key = server_group_connections_key(group.id) + key = _group_counter_key(profile, group) group_count = redis_client.incr(key) if group_count <= group.max_streams: return True @@ -171,7 +179,10 @@ def _release_server_group_slot_for_profile(profile, redis_client) -> None: group = get_enforced_server_group_for_profile(profile) if not group: return - key = server_group_connections_key(group.id) + key = _group_counter_key(profile, group) + current = int(redis_client.get(key) or 0) + if current <= 0: + return new_count = redis_client.decr(key) if new_count < 0: redis_client.set(key, 0) @@ -179,30 +190,29 @@ def _release_server_group_slot_for_profile(profile, redis_client) -> None: def reserve_profile_slot(profile, redis_client) -> Tuple[bool, int]: """ - Atomically reserve profile + shared pool slots (INCR-first). + Atomically reserve profile + optional group slots (INCR-first). Returns (reserved, profile_count_after_attempt). """ - if profile.max_streams == 0: - if _reserve_server_group_slot_for_profile(profile, redis_client): - return True, 0 - return False, 0 - profile_key = profile_connections_key(profile.id) - new_count = redis_client.incr(profile_key) + profile_count = 0 - if new_count <= profile.max_streams: - if _reserve_server_group_slot_for_profile(profile, redis_client): - return True, new_count - redis_client.decr(profile_key) - return False, new_count - 1 + if profile.max_streams > 0: + profile_count = redis_client.incr(profile_key) + if profile_count > profile.max_streams: + redis_client.decr(profile_key) + return False, profile_count - 1 - redis_client.decr(profile_key) - return False, new_count - 1 + if not _reserve_server_group_slot_for_profile(profile, redis_client): + if profile.max_streams > 0: + redis_client.decr(profile_key) + return False, profile_count - 1 if profile.max_streams > 0 else 0 + + return True, profile_count def release_profile_slot(profile_id: int, redis_client) -> None: - """Release profile and shared pool slots after a stream ends.""" + """Release profile and shared group slots after a stream ends.""" from apps.m3u.models import M3UAccountProfile try: @@ -218,82 +228,3 @@ def release_profile_slot(profile_id: int, redis_client) -> None: if profile: _release_server_group_slot_for_profile(profile, redis_client) - - -def recompute_pool_max_streams(server_group) -> None: - """Set pool max_streams to the minimum positive limit across members and profiles.""" - from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup - - if not server_group.credential_fingerprint: - return - - fp = server_group.credential_fingerprint - limits = list( - M3UAccount.objects.filter( - server_group=server_group, max_streams__gt=0 - ).values_list("max_streams", flat=True) - ) - - for profile in M3UAccountProfile.objects.filter(is_active=True).select_related( - "m3u_account" - ): - if account_excluded_from_pool(profile.m3u_account): - continue - if get_profile_credential_fingerprint(profile) != fp: - continue - if profile.max_streams > 0: - limits.append(profile.max_streams) - - new_max = min(limits) if limits else 0 - if server_group.max_streams != new_max: - ServerGroup.objects.filter(pk=server_group.pk).update(max_streams=new_max) - - -def _ensure_pool_for_fingerprint(fingerprint: str): - from apps.m3u.models import ServerGroup - - group, _created = ServerGroup.objects.get_or_create( - credential_fingerprint=fingerprint, - defaults={ - "name": f"credential-pool-{fingerprint[:16]}", - "max_streams": 0, - }, - ) - recompute_pool_max_streams(group) - return group - - -def sync_account_credential_pool(m3u_account) -> None: - """ - Ensure auto credential pools exist for each distinct login on this account. - - Sets M3UAccount.server_group only when every active profile shares one login. - """ - from apps.m3u.models import M3UAccount, M3UAccountProfile - - if account_excluded_from_pool(m3u_account): - return - - if m3u_account.server_group_id: - existing = m3u_account.server_group - if existing and not existing.credential_fingerprint: - return - - profile_fps = set() - for profile in M3UAccountProfile.objects.filter( - m3u_account=m3u_account, is_active=True - ): - fp = get_profile_credential_fingerprint(profile) - if not fp: - continue - profile_fps.add(fp) - _ensure_pool_for_fingerprint(fp) - - if len(profile_fps) == 1: - group = _get_pool_for_fingerprint(next(iter(profile_fps))) - if group and m3u_account.server_group_id != group.id: - M3UAccount.objects.filter(pk=m3u_account.pk).update(server_group=group) - elif m3u_account.server_group_id: - existing = m3u_account.server_group - if existing and existing.credential_fingerprint: - M3UAccount.objects.filter(pk=m3u_account.pk).update(server_group=None) diff --git a/apps/m3u/migrations/0021_servergroup_credential_fingerprint.py b/apps/m3u/migrations/0021_servergroup_credential_fingerprint.py deleted file mode 100644 index 8d958945..00000000 --- a/apps/m3u/migrations/0021_servergroup_credential_fingerprint.py +++ /dev/null @@ -1,102 +0,0 @@ -# Generated manually for shared credential connection pools (#1137) - -from django.db import migrations, models - -from apps.m3u.connection_pool import ( - compute_credential_fingerprint, - extract_credentials_from_stream_url, -) - - -def _historical_account_fingerprint(M3UAccount, M3UAccountProfile, Stream, account): - """Resolve fingerprint for migration using historical models only.""" - fingerprint = compute_credential_fingerprint( - account.username or "", - account.password or "", - ) - if fingerprint: - return fingerprint - - sample_url = ( - Stream.objects.filter(m3u_account_id=account.pk) - .exclude(url="") - .values_list("url", flat=True) - .first() - ) - if sample_url: - url_user, url_pass = extract_credentials_from_stream_url(sample_url) - return compute_credential_fingerprint(url_user or "", url_pass or "") - - return None - - -def backfill_credential_pools(apps, schema_editor): - from django.db.models import Min - - M3UAccount = apps.get_model("m3u", "M3UAccount") - ServerGroup = apps.get_model("m3u", "ServerGroup") - M3UAccountProfile = apps.get_model("m3u", "M3UAccountProfile") - Stream = apps.get_model("dispatcharr_channels", "Stream") - - seen = {} - for account in M3UAccount.objects.all(): - props = account.custom_properties or {} - if props.get("exclude_from_credential_pool"): - continue - if account.server_group_id: - group = ServerGroup.objects.filter(pk=account.server_group_id).first() - if group and not group.credential_fingerprint: - continue - - fingerprint = _historical_account_fingerprint( - M3UAccount, M3UAccountProfile, Stream, account - ) - if not fingerprint: - continue - - if fingerprint not in seen: - short = fingerprint[:16] - group, _ = ServerGroup.objects.get_or_create( - credential_fingerprint=fingerprint, - defaults={ - "name": f"credential-pool-{short}", - "max_streams": 0, - }, - ) - seen[fingerprint] = group - else: - group = seen[fingerprint] - - M3UAccount.objects.filter(pk=account.pk).update(server_group_id=group.pk) - - for group in seen.values(): - agg = M3UAccount.objects.filter( - server_group_id=group.pk, max_streams__gt=0 - ).aggregate(min_limit=Min("max_streams")) - new_max = agg["min_limit"] or 0 - if group.max_streams != new_max: - ServerGroup.objects.filter(pk=group.pk).update(max_streams=new_max) - - -class Migration(migrations.Migration): - - dependencies = [ - ("m3u", "0020_servergroup_max_streams"), - ("dispatcharr_channels", "0037_auto_sync_overhaul"), - ] - - operations = [ - migrations.AddField( - model_name="servergroup", - name="credential_fingerprint", - field=models.CharField( - blank=True, - db_index=True, - help_text="Auto-assigned hash for accounts sharing the same IPTV credentials", - max_length=64, - null=True, - unique=True, - ), - ), - migrations.RunPython(backfill_credential_pools, migrations.RunPython.noop), - ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 4b1325d5..e6ccaadb 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -203,14 +203,6 @@ class ServerGroup(models.Model): default=0, help_text="Maximum number of concurrent streams shared across all accounts in this group (0 for unlimited)", ) - credential_fingerprint = models.CharField( - max_length=64, - null=True, - blank=True, - unique=True, - db_index=True, - help_text="Auto-assigned hash for accounts sharing the same IPTV credentials", - ) def __str__(self): return self.name @@ -346,26 +338,6 @@ class M3UAccountProfile(models.Model): return None -@receiver(models.signals.post_save, sender=M3UAccount) -def assign_credential_pool_for_m3u_account(sender, instance, **kwargs): - """Link accounts with identical credentials into a shared connection pool.""" - if kwargs.get("raw"): - return - from apps.m3u.connection_pool import sync_account_credential_pool - - sync_account_credential_pool(instance) - - -@receiver(models.signals.post_save, sender=M3UAccountProfile) -def assign_credential_pool_for_m3u_profile(sender, instance, **kwargs): - """Re-sync pools when profile URL transforms change.""" - if kwargs.get("raw"): - return - from apps.m3u.connection_pool import sync_account_credential_pool - - sync_account_credential_pool(instance.m3u_account) - - @receiver(models.signals.post_save, sender=M3UAccount) def create_profile_for_m3u_account(sender, instance, created, **kwargs): """Automatically create an M3UAccountProfile when M3UAccount is created.""" diff --git a/apps/m3u/tests/test_connection_pool.py b/apps/m3u/tests/test_connection_pool.py index 79285484..44281211 100644 --- a/apps/m3u/tests/test_connection_pool.py +++ b/apps/m3u/tests/test_connection_pool.py @@ -1,18 +1,21 @@ -"""Tests for shared credential connection pools (#1137).""" +"""Tests for shared ServerGroup connection pools (#1137).""" from django.test import TestCase from unittest.mock import patch from apps.m3u.connection_pool import ( - compute_credential_fingerprint, extract_credentials_from_stream_url, get_enforced_server_group_for_profile, + get_group_connection_count, + get_profile_connection_count, get_profile_credential_fingerprint, + group_has_capacity_for_profile, pool_has_capacity_for_profile, + profile_has_capacity_for_selection, + profile_connections_key, release_profile_slot, reserve_profile_slot, server_group_connections_key, - sync_account_credential_pool, ) from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup @@ -41,22 +44,7 @@ class FakeRedis: return self._data[key] -class CredentialFingerprintTests(TestCase): - def test_same_credentials_same_fingerprint(self): - fp1 = compute_credential_fingerprint("User", "pass") - fp2 = compute_credential_fingerprint("user", "pass") - self.assertEqual(fp1, fp2) - self.assertIsNotNone(fp1) - - def test_different_password_different_fingerprint(self): - fp1 = compute_credential_fingerprint("user", "pass1") - fp2 = compute_credential_fingerprint("user", "pass2") - self.assertNotEqual(fp1, fp2) - - def test_empty_credentials_returns_none(self): - self.assertIsNone(compute_credential_fingerprint("", "pass")) - self.assertIsNone(compute_credential_fingerprint("user", "")) - +class ExtractCredentialsTests(TestCase): def test_extract_credentials_from_xc_style_url(self): url = "http://example.com/live/alice/secret123/99999.ts" user, password = extract_credentials_from_stream_url(url) @@ -64,94 +52,76 @@ class CredentialFingerprintTests(TestCase): self.assertEqual(password, "secret123") -class AutoAssignTests(TestCase): - def test_accounts_with_same_credentials_share_server_group(self): - account1 = M3UAccount.objects.create( - name="Provider A", - account_type="XC", - username="user1", - password="secret", - server_url="http://a.example.com", - max_streams=2, - ) - account2 = M3UAccount.objects.create( - name="Provider B", - account_type="XC", - username="user1", - password="secret", - server_url="http://b.example.com", - max_streams=3, - ) - - account1.refresh_from_db() - account2.refresh_from_db() - - self.assertIsNotNone(account1.server_group_id) - self.assertEqual(account1.server_group_id, account2.server_group_id) - self.assertTrue(account1.server_group.credential_fingerprint) - self.assertEqual(account1.server_group.max_streams, 2) - - def test_exclude_from_pool_opt_out(self): - account1 = M3UAccount.objects.create( - name="Pooled", - account_type="XC", - username="shared", - password="secret", - max_streams=1, - ) - account2 = M3UAccount.objects.create( - name="Opt-out", - account_type="XC", - username="shared", - password="secret", - custom_properties={"exclude_from_credential_pool": True}, - max_streams=1, - ) - - account1.refresh_from_db() - account2.refresh_from_db() - - self.assertIsNotNone(account1.server_group_id) - self.assertIsNone(account2.server_group_id) - - -class MultiProfilePoolTests(TestCase): - def test_profiles_with_different_credentials_get_separate_pools(self): +class ManualServerGroupTests(TestCase): + def test_group_enforced_when_max_streams_set(self): + group = ServerGroup.objects.create(name="provider-a", max_streams=2) account = M3UAccount.objects.create( - name="Multi-login XC", + name="Account A", account_type="XC", - username="xc_user_a", - password="xc_pass_a", + username="user", + password="pass", + server_group=group, + ) + profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + + self.assertEqual(get_enforced_server_group_for_profile(profile), group) + + def test_accounts_in_same_group_share_counter(self): + group = ServerGroup.objects.create(name="shared", max_streams=1) + account1 = M3UAccount.objects.create( + name="XC Account", + account_type="XC", + username="user", + password="pass", server_url="http://xc.example.com", + server_group=group, + max_streams=5, + ) + account2 = M3UAccount.objects.create( + name="M3U Account", + account_type="STD", + username="user", + password="pass", + server_group=group, + max_streams=5, + ) + profile1 = M3UAccountProfile.objects.get(m3u_account=account1, is_default=True) + profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True) + + redis = FakeRedis() + reserved1, _ = reserve_profile_slot(profile1, redis) + self.assertTrue(reserved1) + + reserved2, _ = reserve_profile_slot(profile2, redis) + self.assertFalse(reserved2) + self.assertFalse(group_has_capacity_for_profile(profile2, redis)) + + def test_profile_rotation_when_default_profile_full(self): + """Pre-pool behavior: try the next profile on the same account.""" + account = M3UAccount.objects.create( + name="Multi-profile", + account_type="XC", max_streams=1, ) - base = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) - base.search_pattern = r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$" - base.replace_pattern = r"http://xc.example.com/live/xc_user_a/xc_pass_a/\1" - base.save() + default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + default.max_streams = 1 + default.save() - p2 = M3UAccountProfile.objects.create( + alt = M3UAccountProfile.objects.create( m3u_account=account, - name="login_b", + name="alt_profile", is_default=False, is_active=True, max_streams=1, - search_pattern=r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$", - replace_pattern=r"http://xc.example.com/live/xc_user_b/xc_pass_b/\1", + search_pattern="", + replace_pattern="", ) - sync_account_credential_pool(account) - - fp1 = get_profile_credential_fingerprint(base) - fp2 = get_profile_credential_fingerprint(p2) - self.assertNotEqual(fp1, fp2) - - g1 = get_enforced_server_group_for_profile(base) - g2 = get_enforced_server_group_for_profile(p2) - self.assertIsNotNone(g1) - self.assertIsNotNone(g2) - self.assertNotEqual(g1.id, g2.id) - self.assertIsNone(account.server_group_id) + redis = FakeRedis() + reserved, _ = reserve_profile_slot(default, redis) + self.assertTrue(reserved) + self.assertFalse(profile_has_capacity_for_selection(default, redis)) + self.assertTrue(profile_has_capacity_for_selection(alt, redis)) class PoolEnforcementTests(TestCase): @@ -172,42 +142,86 @@ class PoolEnforcementTests(TestCase): self.profile = M3UAccountProfile.objects.get( m3u_account=self.account, is_default=True ) + self.profile.max_streams = 5 + self.profile.save() - def test_reserve_and_release_pool_counter(self): + def test_reserve_and_release_both_counters(self): reserved, count = reserve_profile_slot(self.profile, self.redis) self.assertTrue(reserved) self.assertEqual(count, 1) - group_key = server_group_connections_key(self.group.id) + group_key = server_group_connections_key( + self.group.id, + get_profile_credential_fingerprint(self.profile), + ) + profile_key = profile_connections_key(self.profile.id) self.assertEqual(self.redis._data[group_key], 1) + self.assertEqual(self.redis._data[profile_key], 1) release_profile_slot(self.profile.id, self.redis) self.assertEqual(self.redis._data[group_key], 0) + self.assertEqual(self.redis._data[profile_key], 0) - def test_reserve_fails_when_pool_at_capacity(self): - group_key = server_group_connections_key(self.group.id) + def test_reserve_fails_when_group_at_capacity(self): + group_key = server_group_connections_key( + self.group.id, + get_profile_credential_fingerprint(self.profile), + ) self.redis.set(group_key, 1) reserved, _count = reserve_profile_slot(self.profile, self.redis) self.assertFalse(reserved) self.assertFalse(pool_has_capacity_for_profile(self.profile, self.redis)) - def test_live_slot_blocks_second_reserve_same_profile(self): - reserved1, _ = reserve_profile_slot(self.profile, self.redis) - self.assertTrue(reserved1) + def test_different_logins_in_group_do_not_block_each_other(self): + """Profiles with different provider logins keep separate group counters.""" + account = M3UAccount.objects.create( + name="Grouped multi-login", + account_type="XC", + username="login_a", + password="pass_a", + server_group=self.group, + max_streams=5, + ) + default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + default.max_streams = 1 + default.save() - reserved2, _ = reserve_profile_slot(self.profile, self.redis) - self.assertFalse(reserved2) + alt = M3UAccountProfile.objects.create( + m3u_account=account, + name="alt_login", + is_default=False, + is_active=True, + max_streams=1, + search_pattern="", + replace_pattern="", + ) + + fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + with patch( + "apps.m3u.connection_pool.get_profile_credential_fingerprint", + side_effect=lambda profile: fp_a if profile.id == default.id else fp_b, + ): + reserved_default, _ = reserve_profile_slot(default, self.redis) + self.assertTrue(reserved_default) + + reserved_alt, _ = reserve_profile_slot(alt, self.redis) + self.assertTrue(reserved_alt) + + key_a = server_group_connections_key(self.group.id, fp_a) + key_b = server_group_connections_key(self.group.id, fp_b) + self.assertEqual(self.redis._data[key_a], 1) + self.assertEqual(self.redis._data[key_b], 1) class VodProfileSelectionTests(TestCase): - """VOD must try alternate profiles when the default pool is full (live TV).""" - - def test_get_m3u_profile_skips_default_when_pool_full(self): + def test_get_m3u_profile_skips_default_when_profile_full(self): from apps.proxy.vod_proxy.views import _get_m3u_profile account = M3UAccount.objects.create( - name="VOD multi-login", + name="VOD multi-profile", account_type="XC", username="xc_user_a", password="xc_pass_a", @@ -215,26 +229,19 @@ class VodProfileSelectionTests(TestCase): max_streams=1, ) default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) - default.search_pattern = ( - r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$" - ) - default.replace_pattern = ( - r"http://xc.example.com/live/xc_user_a/xc_pass_a/\1" - ) + default.max_streams = 1 default.save() alt = M3UAccountProfile.objects.create( m3u_account=account, - name="login_b", + name="alt_profile", is_default=False, is_active=True, max_streams=1, - search_pattern=r"^http://xc\.example\.com/live/xc_user_a/xc_pass_a/(.*)$", - replace_pattern=r"http://xc.example.com/live/xc_user_b/xc_pass_b/\1", + search_pattern="", + replace_pattern="", ) - sync_account_credential_pool(account) - redis = FakeRedis() reserved, _ = reserve_profile_slot(default, redis) self.assertTrue(reserved) diff --git a/apps/proxy/live_proxy/url_utils.py b/apps/proxy/live_proxy/url_utils.py index 083b0fd1..60d0e3d0 100644 --- a/apps/proxy/live_proxy/url_utils.py +++ b/apps/proxy/live_proxy/url_utils.py @@ -8,7 +8,7 @@ from typing import Optional, Tuple, List from django.shortcuts import get_object_or_404 from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccount, M3UAccountProfile -from apps.m3u.connection_pool import pool_has_capacity_for_profile +from apps.m3u.connection_pool import profile_connections_key from core.models import UserAgent, CoreSettings, StreamProfile from .utils import get_logger from uuid import UUID @@ -54,15 +54,12 @@ def get_stream_object(id: str): logger.info(f"Fetching stream hash {id}") return get_object_or_404(Stream, stream_hash=id) -def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]: +def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int], bool]: """ Generate the appropriate stream URL for a channel or stream based on its profile settings. - Args: - channel_id: The UUID of the channel or stream hash - Returns: - Tuple[str, str, bool, Optional[int]]: (stream_url, user_agent, transcode_flag, profile_id) + Tuple: (stream_url, user_agent, transcode_flag, profile_id, slot_reserved) """ try: channel_or_stream = get_stream_object(channel_id) @@ -74,15 +71,12 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] if not stream.m3u_account: logger.error(f"Stream {stream.id} has no M3U account") - return None, None, False, None + return None, None, False, None, False - # Use get_stream() to atomically reserve a slot and write the - # channel_stream / stream_profile Redis keys, matching the channel - # path so stream_name and stream_stats work correctly. - stream_id, profile_id, error_reason = stream.get_stream() + stream_id, profile_id, error_reason, slot_reserved = stream.get_stream() if not stream_id or not profile_id: logger.error(f"No profile available for stream {stream.id}: {error_reason}") - return None, None, False, None + return None, None, False, None, False try: profile = M3UAccountProfile.objects.get(id=profile_id) @@ -101,11 +95,12 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] transcode = not stream_profile.is_proxy() stream_profile_id = stream_profile.id - return stream_url, stream_user_agent, transcode, stream_profile_id + return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved except Exception as e: logger.error(f"Error generating stream URL for stream {stream.id}: {e}") - stream.release_stream() - return None, None, False, None + if slot_reserved: + stream.release_stream() + return None, None, False, None, False # Handle channel preview (existing logic) @@ -113,11 +108,11 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] # Get stream and profile for this channel # Note: get_stream now returns 3 values (stream_id, profile_id, error_reason) - stream_id, profile_id, error_reason = channel.get_stream() + stream_id, profile_id, error_reason, slot_reserved = channel.get_stream() if not stream_id or not profile_id: logger.error(f"No stream available for channel {channel_id}: {error_reason}") - return None, None, False, None + return None, None, False, None, False # get_stream() allocated a connection slot - ensure it's released on any error try: @@ -147,15 +142,16 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] stream_profile_id = stream_profile.id - return stream_url, stream_user_agent, transcode, stream_profile_id + return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved except Exception as e: logger.error(f"Error generating stream URL for channel {channel_id}: {e}") - if not channel.release_stream(): - logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error") - return None, None, False, None + if slot_reserved: + if not channel.release_stream(): + logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error") + return None, None, False, None, False except Exception as e: logger.error(f"Error generating stream URL: {e}") - return None, None, False, None + return None, None, False, None, False def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str: """ @@ -233,14 +229,9 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] selected_profile = None for profile in profiles: if redis_client: - if not pool_has_capacity_for_profile(profile, redis_client): - logger.debug( - f"Profile {profile.id} credential pool at capacity for stream switch" - ) - continue - - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) + current_connections = int( + redis_client.get(profile_connections_key(profile.id)) or 0 + ) channel_using_profile = False existing_stream_id = redis_client.get(f"channel_stream:{channel.id}") @@ -275,7 +266,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] m3u_profile_id = selected_profile.id else: - stream_id, m3u_profile_id, error_reason = channel.get_stream() + stream_id, m3u_profile_id, error_reason, _slot_reserved = channel.get_stream() if stream_id is None or m3u_profile_id is None: return {'error': error_reason or 'No stream assigned to channel'} @@ -398,14 +389,12 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No # Check if profile has available slots if profile.max_streams == 0 or effective_connections < profile.max_streams: - if not pool_has_capacity_for_profile(profile, redis_client): - logger.debug( - f"Credential pool at capacity for profile " - f"{profile.id} (account {m3u_account.id})" - ) - continue selected_profile = profile - logger.debug(f"Found available profile {profile.id} for stream {stream.id}: {effective_connections}/{profile.max_streams} effective (current: {current_connections}, already using: {channel_using_profile})") + logger.debug( + f"Found available profile {profile.id} for stream {stream.id}: " + f"{effective_connections}/{profile.max_streams} effective " + f"(current: {current_connections}, already using: {channel_using_profile})" + ) break else: logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})") diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..e9e1b8ab 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -213,6 +213,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): stream_user_agent = None transcode = False profile_value = None + slot_reserved = False error_reason = None attempt = 0 should_retry = True @@ -220,7 +221,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): # Try to get a stream with fixed interval retries while should_retry and time.time() - wait_start_time < retry_timeout: attempt += 1 - stream_url, stream_user_agent, transcode, profile_value = ( + stream_url, stream_user_agent, transcode, profile_value, slot_reserved = ( generate_stream_url(channel_id) ) @@ -232,7 +233,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): # On first failure, check if the error is retryable if attempt == 1: - _, _, error_reason = channel.get_stream() + _, _, error_reason, _ = channel.get_stream() if error_reason and "maximum connection limits" not in error_reason: logger.warning( f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}" @@ -265,7 +266,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): logger.info( f"[{client_id}] Making final attempt {attempt} at timeout boundary" ) - stream_url, stream_user_agent, transcode, profile_value = ( + stream_url, stream_user_agent, transcode, profile_value, slot_reserved = ( generate_stream_url(channel_id) ) if stream_url is not None: @@ -274,9 +275,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): ) if stream_url is None: - # Release any connection slot that may have been allocated - # by the error-checking get_stream() call during retries - if not channel.release_stream(): + if slot_reserved and not channel.release_stream(): logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup") # Get the specific error message if available @@ -295,7 +294,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): # generate_stream_url() called get_stream() which allocated a connection # slot (INCR'd profile_connections) - track this for cleanup on error - if needs_initialization: + if needs_initialization and slot_reserved: connection_allocated = True # Read stream assignment from Redis (already set by generate_stream_url → get_stream). @@ -378,8 +377,8 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): logger.warning( f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}" ) - # Release stream lock before redirecting - if not channel.release_stream(): + # Release stream lock before redirecting only if we reserved a slot + if connection_allocated and not channel.release_stream(): logger.warning(f"[{client_id}] Failed to release stream before redirect") connection_allocated = False # Final decision based on validation results diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index a7995107..01272468 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -178,7 +178,10 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): """ try: from core.utils import RedisClient - from apps.m3u.connection_pool import pool_has_capacity_for_profile + from apps.m3u.connection_pool import ( + get_profile_connection_count, + profile_has_capacity_for_selection, + ) redis_client = RedisClient.get_client() if not redis_client: @@ -229,15 +232,12 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): ) # Check Redis-based current connections profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) + current_connections = get_profile_connection_count(profile, redis_client) - if (profile.max_streams == 0 or current_connections < profile.max_streams) and pool_has_capacity_for_profile(profile, redis_client): + if profile_has_capacity_for_selection(profile, redis_client): logger.info(f"[PROFILE-SELECTION] Using requested profile {profile.id}: {current_connections}/{profile.max_streams} connections") return (profile, current_connections) - elif not pool_has_capacity_for_profile(profile, redis_client): - logger.warning(f"[PROFILE-SELECTION] Shared credential pool at capacity for account {m3u_account.id}") - else: - logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}") + logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}") except M3UAccountProfile.DoesNotExist: logger.warning(f"[PROFILE-SELECTION] Requested profile {profile_id} not found") @@ -256,19 +256,11 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): profiles = [default_profile] + list(m3u_profiles.filter(is_default=False)) for profile in profiles: - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) + current_connections = get_profile_connection_count(profile, redis_client) - # Check if profile has available connection slots - if (profile.max_streams == 0 or current_connections < profile.max_streams) and pool_has_capacity_for_profile(profile, redis_client): + if profile_has_capacity_for_selection(profile, redis_client): logger.info(f"[PROFILE-SELECTION] Selected profile {profile.id} ({profile.name}): {current_connections}/{profile.max_streams} connections") return (profile, current_connections) - elif not pool_has_capacity_for_profile(profile, redis_client): - logger.debug( - f"[PROFILE-SELECTION] Credential pool at capacity for profile " - f"{profile.id}, trying next profile" - ) - continue else: logger.debug(f"[PROFILE-SELECTION] Profile {profile.id} at capacity: {current_connections}/{profile.max_streams}") diff --git a/frontend/src/api.js b/frontend/src/api.js index 2a357a81..4f58527e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3,6 +3,7 @@ import useAuthStore from './store/auth'; import useChannelsStore from './store/channels'; import useLogosStore from './store/logos'; import useUserAgentsStore from './store/userAgents'; +import useServerGroupsStore from './store/serverGroups'; import usePlaylistsStore from './store/playlists'; import useEPGsStore from './store/epgs'; import useStreamsStore from './store/streams'; @@ -1305,6 +1306,59 @@ export default class API { } } + static async getServerGroups() { + try { + const response = await request(`${host}/api/m3u/server-groups/`); + + return response; + } catch (e) { + errorNotification('Failed to retrieve server groups', e); + } + } + + static async addServerGroup(values) { + try { + const response = await request(`${host}/api/m3u/server-groups/`, { + method: 'POST', + body: values, + }); + + useServerGroupsStore.getState().addServerGroup(response); + + return response; + } catch (e) { + errorNotification('Failed to create server group', e); + } + } + + static async updateServerGroup(values) { + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/m3u/server-groups/${id}/`, { + method: 'PUT', + body: payload, + }); + + useServerGroupsStore.getState().updateServerGroup(response); + + return response; + } catch (e) { + errorNotification('Failed to update server group', e); + } + } + + static async deleteServerGroup(id) { + try { + await request(`${host}/api/m3u/server-groups/${id}/`, { + method: 'DELETE', + }); + + useServerGroupsStore.getState().removeServerGroups([id]); + } catch (e) { + errorNotification('Failed to delete server group', e); + } + } + static async getPlaylist(id) { try { const response = await request(`${host}/api/m3u/accounts/${id}/`); diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index e27570db..3a4161cb 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -1,6 +1,7 @@ // Modal.js import React, { useEffect, useState } from 'react'; import useUserAgentsStore from '../../store/userAgents'; +import useServerGroupsStore from '../../store/serverGroups'; import M3UProfiles from './M3UProfiles'; import { Box, @@ -43,6 +44,7 @@ const M3U = ({ playlistCreated = false, }) => { const userAgents = useUserAgentsStore((s) => s.userAgents); + const serverGroups = useServerGroupsStore((s) => s.serverGroups); const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const fetchEPGs = useEPGsStore((s) => s.fetchEPGs); const fetchCategories = useVODStore((s) => s.fetchCategories); @@ -61,6 +63,7 @@ const M3U = ({ name: '', server_url: '', user_agent: '0', + server_group: '0', is_active: true, max_streams: 0, refresh_interval: 24, @@ -88,6 +91,9 @@ const M3U = ({ server_url: m3uAccount.server_url, max_streams: m3uAccount.max_streams, user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0', + server_group: m3uAccount.server_group + ? `${m3uAccount.server_group}` + : '0', is_active: m3uAccount.is_active, refresh_interval: m3uAccount.refresh_interval, cron_expression: m3uAccount.cron_expression || '', @@ -355,6 +361,24 @@ const M3U = ({ key={form.key('max_streams')} /> + { + const defaultValues = useMemo( + () => ({ + name: serverGroup?.name || '', + max_streams: serverGroup?.max_streams ?? 0, + }), + [serverGroup] + ); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + watch, + } = useForm({ + defaultValues, + resolver: yupResolver(schema), + }); + + const onSubmit = async (values) => { + const payload = { + ...values, + max_streams: Number(values.max_streams), + }; + + if (serverGroup?.id) { + await API.updateServerGroup({ id: serverGroup.id, ...payload }); + } else { + await API.addServerGroup(payload); + } + + reset(); + onClose(); + }; + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + if (!isOpen) { + return null; + } + + const maxStreams = watch('max_streams'); + + return ( + +
+ + + setValue('max_streams', value ?? 0)} + error={errors.max_streams?.message} + /> + + + + + +
+ ); +}; + +export default ServerGroupForm; diff --git a/frontend/src/components/tables/ServerGroupsTable.jsx b/frontend/src/components/tables/ServerGroupsTable.jsx new file mode 100644 index 00000000..e4280037 --- /dev/null +++ b/frontend/src/components/tables/ServerGroupsTable.jsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useState } from 'react'; +import API from '../../api'; +import useServerGroupsStore from '../../store/serverGroups'; +import ServerGroupForm from '../forms/ServerGroup'; +import { + ActionIcon, + Box, + Button, + Center, + Flex, + Paper, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { SquareMinus, SquarePen, SquarePlus } from 'lucide-react'; +import { CustomTable, useTable } from './CustomTable'; +import useLocalStorage from '../../hooks/useLocalStorage'; + +const RowActions = ({ row, editServerGroup, deleteServerGroup }) => { + return ( + <> + editServerGroup(row.original)} + > + + + deleteServerGroup(row.original.id)} + > + + + + ); +}; + +const ServerGroupsTable = () => { + const [serverGroup, setServerGroup] = useState(null); + const [serverGroupModalOpen, setServerGroupModalOpen] = useState(false); + + const serverGroups = useServerGroupsStore((state) => state.serverGroups); + const fetchServerGroups = useServerGroupsStore( + (state) => state.fetchServerGroups + ); + const [tableSize] = useLocalStorage('table-size', 'default'); + + const columns = useMemo( + () => [ + { + header: 'Name', + accessorKey: 'name', + size: 175, + }, + { + header: 'Max Streams', + accessorKey: 'max_streams', + size: 100, + cell: ({ cell }) => { + const value = cell.getValue(); + return value === 0 ? 'Unlimited' : value; + }, + }, + { + id: 'actions', + header: 'Actions', + size: tableSize == 'compact' ? 50 : 75, + }, + ], + [tableSize] + ); + + const [isLoading, setIsLoading] = useState(true); + + const editServerGroup = (group = null) => { + setServerGroup(group); + setServerGroupModalOpen(true); + }; + + const deleteServerGroup = async (id) => { + await API.deleteServerGroup(id); + }; + + const closeServerGroupForm = () => { + setServerGroup(null); + setServerGroupModalOpen(false); + }; + + useEffect(() => { + fetchServerGroups().finally(() => setIsLoading(false)); + }, [fetchServerGroups]); + + const renderHeaderCell = (header) => { + return ( + + {header.column.columnDef.header} + + ); + }; + + const renderBodyCell = ({ row }) => { + return ( + + ); + }; + + const table = useTable({ + columns, + data: serverGroups, + allRowIds: serverGroups.map((group) => group.id), + bodyCellRenderFns: { + actions: renderBodyCell, + }, + headerCellRenderFns: { + name: renderHeaderCell, + max_streams: renderHeaderCell, + actions: renderHeaderCell, + }, + }); + + if (isLoading) { + return ( +
+ Loading server groups... +
+ ); + } + + return ( + + + + + + + + + + + + + +
+ +
+
+
+ + +
+ ); +}; + +export default ServerGroupsTable; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 29d46dd8..c3481984 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -14,6 +14,9 @@ import { const UserAgentsTable = React.lazy( () => import('../components/tables/UserAgentsTable.jsx') ); +const ServerGroupsTable = React.lazy( + () => import('../components/tables/ServerGroupsTable.jsx') +); const StreamProfilesTable = React.lazy( () => import('../components/tables/StreamProfilesTable.jsx') ); @@ -135,6 +138,19 @@ const SettingsPage = () => { + + Server Groups + + + }> + + + + + + User-Agents diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index cfea5d4f..8f362357 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -6,6 +6,7 @@ import useEPGsStore from './epgs'; import useStreamProfilesStore from './streamProfiles'; import useOutputProfilesStore from './outputProfiles'; import useUserAgentsStore from './userAgents'; +import useServerGroupsStore from './serverGroups'; import useUsersStore from './users'; import API from '../api'; import { USER_LEVELS } from '../constants'; @@ -124,6 +125,7 @@ const useAuthStore = create((set, get) => ({ useStreamProfilesStore.getState().fetchProfiles(), useOutputProfilesStore.getState().fetchProfiles(), useUserAgentsStore.getState().fetchUserAgents(), + useServerGroupsStore.getState().fetchServerGroups(), useChannelsStore.getState().fetchChannelIds(), ]); diff --git a/frontend/src/store/serverGroups.jsx b/frontend/src/store/serverGroups.jsx new file mode 100644 index 00000000..42406d70 --- /dev/null +++ b/frontend/src/store/serverGroups.jsx @@ -0,0 +1,40 @@ +import { create } from 'zustand'; +import api from '../api'; + +const useServerGroupsStore = create((set) => ({ + serverGroups: [], + isLoading: false, + error: null, + + fetchServerGroups: async () => { + set({ isLoading: true, error: null }); + try { + const serverGroups = await api.getServerGroups(); + set({ serverGroups: serverGroups || [], isLoading: false }); + } catch (error) { + console.error('Failed to fetch server groups:', error); + set({ error: 'Failed to load server groups.', isLoading: false }); + } + }, + + addServerGroup: (serverGroup) => + set((state) => ({ + serverGroups: [...state.serverGroups, serverGroup], + })), + + updateServerGroup: (serverGroup) => + set((state) => ({ + serverGroups: state.serverGroups.map((group) => + group.id === serverGroup.id ? serverGroup : group + ), + })), + + removeServerGroups: (serverGroupIds) => + set((state) => ({ + serverGroups: state.serverGroups.filter( + (group) => !serverGroupIds.includes(group.id) + ), + })), +})); + +export default useServerGroupsStore; diff --git a/frontend/src/utils/forms/M3uUtils.js b/frontend/src/utils/forms/M3uUtils.js index 7fc7e369..44692b78 100644 --- a/frontend/src/utils/forms/M3uUtils.js +++ b/frontend/src/utils/forms/M3uUtils.js @@ -50,5 +50,9 @@ export const prepareSubmitValues = (values, expDate) => { prepared.user_agent = null; } + if (prepared.server_group == '0') { + prepared.server_group = null; + } + return prepared; }; From 6e07dce3f17532fc3519c3ef2b052467c06ead05 Mon Sep 17 00:00:00 2001 From: Goldenfreddy0703 <62456796+Goldenfreddy0703@users.noreply.github.com> Date: Fri, 29 May 2026 17:05:59 -0400 Subject: [PATCH 03/11] Fix maintainer review items without breaking multi-login rotation. Cap credential-scoped group counters at profile.max_streams (not group.max_streams), skip credential counters when fingerprint resolution fails, and always swap profile counters on update_stream_profile. Restore per-profile-only selection for VOD/live switches so a second stream can use a different login without invalid HTTP errors. Co-authored-by: Cursor --- apps/channels/models.py | 44 +++--- apps/m3u/connection_pool.py | 94 ++++++++----- apps/m3u/tests/test_connection_pool.py | 178 +++++++++++++++++++++---- 3 files changed, 233 insertions(+), 83 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 38ebbbc7..154f6fa2 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -712,6 +712,11 @@ class Channel(models.Model): True, ) # Return newly assigned stream and matched profile else: + from apps.m3u.connection_pool import ( + group_has_capacity_for_profile, + profile_has_capacity_for_selection, + ) + # At capacity: try to preempt a lower-impact channel on this profile victim_channel_id = self._pick_channel_to_preempt( profile_id=profile.id, @@ -723,12 +728,21 @@ class Channel(models.Model): logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}") # return self.id, profile.id, victim_channel_id - # This profile is at max connections has_streams_but_maxed_out = True - logger.debug( - f"Profile {profile.id} at max connections: " - f"{current_count}/{profile.max_streams}" - ) + if not profile_has_capacity_for_selection(profile, redis_client): + logger.info( + f"Profile {profile.id} at max connections: " + f"{current_count}/{profile.max_streams}, trying next profile" + ) + elif not group_has_capacity_for_profile(profile, redis_client): + logger.info( + f"Profile {profile.id} login or server group pool full, trying next profile" + ) + else: + logger.debug( + f"Profile {profile.id} reservation failed: " + f"{current_count}/{profile.max_streams}" + ) # No available streams - determine specific reason if has_streams_but_maxed_out: @@ -873,25 +887,9 @@ class Channel(models.Model): if current_profile_id == new_profile_id: return True - from apps.m3u.models import M3UAccountProfile - from apps.m3u.connection_pool import ( - get_enforced_server_group_for_profile, - profile_connections_key, - ) + from apps.m3u.connection_pool import profile_connections_key - new_profile = M3UAccountProfile.objects.get(id=new_profile_id) - if get_enforced_server_group_for_profile(new_profile): - # Shared group pool: one active stream uses one group slot regardless - # of which profile supplies the URL. - redis_client.set(f"stream_profile:{stream_id}", new_profile_id) - logger.info( - f"Updated stream {stream_id} profile from {current_profile_id} to " - f"{new_profile_id} (shared group pool unchanged)" - ) - return True - - # Use pipeline for atomic profile switch to prevent counter drift - # if an exception occurs between DECR and INCR + # Profile counters always move on switch; group totals stay unchanged (one stream). old_profile_connections_key = profile_connections_key(current_profile_id) new_profile_connections_key = profile_connections_key(new_profile_id) old_count = int(redis_client.get(old_profile_connections_key) or 0) diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py index 9817d86c..35c35b0e 100644 --- a/apps/m3u/connection_pool.py +++ b/apps/m3u/connection_pool.py @@ -3,8 +3,9 @@ Shared connection pool enforcement for M3U accounts in the same ServerGroup. Profile selection rotates across M3UAccountProfile rows using each profile's own Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup -with max_streams > 0, the group counter is scoped by provider login fingerprint -so profiles that rewrite to different IPTV credentials keep independent limits. +with max_streams > 0, a credential-scoped counter is checked on reserve/release +so accounts sharing the same provider login share one limit without blocking +unrelated logins on the same group. """ from __future__ import annotations @@ -29,10 +30,12 @@ def profile_connections_key(profile_id: int) -> str: return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id) -def server_group_connections_key(group_id: int, fingerprint: Optional[str] = None) -> str: - """Redis key for a manual ServerGroup slot, scoped by provider login.""" - fp = (fingerprint or "unknown")[:16] - return SERVER_GROUP_CONNECTIONS_KEY.format(group_id=group_id, fingerprint=fp) +def server_group_connections_key(group_id: int, fingerprint: str) -> str: + """Redis key for per-credential usage within a ServerGroup.""" + return SERVER_GROUP_CONNECTIONS_KEY.format( + group_id=group_id, + fingerprint=fingerprint[:16], + ) def compute_credential_fingerprint(username: str, password: str) -> Optional[str]: @@ -124,22 +127,30 @@ def get_enforced_server_group_for_profile(profile): return None -def _group_counter_key(profile, group) -> str: - return server_group_connections_key( - group.id, - get_profile_credential_fingerprint(profile), - ) +def _credential_counter_key(profile, group) -> Optional[str]: + fingerprint = get_profile_credential_fingerprint(profile) + if not fingerprint: + return None + return server_group_connections_key(group.id, fingerprint) def get_profile_connection_count(profile, redis_client) -> int: return int(redis_client.get(profile_connections_key(profile.id)) or 0) -def get_group_connection_count(profile, redis_client) -> int: +def get_credential_connection_count(profile, redis_client) -> int: group = get_enforced_server_group_for_profile(profile) if not group: return 0 - return int(redis_client.get(_group_counter_key(profile, group)) or 0) + cred_key = _credential_counter_key(profile, group) + if not cred_key: + return 0 + return int(redis_client.get(cred_key) or 0) + + +def get_group_connection_count(profile, redis_client) -> int: + """Backwards-compatible alias for credential-scoped group usage.""" + return get_credential_connection_count(profile, redis_client) def profile_has_capacity_for_selection(profile, redis_client) -> bool: @@ -151,35 +162,22 @@ def profile_has_capacity_for_selection(profile, redis_client) -> bool: def group_has_capacity_for_profile(profile, redis_client) -> bool: group = get_enforced_server_group_for_profile(profile) - if not group: + if not group or profile.max_streams == 0: return True - return get_group_connection_count(profile, redis_client) < group.max_streams + cred_key = _credential_counter_key(profile, group) + if not cred_key: + return True + return get_credential_connection_count(profile, redis_client) < profile.max_streams def pool_has_capacity_for_profile(profile, redis_client) -> bool: - """Non-mutating check before reserve: profile slot and group slot if applicable.""" + """Non-mutating check before reserve: profile slot and credential slot if applicable.""" return profile_has_capacity_for_selection(profile, redis_client) and group_has_capacity_for_profile( profile, redis_client ) -def _reserve_server_group_slot_for_profile(profile, redis_client) -> bool: - group = get_enforced_server_group_for_profile(profile) - if not group: - return True - key = _group_counter_key(profile, group) - group_count = redis_client.incr(key) - if group_count <= group.max_streams: - return True - redis_client.decr(key) - return False - - -def _release_server_group_slot_for_profile(profile, redis_client) -> None: - group = get_enforced_server_group_for_profile(profile) - if not group: - return - key = _group_counter_key(profile, group) +def _safe_decr(redis_client, key: str) -> None: current = int(redis_client.get(key) or 0) if current <= 0: return @@ -188,9 +186,35 @@ def _release_server_group_slot_for_profile(profile, redis_client) -> None: redis_client.set(key, 0) +def _reserve_server_group_slot_for_profile(profile, redis_client) -> bool: + group = get_enforced_server_group_for_profile(profile) + if not group or profile.max_streams == 0: + return True + + cred_key = _credential_counter_key(profile, group) + if not cred_key: + return True + + cred_count = redis_client.incr(cred_key) + if cred_count <= profile.max_streams: + return True + + redis_client.decr(cred_key) + return False + + +def _release_server_group_slot_for_profile(profile, redis_client) -> None: + group = get_enforced_server_group_for_profile(profile) + if not group or profile.max_streams == 0: + return + cred_key = _credential_counter_key(profile, group) + if cred_key: + _safe_decr(redis_client, cred_key) + + def reserve_profile_slot(profile, redis_client) -> Tuple[bool, int]: """ - Atomically reserve profile + optional group slots (INCR-first). + Atomically reserve profile + optional credential slots (INCR-first). Returns (reserved, profile_count_after_attempt). """ @@ -212,7 +236,7 @@ def reserve_profile_slot(profile, redis_client) -> Tuple[bool, int]: def release_profile_slot(profile_id: int, redis_client) -> None: - """Release profile and shared group slots after a stream ends.""" + """Release profile and shared credential slots after a stream end.""" from apps.m3u.models import M3UAccountProfile try: diff --git a/apps/m3u/tests/test_connection_pool.py b/apps/m3u/tests/test_connection_pool.py index 44281211..1dbfc2a9 100644 --- a/apps/m3u/tests/test_connection_pool.py +++ b/apps/m3u/tests/test_connection_pool.py @@ -5,6 +5,7 @@ from unittest.mock import patch from apps.m3u.connection_pool import ( extract_credentials_from_stream_url, + get_credential_connection_count, get_enforced_server_group_for_profile, get_group_connection_count, get_profile_connection_count, @@ -43,6 +44,37 @@ class FakeRedis: self._data[key] = self._data.get(key, 0) - 1 return self._data[key] + def pipeline(self): + return FakeRedisPipeline(self) + + +class FakeRedisPipeline: + def __init__(self, redis): + self.redis = redis + self._ops = [] + + def decr(self, key): + self._ops.append(("decr", key)) + return self + + def incr(self, key): + self._ops.append(("incr", key)) + return self + + def set(self, key, value): + self._ops.append(("set", key, value)) + return self + + def execute(self): + for op in self._ops: + if op[0] == "decr": + self.redis.decr(op[1]) + elif op[0] == "incr": + self.redis.incr(op[1]) + elif op[0] == "set": + self.redis.set(op[1], op[2]) + self._ops = [] + class ExtractCredentialsTests(TestCase): def test_extract_credentials_from_xc_style_url(self): @@ -66,8 +98,8 @@ class ManualServerGroupTests(TestCase): self.assertEqual(get_enforced_server_group_for_profile(profile), group) - def test_accounts_in_same_group_share_counter(self): - group = ServerGroup.objects.create(name="shared", max_streams=1) + def test_accounts_in_same_group_share_credential_counter(self): + group = ServerGroup.objects.create(name="shared", max_streams=2) account1 = M3UAccount.objects.create( name="XC Account", account_type="XC", @@ -87,6 +119,10 @@ class ManualServerGroupTests(TestCase): ) profile1 = M3UAccountProfile.objects.get(m3u_account=account1, is_default=True) profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True) + profile1.max_streams = 1 + profile1.save() + profile2.max_streams = 1 + profile2.save() redis = FakeRedis() reserved1, _ = reserve_profile_slot(profile1, redis) @@ -97,7 +133,6 @@ class ManualServerGroupTests(TestCase): self.assertFalse(group_has_capacity_for_profile(profile2, redis)) def test_profile_rotation_when_default_profile_full(self): - """Pre-pool behavior: try the next profile on the same account.""" account = M3UAccount.objects.create( name="Multi-profile", account_type="XC", @@ -129,20 +164,21 @@ class PoolEnforcementTests(TestCase): self.redis = FakeRedis() self.group = ServerGroup.objects.create( name="test-pool", - max_streams=1, + max_streams=2, ) self.account = M3UAccount.objects.create( name="Test Account", account_type="XC", username="user", password="pass", + server_url="http://xc.example.com", server_group=self.group, max_streams=5, ) self.profile = M3UAccountProfile.objects.get( m3u_account=self.account, is_default=True ) - self.profile.max_streams = 5 + self.profile.max_streams = 1 self.profile.save() def test_reserve_and_release_both_counters(self): @@ -150,31 +186,45 @@ class PoolEnforcementTests(TestCase): self.assertTrue(reserved) self.assertEqual(count, 1) - group_key = server_group_connections_key( + cred_key = server_group_connections_key( self.group.id, get_profile_credential_fingerprint(self.profile), ) profile_key = profile_connections_key(self.profile.id) - self.assertEqual(self.redis._data[group_key], 1) + self.assertEqual(self.redis._data[cred_key], 1) self.assertEqual(self.redis._data[profile_key], 1) release_profile_slot(self.profile.id, self.redis) - self.assertEqual(self.redis._data[group_key], 0) + self.assertEqual(self.redis._data[cred_key], 0) self.assertEqual(self.redis._data[profile_key], 0) - def test_reserve_fails_when_group_at_capacity(self): - group_key = server_group_connections_key( - self.group.id, - get_profile_credential_fingerprint(self.profile), + def test_same_credential_capped_at_profile_max_not_group_max(self): + """Maintainer example: group max=2 but each login only allows 1.""" + account2 = M3UAccount.objects.create( + name="Second Account", + account_type="XC", + username="user", + password="pass", + server_url="http://xc.example.com", + server_group=self.group, + max_streams=5, ) - self.redis.set(group_key, 1) + profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True) + profile2.max_streams = 1 + profile2.save() - reserved, _count = reserve_profile_slot(self.profile, self.redis) - self.assertFalse(reserved) - self.assertFalse(pool_has_capacity_for_profile(self.profile, self.redis)) + self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0]) + self.assertFalse(reserve_profile_slot(profile2, self.redis)[0]) + + fp = get_profile_credential_fingerprint(self.profile) + cred_key = server_group_connections_key(self.group.id, fp) + self.assertEqual(self.redis._data[cred_key], 1) + + def test_different_logins_both_stream_when_group_max_is_one(self): + """Regression: group max=1 must not block a second login on another profile.""" + self.group.max_streams = 1 + self.group.save() - def test_different_logins_in_group_do_not_block_each_other(self): - """Profiles with different provider logins keep separate group counters.""" account = M3UAccount.objects.create( name="Grouped multi-login", account_type="XC", @@ -204,17 +254,96 @@ class PoolEnforcementTests(TestCase): "apps.m3u.connection_pool.get_profile_credential_fingerprint", side_effect=lambda profile: fp_a if profile.id == default.id else fp_b, ): - reserved_default, _ = reserve_profile_slot(default, self.redis) - self.assertTrue(reserved_default) - - reserved_alt, _ = reserve_profile_slot(alt, self.redis) - self.assertTrue(reserved_alt) + self.assertTrue(reserve_profile_slot(default, self.redis)[0]) + self.assertTrue(reserve_profile_slot(alt, self.redis)[0]) key_a = server_group_connections_key(self.group.id, fp_a) key_b = server_group_connections_key(self.group.id, fp_b) self.assertEqual(self.redis._data[key_a], 1) self.assertEqual(self.redis._data[key_b], 1) + def test_no_fingerprint_skips_credential_counter(self): + account = M3UAccount.objects.create( + name="No creds", + account_type="STD", + server_group=self.group, + max_streams=5, + ) + profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + profile.max_streams = 1 + profile.save() + + with patch( + "apps.m3u.connection_pool.get_profile_credential_fingerprint", + return_value=None, + ): + self.assertTrue(reserve_profile_slot(profile, self.redis)[0]) + self.assertEqual(get_credential_connection_count(profile, self.redis), 0) + self.assertEqual(get_group_connection_count(profile, self.redis), 0) + + def test_release_when_profile_row_deleted(self): + profile_id = self.profile.id + fp = get_profile_credential_fingerprint(self.profile) + cred_key = server_group_connections_key(self.group.id, fp) + + self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0]) + self.profile.delete() + + release_profile_slot(profile_id, self.redis) + + self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0) + self.assertEqual(self.redis._data[cred_key], 1) + + +class UpdateStreamProfileTests(TestCase): + def test_switch_updates_profile_counters_when_group_assigned(self): + from apps.channels.models import Channel, Stream + + redis = FakeRedis() + group = ServerGroup.objects.create(name="switch-group", max_streams=2) + account = M3UAccount.objects.create( + name="Switch Account", + account_type="XC", + username="user", + password="pass", + server_url="http://xc.example.com", + server_group=group, + max_streams=5, + ) + profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + profile_a.max_streams = 1 + profile_a.save() + profile_b = M3UAccountProfile.objects.create( + m3u_account=account, + name="alt", + is_default=False, + is_active=True, + max_streams=1, + search_pattern="", + replace_pattern="", + ) + + stream = Stream.objects.create(name="Test Stream", m3u_account=account) + channel = Channel.objects.create(channel_number=501, name="Switch Channel") + channel.streams.add(stream) + + reserve_profile_slot(profile_a, redis) + redis.set(f"channel_stream:{channel.id}", stream.id) + redis.set(f"stream_profile:{stream.id}", profile_a.id) + + cred_key = server_group_connections_key( + group.id, get_profile_credential_fingerprint(profile_a) + ) + cred_before = redis._data[cred_key] + + with patch("core.utils.RedisClient.get_client", return_value=redis): + self.assertTrue(channel.update_stream_profile(profile_b.id)) + + self.assertEqual(int(redis.get(f"stream_profile:{stream.id}")), profile_b.id) + self.assertEqual(redis._data[profile_connections_key(profile_a.id)], 0) + self.assertEqual(redis._data[profile_connections_key(profile_b.id)], 1) + self.assertEqual(redis._data[cred_key], cred_before) + class VodProfileSelectionTests(TestCase): def test_get_m3u_profile_skips_default_when_profile_full(self): @@ -243,8 +372,7 @@ class VodProfileSelectionTests(TestCase): ) redis = FakeRedis() - reserved, _ = reserve_profile_slot(default, redis) - self.assertTrue(reserved) + self.assertTrue(reserve_profile_slot(default, redis)[0]) with patch("core.utils.RedisClient.get_client", return_value=redis): result = _get_m3u_profile(account, None, None) From abdf2d7864d29a4e834c5c521522ad0144e129f1 Mon Sep 17 00:00:00 2001 From: Goldenfreddy0703 <62456796+Goldenfreddy0703@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:51:20 -0400 Subject: [PATCH 04/11] Fix credential release on deleted profile and round 3 review items. Store credential Redis keys at reserve so release works when the profile row is deleted. Return reserve failure reasons to avoid fingerprint DB queries on logging paths. Document unlimited profile bypass in pool logic and Server Group UI. Co-authored-by: Cursor --- apps/channels/models.py | 19 +++-- apps/m3u/connection_pool.py | 78 +++++++++++++++---- apps/m3u/models.py | 6 +- apps/m3u/tests/test_connection_pool.py | 47 +++++++++-- .../multi_worker_connection_manager.py | 4 +- frontend/src/components/forms/M3U.jsx | 2 +- frontend/src/components/forms/ServerGroup.jsx | 2 +- 7 files changed, 123 insertions(+), 35 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 154f6fa2..9742626b 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -234,7 +234,9 @@ class Stream(models.Model): continue # Atomic slot reservation via shared connection pool helper - reserved, _count = reserve_profile_slot(profile, redis_client) + reserved, _count, _failure_reason = reserve_profile_slot( + profile, redis_client + ) if reserved: redis_client.set(f"channel_stream:{self.id}", self.id) @@ -694,7 +696,9 @@ class Channel(models.Model): has_active_profiles = True # Atomically check and reserve a slot (INCR-first pattern) - reserved, current_count = reserve_profile_slot(profile, redis_client) + reserved, current_count, failure_reason = reserve_profile_slot( + profile, redis_client + ) if reserved: # Slot reserved — assign stream to this channel @@ -712,11 +716,6 @@ class Channel(models.Model): True, ) # Return newly assigned stream and matched profile else: - from apps.m3u.connection_pool import ( - group_has_capacity_for_profile, - profile_has_capacity_for_selection, - ) - # At capacity: try to preempt a lower-impact channel on this profile victim_channel_id = self._pick_channel_to_preempt( profile_id=profile.id, @@ -729,14 +728,14 @@ class Channel(models.Model): # return self.id, profile.id, victim_channel_id has_streams_but_maxed_out = True - if not profile_has_capacity_for_selection(profile, redis_client): + if failure_reason == "profile_full": logger.info( f"Profile {profile.id} at max connections: " f"{current_count}/{profile.max_streams}, trying next profile" ) - elif not group_has_capacity_for_profile(profile, redis_client): + elif failure_reason == "credential_full": logger.info( - f"Profile {profile.id} login or server group pool full, trying next profile" + f"Profile {profile.id} shared login pool full, trying next profile" ) else: logger.debug( diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py index 35c35b0e..36a74d17 100644 --- a/apps/m3u/connection_pool.py +++ b/apps/m3u/connection_pool.py @@ -5,7 +5,8 @@ Profile selection rotates across M3UAccountProfile rows using each profile's own Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup with max_streams > 0, a credential-scoped counter is checked on reserve/release so accounts sharing the same provider login share one limit without blocking -unrelated logins on the same group. +unrelated logins on the same group. Account profiles with max_streams=0 skip +credential enforcement for that profile. """ from __future__ import annotations @@ -13,11 +14,14 @@ from __future__ import annotations import hashlib import logging import re -from typing import Optional, Tuple +from typing import Literal, Optional, Tuple logger = logging.getLogger(__name__) +ReserveFailureReason = Literal["profile_full", "credential_full"] + PROFILE_CONNECTIONS_KEY = "profile_connections:{profile_id}" +PROFILE_CREDENTIAL_RELEASE_KEY = "profile_credential_release:{profile_id}" SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}:{fingerprint}" _XC_URL_CREDENTIALS_RE = re.compile( @@ -30,6 +34,11 @@ def profile_connections_key(profile_id: int) -> str: return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id) +def profile_credential_release_key(profile_id: int) -> str: + """Redis key storing the credential counter to release when the profile row is gone.""" + return PROFILE_CREDENTIAL_RELEASE_KEY.format(profile_id=profile_id) + + def server_group_connections_key(group_id: int, fingerprint: str) -> str: """Redis key for per-credential usage within a ServerGroup.""" return SERVER_GROUP_CONNECTIONS_KEY.format( @@ -161,6 +170,8 @@ def profile_has_capacity_for_selection(profile, redis_client) -> bool: def group_has_capacity_for_profile(profile, redis_client) -> bool: + # Profiles with max_streams=0 skip credential enforcement entirely. An unlimited + # profile in a pooled group can still stream while other accounts share the login. group = get_enforced_server_group_for_profile(profile) if not group or profile.max_streams == 0: return True @@ -186,21 +197,43 @@ def _safe_decr(redis_client, key: str) -> None: redis_client.set(key, 0) -def _reserve_server_group_slot_for_profile(profile, redis_client) -> bool: +def _remember_credential_release_key( + profile_id: int, cred_key: str, redis_client +) -> None: + redis_client.set(profile_credential_release_key(profile_id), cred_key) + + +def _release_credential_slot_by_profile_id(profile_id: int, redis_client) -> bool: + """Release a reserved credential counter using the key stored at reserve time.""" + release_key = profile_credential_release_key(profile_id) + cred_key = redis_client.get(release_key) + if not cred_key: + return False + + if isinstance(cred_key, bytes): + cred_key = cred_key.decode() + _safe_decr(redis_client, cred_key) + redis_client.delete(release_key) + return True + + +def _reserve_server_group_slot_for_profile( + profile, redis_client +) -> Tuple[bool, Optional[str]]: group = get_enforced_server_group_for_profile(profile) if not group or profile.max_streams == 0: - return True + return True, None cred_key = _credential_counter_key(profile, group) if not cred_key: - return True + return True, None cred_count = redis_client.incr(cred_key) if cred_count <= profile.max_streams: - return True + return True, cred_key redis_client.decr(cred_key) - return False + return False, None def _release_server_group_slot_for_profile(profile, redis_client) -> None: @@ -212,11 +245,14 @@ def _release_server_group_slot_for_profile(profile, redis_client) -> None: _safe_decr(redis_client, cred_key) -def reserve_profile_slot(profile, redis_client) -> Tuple[bool, int]: +def reserve_profile_slot( + profile, redis_client +) -> Tuple[bool, int, Optional[ReserveFailureReason]]: """ Atomically reserve profile + optional credential slots (INCR-first). - Returns (reserved, profile_count_after_attempt). + Returns (reserved, profile_count_after_attempt, failure_reason). + failure_reason is set when reserved is False. """ profile_key = profile_connections_key(profile.id) profile_count = 0 @@ -225,20 +261,34 @@ def reserve_profile_slot(profile, redis_client) -> Tuple[bool, int]: profile_count = redis_client.incr(profile_key) if profile_count > profile.max_streams: redis_client.decr(profile_key) - return False, profile_count - 1 + return False, profile_count - 1, "profile_full" - if not _reserve_server_group_slot_for_profile(profile, redis_client): + cred_reserved, cred_key = _reserve_server_group_slot_for_profile( + profile, redis_client + ) + if not cred_reserved: if profile.max_streams > 0: redis_client.decr(profile_key) - return False, profile_count - 1 if profile.max_streams > 0 else 0 + return ( + False, + profile_count - 1 if profile.max_streams > 0 else 0, + "credential_full", + ) - return True, profile_count + if cred_key: + _remember_credential_release_key(profile.id, cred_key, redis_client) + + return True, profile_count, None def release_profile_slot(profile_id: int, redis_client) -> None: """Release profile and shared credential slots after a stream end.""" from apps.m3u.models import M3UAccountProfile + released_via_stored_key = _release_credential_slot_by_profile_id( + profile_id, redis_client + ) + try: profile = M3UAccountProfile.objects.get(id=profile_id) except M3UAccountProfile.DoesNotExist: @@ -250,5 +300,5 @@ def release_profile_slot(profile_id: int, redis_client) -> None: if current > 0: redis_client.decr(profile_key) - if profile: + if profile and not released_via_stored_key: _release_server_group_slot_for_profile(profile, redis_client) diff --git a/apps/m3u/models.py b/apps/m3u/models.py index e6ccaadb..46d1a068 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -201,7 +201,11 @@ class ServerGroup(models.Model): ) max_streams = models.PositiveIntegerField( default=0, - help_text="Maximum number of concurrent streams shared across all accounts in this group (0 for unlimited)", + help_text=( + "Set above 0 to enable shared credential pooling for accounts in this group. " + "Per-login limits come from each account profile's max_streams. " + "Profiles with max_streams=0 (unlimited) skip cross-account credential enforcement." + ), ) def __str__(self): diff --git a/apps/m3u/tests/test_connection_pool.py b/apps/m3u/tests/test_connection_pool.py index 1dbfc2a9..e5de6c10 100644 --- a/apps/m3u/tests/test_connection_pool.py +++ b/apps/m3u/tests/test_connection_pool.py @@ -14,6 +14,7 @@ from apps.m3u.connection_pool import ( pool_has_capacity_for_profile, profile_has_capacity_for_selection, profile_connections_key, + profile_credential_release_key, release_profile_slot, reserve_profile_slot, server_group_connections_key, @@ -31,10 +32,15 @@ class FakeRedis: val = self._data.get(key) if val is None: return None + if isinstance(val, str): + return val.encode() return str(val).encode() def set(self, key, value, ex=None): - self._data[key] = int(value) + try: + self._data[key] = int(value) + except (ValueError, TypeError): + self._data[key] = value def incr(self, key): self._data[key] = self._data.get(key, 0) + 1 @@ -44,6 +50,9 @@ class FakeRedis: self._data[key] = self._data.get(key, 0) - 1 return self._data[key] + def delete(self, key): + self._data.pop(key, None) + def pipeline(self): return FakeRedisPipeline(self) @@ -125,10 +134,10 @@ class ManualServerGroupTests(TestCase): profile2.save() redis = FakeRedis() - reserved1, _ = reserve_profile_slot(profile1, redis) + reserved1, _, _ = reserve_profile_slot(profile1, redis) self.assertTrue(reserved1) - reserved2, _ = reserve_profile_slot(profile2, redis) + reserved2, _, _ = reserve_profile_slot(profile2, redis) self.assertFalse(reserved2) self.assertFalse(group_has_capacity_for_profile(profile2, redis)) @@ -153,7 +162,7 @@ class ManualServerGroupTests(TestCase): ) redis = FakeRedis() - reserved, _ = reserve_profile_slot(default, redis) + reserved, _, _ = reserve_profile_slot(default, redis) self.assertTrue(reserved) self.assertFalse(profile_has_capacity_for_selection(default, redis)) self.assertTrue(profile_has_capacity_for_selection(alt, redis)) @@ -182,7 +191,7 @@ class PoolEnforcementTests(TestCase): self.profile.save() def test_reserve_and_release_both_counters(self): - reserved, count = reserve_profile_slot(self.profile, self.redis) + reserved, count, _ = reserve_profile_slot(self.profile, self.redis) self.assertTrue(reserved) self.assertEqual(count, 1) @@ -286,13 +295,37 @@ class PoolEnforcementTests(TestCase): fp = get_profile_credential_fingerprint(self.profile) cred_key = server_group_connections_key(self.group.id, fp) - self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0]) + reserved, _, failure_reason = reserve_profile_slot( + self.profile, self.redis + ) + self.assertTrue(reserved) + self.assertIsNone(failure_reason) self.profile.delete() release_profile_slot(profile_id, self.redis) self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0) - self.assertEqual(self.redis._data[cred_key], 1) + self.assertEqual(self.redis._data[cred_key], 0) + self.assertNotIn(profile_credential_release_key(profile_id), self.redis._data) + + def test_reserve_returns_failure_reason_without_extra_checks(self): + account2 = M3UAccount.objects.create( + name="Reason Account", + account_type="XC", + username="user", + password="pass", + server_url="http://xc.example.com", + server_group=self.group, + max_streams=5, + ) + profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True) + profile2.max_streams = 1 + profile2.save() + + self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0]) + reserved, _count, reason = reserve_profile_slot(profile2, self.redis) + self.assertFalse(reserved) + self.assertEqual(reason, "credential_full") class UpdateStreamProfileTests(TestCase): diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 77db3b1b..8d803dcf 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -745,7 +745,9 @@ class MultiWorkerVODConnectionManager: from apps.m3u.connection_pool import reserve_profile_slot try: - reserved, new_count = reserve_profile_slot(m3u_profile, self.redis_client) + reserved, new_count, _failure_reason = reserve_profile_slot( + m3u_profile, self.redis_client + ) if reserved: logger.info( f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: " diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 3a4161cb..6e19c93a 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -365,7 +365,7 @@ const M3U = ({ id="server_group" name="server_group" label="Server Group" - description="Share a connection limit across multiple accounts (e.g. XC + M3U on the same subscription)" + description="Share login limits across accounts in a server group. Set max streams on each profile (unlimited profiles skip group enforcement)." {...form.getInputProps('server_group')} key={form.key('server_group')} data={[{ value: '0', label: '(None)' }].concat( diff --git a/frontend/src/components/forms/ServerGroup.jsx b/frontend/src/components/forms/ServerGroup.jsx index 229479ea..a01a0a76 100644 --- a/frontend/src/components/forms/ServerGroup.jsx +++ b/frontend/src/components/forms/ServerGroup.jsx @@ -70,7 +70,7 @@ const ServerGroupForm = ({ serverGroup = null, isOpen, onClose }) => { setValue('max_streams', value ?? 0)} From bc046b3c92a1bec47c144c80082eb28126042439 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Jun 2026 13:40:16 -0500 Subject: [PATCH 05/11] refactor(m3u, channels): streamline stream assignment and server group management - Renamed `_clear_stream_assignment_keys` to `_release_stale_stream_assignment` for clarity and updated its logic to release pool counters. - Introduced new functions for managing credential slots during profile switches, enhancing the handling of shared connection limits across server groups. - Removed the `max_streams` field from the `ServerGroup` model and updated related components to reflect this change, simplifying the server group management. - Updated frontend components to integrate server group management, allowing for dynamic creation and editing of server groups. - Enhanced error handling in stream URL generation to provide more informative feedback on connection issues. - Added tests for stale assignment release and credential management during profile switches. --- apps/channels/models.py | 47 ++++- apps/m3u/connection_pool.py | 61 +++++- .../0020_servergroup_max_streams.py | 21 -- apps/m3u/models.py | 16 +- apps/m3u/serializers.py | 2 +- apps/m3u/tests/test_connection_pool.py | 185 ++++++++++++++++-- apps/proxy/live_proxy/url_utils.py | 88 +++++---- apps/proxy/live_proxy/views.py | 23 ++- apps/proxy/vod_proxy/views.py | 11 +- .../components/ServerGroupsManagerModal.jsx | 28 +++ .../ServerGroupsManagerModal.test.jsx | 59 ++++++ frontend/src/components/forms/M3U.jsx | 53 ++++- frontend/src/components/forms/ServerGroup.jsx | 50 +++-- frontend/src/components/tables/M3UsTable.jsx | 47 +++-- .../components/tables/ServerGroupsTable.jsx | 170 ++++++++++++---- .../__tests__/ServerGroupsTable.test.jsx | 62 ++++++ frontend/src/pages/Settings.jsx | 16 -- 17 files changed, 710 insertions(+), 229 deletions(-) delete mode 100644 apps/m3u/migrations/0020_servergroup_max_streams.py create mode 100644 frontend/src/components/ServerGroupsManagerModal.jsx create mode 100644 frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx create mode 100644 frontend/src/components/tables/__tests__/ServerGroupsTable.test.jsx diff --git a/apps/channels/models.py b/apps/channels/models.py index d09a50f5..e84f9768 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -607,11 +607,22 @@ class Channel(models.Model): ChannelState.CONNECTING, ) - def _clear_stream_assignment_keys(self, redis_client, stream_id=None) -> None: - """Remove channel/stream profile assignment keys from Redis.""" + def _release_stale_stream_assignment(self, redis_client, stream_id: int) -> None: + """Release pool counters and remove stale channel/stream assignment keys.""" + profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + try: + profile_id = int(profile_id_bytes) + release_profile_slot(profile_id, redis_client) + except (ValueError, TypeError): + logger.debug( + "Invalid profile ID for stale assignment on stream %s: %s", + stream_id, + profile_id_bytes, + ) + redis_client.delete(f"channel_stream:{self.id}") - if stream_id is not None: - redis_client.delete(f"stream_profile:{stream_id}") + redis_client.delete(f"stream_profile:{stream_id}") def get_stream(self, requester=None): """ @@ -659,10 +670,10 @@ class Channel(models.Model): ) else: logger.info( - f"Channel {self.uuid}: clearing stale stream assignment " + f"Channel {self.uuid}: releasing stale stream assignment " f"(stream={stream_id}, proxy not active)" ) - self._clear_stream_assignment_keys(redis_client, stream_id) + self._release_stale_stream_assignment(redis_client, stream_id) # No existing active stream, attempt to assign a new one has_streams_but_maxed_out = False @@ -886,9 +897,29 @@ class Channel(models.Model): if current_profile_id == new_profile_id: return True - from apps.m3u.connection_pool import profile_connections_key + from apps.m3u.connection_pool import ( + move_credential_slot_on_profile_switch, + profile_connections_key, + ) + from apps.m3u.models import M3UAccountProfile - # Profile counters always move on switch; group totals stay unchanged (one stream). + old_profile = M3UAccountProfile.objects.select_related( + "m3u_account__server_group" + ).get(id=current_profile_id) + new_profile = M3UAccountProfile.objects.select_related( + "m3u_account__server_group" + ).get(id=new_profile_id) + + if not move_credential_slot_on_profile_switch( + old_profile, new_profile, redis_client + ): + logger.warning( + "Shared login pool full for profile %s during stream profile switch", + new_profile_id, + ) + return False + + # Profile counters always move on switch; credential totals move only when login changes. old_profile_connections_key = profile_connections_key(current_profile_id) new_profile_connections_key = profile_connections_key(new_profile_id) old_count = int(redis_client.get(old_profile_connections_key) or 0) diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py index 36a74d17..2d4c5830 100644 --- a/apps/m3u/connection_pool.py +++ b/apps/m3u/connection_pool.py @@ -2,8 +2,7 @@ Shared connection pool enforcement for M3U accounts in the same ServerGroup. Profile selection rotates across M3UAccountProfile rows using each profile's own -Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup -with max_streams > 0, a credential-scoped counter is checked on reserve/release +Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup, a credential-scoped counter is checked on reserve/release so accounts sharing the same provider login share one limit without blocking unrelated logins on the same group. Account profiles with max_streams=0 skip credential enforcement for that profile. @@ -129,9 +128,9 @@ def get_profile_credential_fingerprint(profile) -> Optional[str]: def get_enforced_server_group_for_profile(profile): - """Return the shared ServerGroup limit for this profile's account, if configured.""" + """Return the ServerGroup for credential pooling when the account is assigned to one.""" group = profile.m3u_account.server_group - if group and group.max_streams > 0: + if group: return group return None @@ -157,11 +156,6 @@ def get_credential_connection_count(profile, redis_client) -> int: return int(redis_client.get(cred_key) or 0) -def get_group_connection_count(profile, redis_client) -> int: - """Backwards-compatible alias for credential-scoped group usage.""" - return get_credential_connection_count(profile, redis_client) - - def profile_has_capacity_for_selection(profile, redis_client) -> bool: """Per-profile capacity check used when rotating across profiles on one account.""" if profile.max_streams == 0: @@ -188,6 +182,55 @@ def pool_has_capacity_for_profile(profile, redis_client) -> bool: ) +def profile_available_for_channel_switch( + profile, redis_client, *, channel_already_on_profile: bool +) -> bool: + """ + Non-mutating capacity check when selecting a profile for an in-flight channel. + + If the channel already holds this profile's slots, skip re-checking capacity. + """ + if channel_already_on_profile: + return True + return pool_has_capacity_for_profile(profile, redis_client) + + +def move_credential_slot_on_profile_switch( + old_profile, new_profile, redis_client +) -> bool: + """ + Move the shared credential counter when switching to a different provider login. + + Profile counters are managed separately by Channel.update_stream_profile(). + Returns False when the new profile's credential pool is full. + """ + old_fp = get_profile_credential_fingerprint(old_profile) + new_fp = get_profile_credential_fingerprint(new_profile) + if old_fp == new_fp: + return True + + released = _release_credential_slot_by_profile_id(old_profile.id, redis_client) + if not released: + _release_server_group_slot_for_profile(old_profile, redis_client) + + cred_reserved, cred_key = _reserve_server_group_slot_for_profile( + new_profile, redis_client + ) + if not cred_reserved: + restore_reserved, restore_key = _reserve_server_group_slot_for_profile( + old_profile, redis_client + ) + if restore_reserved and restore_key: + _remember_credential_release_key( + old_profile.id, restore_key, redis_client + ) + return False + + if cred_key: + _remember_credential_release_key(new_profile.id, cred_key, redis_client) + return True + + def _safe_decr(redis_client, key: str) -> None: current = int(redis_client.get(key) or 0) if current <= 0: diff --git a/apps/m3u/migrations/0020_servergroup_max_streams.py b/apps/m3u/migrations/0020_servergroup_max_streams.py deleted file mode 100644 index ce17d830..00000000 --- a/apps/m3u/migrations/0020_servergroup_max_streams.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 6.0.3 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("m3u", "0019_m3uaccountprofile_exp_date"), - ] - - operations = [ - migrations.AddField( - model_name="servergroup", - name="max_streams", - field=models.PositiveIntegerField( - default=0, - help_text="Maximum number of concurrent streams shared across all accounts in this group (0 for unlimited)", - ), - ), - ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 46d1a068..53c18d38 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -194,19 +194,17 @@ class M3UFilter(models.Model): class ServerGroup(models.Model): - """Represents a logical grouping of servers or channels.""" + """ + Groups M3U accounts that share provider credentials. + + Accounts assigned to the same server group share credential-scoped connection + counters when their logins match. Limits come from each account profile's + max_streams, not from the group itself. + """ name = models.CharField( max_length=100, unique=True, help_text="Unique name for this server group." ) - max_streams = models.PositiveIntegerField( - default=0, - help_text=( - "Set above 0 to enable shared credential pooling for accounts in this group. " - "Per-login limits come from each account profile's max_streams. " - "Profiles with max_streams=0 (unlimited) skip cross-account credential enforcement." - ), - ) def __str__(self): return self.name diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 29c4157f..485a3687 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -408,4 +408,4 @@ class ServerGroupSerializer(serializers.ModelSerializer): class Meta: model = ServerGroup - fields = ["id", "name", "max_streams"] + fields = ["id", "name"] diff --git a/apps/m3u/tests/test_connection_pool.py b/apps/m3u/tests/test_connection_pool.py index e5de6c10..5b05535a 100644 --- a/apps/m3u/tests/test_connection_pool.py +++ b/apps/m3u/tests/test_connection_pool.py @@ -7,7 +7,6 @@ from apps.m3u.connection_pool import ( extract_credentials_from_stream_url, get_credential_connection_count, get_enforced_server_group_for_profile, - get_group_connection_count, get_profile_connection_count, get_profile_credential_fingerprint, group_has_capacity_for_profile, @@ -94,8 +93,8 @@ class ExtractCredentialsTests(TestCase): class ManualServerGroupTests(TestCase): - def test_group_enforced_when_max_streams_set(self): - group = ServerGroup.objects.create(name="provider-a", max_streams=2) + def test_group_enforced_when_account_assigned(self): + group = ServerGroup.objects.create(name="provider-a") account = M3UAccount.objects.create( name="Account A", account_type="XC", @@ -108,7 +107,7 @@ class ManualServerGroupTests(TestCase): self.assertEqual(get_enforced_server_group_for_profile(profile), group) def test_accounts_in_same_group_share_credential_counter(self): - group = ServerGroup.objects.create(name="shared", max_streams=2) + group = ServerGroup.objects.create(name="shared") account1 = M3UAccount.objects.create( name="XC Account", account_type="XC", @@ -171,10 +170,7 @@ class ManualServerGroupTests(TestCase): class PoolEnforcementTests(TestCase): def setUp(self): self.redis = FakeRedis() - self.group = ServerGroup.objects.create( - name="test-pool", - max_streams=2, - ) + self.group = ServerGroup.objects.create(name="test-pool") self.account = M3UAccount.objects.create( name="Test Account", account_type="XC", @@ -207,8 +203,8 @@ class PoolEnforcementTests(TestCase): self.assertEqual(self.redis._data[cred_key], 0) self.assertEqual(self.redis._data[profile_key], 0) - def test_same_credential_capped_at_profile_max_not_group_max(self): - """Maintainer example: group max=2 but each login only allows 1.""" + def test_same_credential_capped_at_profile_max(self): + """Shared credential counter is capped by each profile's max_streams.""" account2 = M3UAccount.objects.create( name="Second Account", account_type="XC", @@ -229,11 +225,8 @@ class PoolEnforcementTests(TestCase): cred_key = server_group_connections_key(self.group.id, fp) self.assertEqual(self.redis._data[cred_key], 1) - def test_different_logins_both_stream_when_group_max_is_one(self): - """Regression: group max=1 must not block a second login on another profile.""" - self.group.max_streams = 1 - self.group.save() - + def test_different_logins_both_stream_in_same_group(self): + """Different provider logins keep separate credential counters in one group.""" account = M3UAccount.objects.create( name="Grouped multi-login", account_type="XC", @@ -288,7 +281,7 @@ class PoolEnforcementTests(TestCase): ): self.assertTrue(reserve_profile_slot(profile, self.redis)[0]) self.assertEqual(get_credential_connection_count(profile, self.redis), 0) - self.assertEqual(get_group_connection_count(profile, self.redis), 0) + self.assertEqual(get_credential_connection_count(profile, self.redis), 0) def test_release_when_profile_row_deleted(self): profile_id = self.profile.id @@ -328,12 +321,55 @@ class PoolEnforcementTests(TestCase): self.assertEqual(reason, "credential_full") +class StaleAssignmentTests(TestCase): + def test_stale_assignment_releases_counters(self): + from apps.channels.models import Channel, Stream + + redis = FakeRedis() + group = ServerGroup.objects.create(name="stale-group") + account = M3UAccount.objects.create( + name="Stale Account", + account_type="XC", + username="user", + password="pass", + server_url="http://xc.example.com", + server_group=group, + max_streams=5, + ) + profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + profile.max_streams = 1 + profile.save() + + stream = Stream.objects.create(name="Stale Stream", m3u_account=account) + channel = Channel.objects.create(channel_number=502, name="Stale Channel") + channel.streams.add(stream) + + reserve_profile_slot(profile, redis) + redis.set(f"channel_stream:{channel.id}", stream.id) + redis.set(f"stream_profile:{stream.id}", profile.id) + + profile_key = profile_connections_key(profile.id) + cred_key = server_group_connections_key( + group.id, get_profile_credential_fingerprint(profile) + ) + self.assertEqual(redis._data[profile_key], 1) + self.assertEqual(redis._data[cred_key], 1) + + with patch("core.utils.RedisClient.get_client", return_value=redis): + channel._release_stale_stream_assignment(redis, stream.id) + + self.assertNotIn(f"channel_stream:{channel.id}", redis._data) + self.assertNotIn(f"stream_profile:{stream.id}", redis._data) + self.assertEqual(redis._data[profile_key], 0) + self.assertEqual(redis._data[cred_key], 0) + + class UpdateStreamProfileTests(TestCase): def test_switch_updates_profile_counters_when_group_assigned(self): from apps.channels.models import Channel, Stream redis = FakeRedis() - group = ServerGroup.objects.create(name="switch-group", max_streams=2) + group = ServerGroup.objects.create(name="switch-group") account = M3UAccount.objects.create( name="Switch Account", account_type="XC", @@ -377,6 +413,58 @@ class UpdateStreamProfileTests(TestCase): self.assertEqual(redis._data[profile_connections_key(profile_b.id)], 1) self.assertEqual(redis._data[cred_key], cred_before) + def test_switch_moves_credential_counter_when_login_changes(self): + from apps.channels.models import Channel, Stream + + redis = FakeRedis() + group = ServerGroup.objects.create(name="cred-switch-group") + account = M3UAccount.objects.create( + name="Cred Switch Account", + account_type="XC", + username="login_a", + password="pass_a", + server_url="http://xc.example.com", + server_group=group, + max_streams=5, + ) + profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + profile_a.max_streams = 1 + profile_a.save() + profile_b = M3UAccountProfile.objects.create( + m3u_account=account, + name="alt_login", + is_default=False, + is_active=True, + max_streams=1, + search_pattern="", + replace_pattern="", + ) + + fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + key_a = server_group_connections_key(group.id, fp_a) + key_b = server_group_connections_key(group.id, fp_b) + + stream = Stream.objects.create(name="Cred Switch Stream", m3u_account=account) + channel = Channel.objects.create(channel_number=503, name="Cred Switch Channel") + channel.streams.add(stream) + + with patch( + "apps.m3u.connection_pool.get_profile_credential_fingerprint", + side_effect=lambda profile: fp_a if profile.id == profile_a.id else fp_b, + ): + reserve_profile_slot(profile_a, redis) + redis.set(f"channel_stream:{channel.id}", stream.id) + redis.set(f"stream_profile:{stream.id}", profile_a.id) + self.assertEqual(redis._data[key_a], 1) + self.assertNotIn(key_b, redis._data) + + with patch("core.utils.RedisClient.get_client", return_value=redis): + self.assertTrue(channel.update_stream_profile(profile_b.id)) + + self.assertEqual(redis._data[key_a], 0) + self.assertEqual(redis._data[key_b], 1) + class VodProfileSelectionTests(TestCase): def test_get_m3u_profile_skips_default_when_profile_full(self): @@ -413,3 +501,66 @@ class VodProfileSelectionTests(TestCase): self.assertIsNotNone(result) selected, _connections = result self.assertEqual(selected.id, alt.id) + + def test_get_m3u_profile_skips_default_when_credential_pool_full(self): + from apps.proxy.vod_proxy.views import _get_m3u_profile + + group = ServerGroup.objects.create(name="vod-cred-pool") + account = M3UAccount.objects.create( + name="VOD pooled", + account_type="XC", + username="shared_user", + password="shared_pass", + server_url="http://xc.example.com", + server_group=group, + max_streams=5, + ) + default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + default.max_streams = 1 + default.save() + + alt = M3UAccountProfile.objects.create( + m3u_account=account, + name="alt_login", + is_default=False, + is_active=True, + max_streams=1, + search_pattern="", + replace_pattern="", + ) + + other_account = M3UAccount.objects.create( + name="Other pooled account", + account_type="XC", + username="shared_user", + password="shared_pass", + server_url="http://xc.example.com", + server_group=group, + max_streams=5, + ) + other_profile = M3UAccountProfile.objects.get( + m3u_account=other_account, is_default=True + ) + other_profile.max_streams = 1 + other_profile.save() + + fp_shared = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + fp_alt = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + + redis = FakeRedis() + with patch( + "apps.m3u.connection_pool.get_profile_credential_fingerprint", + side_effect=lambda profile: ( + fp_shared + if profile.id in (default.id, other_profile.id) + else fp_alt + ), + ): + self.assertTrue(reserve_profile_slot(other_profile, redis)[0]) + + with patch("core.utils.RedisClient.get_client", return_value=redis): + result = _get_m3u_profile(account, None, None) + + self.assertIsNotNone(result) + selected, _connections = result + self.assertEqual(selected.id, alt.id) diff --git a/apps/proxy/live_proxy/url_utils.py b/apps/proxy/live_proxy/url_utils.py index 60d0e3d0..899ffc9a 100644 --- a/apps/proxy/live_proxy/url_utils.py +++ b/apps/proxy/live_proxy/url_utils.py @@ -8,7 +8,10 @@ from typing import Optional, Tuple, List from django.shortcuts import get_object_or_404 from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccount, M3UAccountProfile -from apps.m3u.connection_pool import profile_connections_key +from apps.m3u.connection_pool import ( + get_profile_connection_count, + profile_available_for_channel_switch, +) from core.models import UserAgent, CoreSettings, StreamProfile from .utils import get_logger from uuid import UUID @@ -54,12 +57,14 @@ def get_stream_object(id: str): logger.info(f"Fetching stream hash {id}") return get_object_or_404(Stream, stream_hash=id) -def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int], bool]: +def generate_stream_url( + channel_id: str, +) -> Tuple[str, str, bool, Optional[int], bool, Optional[str]]: """ Generate the appropriate stream URL for a channel or stream based on its profile settings. Returns: - Tuple: (stream_url, user_agent, transcode_flag, profile_id, slot_reserved) + Tuple: (stream_url, user_agent, transcode_flag, profile_id, slot_reserved, error_reason) """ try: channel_or_stream = get_stream_object(channel_id) @@ -71,12 +76,12 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int], if not stream.m3u_account: logger.error(f"Stream {stream.id} has no M3U account") - return None, None, False, None, False + return None, None, False, None, False, "Stream has no M3U account" stream_id, profile_id, error_reason, slot_reserved = stream.get_stream() if not stream_id or not profile_id: logger.error(f"No profile available for stream {stream.id}: {error_reason}") - return None, None, False, None, False + return None, None, False, None, False, error_reason try: profile = M3UAccountProfile.objects.get(id=profile_id) @@ -95,24 +100,23 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int], transcode = not stream_profile.is_proxy() stream_profile_id = stream_profile.id - return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved + return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved, None except Exception as e: logger.error(f"Error generating stream URL for stream {stream.id}: {e}") if slot_reserved: stream.release_stream() - return None, None, False, None, False + return None, None, False, None, False, str(e) # Handle channel preview (existing logic) channel = channel_or_stream # Get stream and profile for this channel - # Note: get_stream now returns 3 values (stream_id, profile_id, error_reason) stream_id, profile_id, error_reason, slot_reserved = channel.get_stream() if not stream_id or not profile_id: logger.error(f"No stream available for channel {channel_id}: {error_reason}") - return None, None, False, None, False + return None, None, False, None, False, error_reason # get_stream() allocated a connection slot - ensure it's released on any error try: @@ -142,16 +146,16 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int], stream_profile_id = stream_profile.id - return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved + return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved, None except Exception as e: logger.error(f"Error generating stream URL for channel {channel_id}: {e}") if slot_reserved: if not channel.release_stream(): logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error") - return None, None, False, None, False + return None, None, False, None, False, str(e) except Exception as e: logger.error(f"Error generating stream URL: {e}") - return None, None, False, None, False + return None, None, False, None, False, str(e) def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str: """ @@ -229,10 +233,6 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] selected_profile = None for profile in profiles: if redis_client: - current_connections = int( - redis_client.get(profile_connections_key(profile.id)) or 0 - ) - channel_using_profile = False existing_stream_id = redis_client.get(f"channel_stream:{channel.id}") if existing_stream_id: @@ -242,20 +242,22 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] if existing_profile_id and int(existing_profile_id) == profile.id: channel_using_profile = True - effective_connections = current_connections - ( - 1 if channel_using_profile else 0 - ) - - if profile.max_streams == 0 or effective_connections < profile.max_streams: + if profile_available_for_channel_switch( + profile, + redis_client, + channel_already_on_profile=channel_using_profile, + ): + current_connections = get_profile_connection_count( + profile, redis_client + ) selected_profile = profile logger.debug( f"Selected profile {profile.id} with " - f"{effective_connections}/{profile.max_streams} effective connections" + f"{current_connections}/{profile.max_streams} connections" ) break logger.debug( - f"Profile {profile.id} at max connections: " - f"{effective_connections}/{profile.max_streams}" + f"Profile {profile.id} unavailable for channel switch" ) else: selected_profile = profile @@ -368,38 +370,38 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No selected_profile = None for profile in profiles: - # Check connection availability if redis_client: - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) - - # Check if this channel is already using this profile channel_using_profile = False existing_stream_id = redis_client.get(f"channel_stream:{channel.id}") if existing_stream_id: - # Decode bytes to string/int for proper Redis key lookup - existing_stream_id = existing_stream_id - existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}") + existing_profile_id = redis_client.get( + f"stream_profile:{existing_stream_id}" + ) if existing_profile_id and int(existing_profile_id) == profile.id: channel_using_profile = True - logger.debug(f"Channel {channel.id} already using profile {profile.id}") + logger.debug( + f"Channel {channel.id} already using profile {profile.id}" + ) - # Calculate effective connections (subtract 1 if channel already using this profile) - effective_connections = current_connections - (1 if channel_using_profile else 0) - - # Check if profile has available slots - if profile.max_streams == 0 or effective_connections < profile.max_streams: + if profile_available_for_channel_switch( + profile, + redis_client, + channel_already_on_profile=channel_using_profile, + ): + current_connections = get_profile_connection_count( + profile, redis_client + ) selected_profile = profile logger.debug( f"Found available profile {profile.id} for stream {stream.id}: " - f"{effective_connections}/{profile.max_streams} effective " - f"(current: {current_connections}, already using: {channel_using_profile})" + f"{current_connections}/{profile.max_streams} " + f"(already using: {channel_using_profile})" ) break - else: - logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})") + logger.debug( + f"Profile {profile.id} unavailable for alternate stream {stream.id}" + ) else: - # No Redis available, assume first active profile is okay selected_profile = profile break diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index eb845743..d60d9703 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -221,9 +221,14 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): # Try to get a stream with fixed interval retries while should_retry and time.time() - wait_start_time < retry_timeout: attempt += 1 - stream_url, stream_user_agent, transcode, profile_value, slot_reserved = ( - generate_stream_url(channel_id) - ) + ( + stream_url, + stream_user_agent, + transcode, + profile_value, + slot_reserved, + error_reason, + ) = generate_stream_url(channel_id) if stream_url is not None: logger.info( @@ -233,7 +238,6 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): # On first failure, check if the error is retryable if attempt == 1: - _, _, error_reason, _ = channel.get_stream() if error_reason and "maximum connection limits" not in error_reason: logger.warning( f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}" @@ -266,9 +270,14 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): logger.info( f"[{client_id}] Making final attempt {attempt} at timeout boundary" ) - stream_url, stream_user_agent, transcode, profile_value, slot_reserved = ( - generate_stream_url(channel_id) - ) + ( + stream_url, + stream_user_agent, + transcode, + profile_value, + slot_reserved, + error_reason, + ) = generate_stream_url(channel_id) if stream_url is not None: logger.info( f"[{client_id}] Successfully obtained stream on final attempt for channel {channel_id}" diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index c4e84986..fc825cf4 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -262,7 +262,7 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): from core.utils import RedisClient from apps.m3u.connection_pool import ( get_profile_connection_count, - profile_has_capacity_for_selection, + pool_has_capacity_for_profile, ) redis_client = RedisClient.get_client() @@ -316,7 +316,7 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): profile_connections_key = f"profile_connections:{profile.id}" current_connections = get_profile_connection_count(profile, redis_client) - if profile_has_capacity_for_selection(profile, redis_client): + if pool_has_capacity_for_profile(profile, redis_client): logger.info(f"[PROFILE-SELECTION] Using requested profile {profile.id}: {current_connections}/{profile.max_streams} connections") return (profile, current_connections) logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}") @@ -340,11 +340,14 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): for profile in profiles: current_connections = get_profile_connection_count(profile, redis_client) - if profile_has_capacity_for_selection(profile, redis_client): + if pool_has_capacity_for_profile(profile, redis_client): logger.info(f"[PROFILE-SELECTION] Selected profile {profile.id} ({profile.name}): {current_connections}/{profile.max_streams} connections") return (profile, current_connections) else: - logger.debug(f"[PROFILE-SELECTION] Profile {profile.id} at capacity: {current_connections}/{profile.max_streams}") + logger.debug( + f"[PROFILE-SELECTION] Profile {profile.id} unavailable " + f"(profile={current_connections}/{profile.max_streams})" + ) # All profiles are at capacity - return None to trigger error response logger.error(f"[PROFILE-SELECTION] All profiles at capacity for M3U account {m3u_account.id}, rejecting request") diff --git a/frontend/src/components/ServerGroupsManagerModal.jsx b/frontend/src/components/ServerGroupsManagerModal.jsx new file mode 100644 index 00000000..5e3005ec --- /dev/null +++ b/frontend/src/components/ServerGroupsManagerModal.jsx @@ -0,0 +1,28 @@ +import { Modal } from '@mantine/core'; +import ServerGroupsTable from './tables/ServerGroupsTable'; + +const ServerGroupsManagerModal = ({ + isOpen, + onClose, + onGroupCreated, + openCreateOnMount = false, +}) => { + return ( + + {isOpen ? ( + + ) : null} + + ); +}; + +export default ServerGroupsManagerModal; diff --git a/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx b/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx new file mode 100644 index 00000000..a60ae4db --- /dev/null +++ b/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import ServerGroupsManagerModal from '../ServerGroupsManagerModal'; + +vi.mock('../../api', () => ({ + default: { + deleteServerGroup: vi.fn(), + }, +})); + +vi.mock('../../store/serverGroups', () => ({ + default: (selector) => + selector({ + serverGroups: [ + { id: 1, name: 'Pool A' }, + { id: 2, name: 'Pool B' }, + ], + fetchServerGroups: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock('../../store/playlists', () => ({ + default: (selector) => + selector({ + playlists: [{ id: 10, server_group: 2 }], + }), +})); + +vi.mock('../forms/ServerGroup', () => ({ + default: () => null, +})); + +vi.mock('../../hooks/useLocalStorage', () => ({ + default: () => ['default', vi.fn()], +})); + +const renderModal = (props = {}) => + render( + + + + ); + +describe('ServerGroupsManagerModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens without Mantine Tooltip errors', async () => { + expect(() => renderModal()).not.toThrow(); + + await waitFor(() => { + expect(screen.getByText('Pool A')).toBeInTheDocument(); + expect(screen.getByText('Pool B')).toBeInTheDocument(); + expect(screen.getByText('Accounts')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 6e19c93a..bfc015de 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -36,6 +36,7 @@ import { prepareSubmitValues, updatePlaylist, } from '../../utils/forms/M3uUtils.js'; +import ServerGroupsManagerModal from '../ServerGroupsManagerModal'; const M3U = ({ m3uAccount = null, @@ -56,6 +57,8 @@ const M3U = ({ const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false); const [filterModalOpen, setFilterModalOpen] = useState(false); const [scheduleType, setScheduleType] = useState('interval'); + const [serverGroupsManagerOpen, setServerGroupsManagerOpen] = useState(false); + const [serverGroupsCreateOnOpen, setServerGroupsCreateOnOpen] = useState(false); const form = useForm({ mode: 'uncontrolled', @@ -366,18 +369,36 @@ const M3U = ({ name="server_group" label="Server Group" description="Share login limits across accounts in a server group. Set max streams on each profile (unlimited profiles skip group enforcement)." - {...form.getInputProps('server_group')} key={form.key('server_group')} - data={[{ value: '0', label: '(None)' }].concat( - serverGroups.map((group) => ({ - label: - group.max_streams > 0 - ? `${group.name} (max: ${group.max_streams})` - : `${group.name} (unlimited)`, + value={form.getValues().server_group} + onChange={(value) => { + if (value === '__new__') { + setServerGroupsCreateOnOpen(true); + setServerGroupsManagerOpen(true); + return; + } + form.setFieldValue('server_group', value); + }} + data={[ + { value: '0', label: '(None)' }, + ...serverGroups.map((group) => ({ + label: group.name, value: `${group.id}`, - })) - )} + })), + { value: '__new__', label: '+ Add server group...' }, + ]} /> + } data={[ - { - value: 'STD', - label: 'Standard', - }, - { - value: 'XC', - label: 'Xtream Codes', - }, + { value: 'STD', label: 'Standard' }, + { value: 'XC', label: 'Xtream Codes' }, ]} key={form.key('account_type')} {...form.getInputProps('account_type')} /> {form.getValues().account_type == 'XC' && ( - - {!m3uAccount && ( - - Create EPG - - - )} - - - Enable VOD Scanning - - - + <> - - + )} {form.getValues().account_type != 'XC' && ( @@ -335,7 +297,6 @@ const M3U = ({ }, }} /> - - + - + + + + } /> - - + {form.getValues().account_type == 'XC' && ( + + + + + Enable VOD Scanning + + + + {!m3uAccount && ( + + Create EPG + + + )} + + )} - - + - - - {playlist && ( - <> - - - - - )} + + {playlist && ( + <> + + + + + )} - + + From 4fc12bca4a449826d9c3b5fb54c85c26ca651411 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Jun 2026 17:59:12 -0500 Subject: [PATCH 09/11] refactor(m3u, channels, proxy): enhance stream assignment logic and error handling - Improved logging in the Stream model for better debugging of profile evaluations. - Introduced a new method `_stream_assignment_is_reusable` in the Channel model to determine if existing stream assignments can be reused, enhancing efficiency. - Updated the release logic in `release_profile_slot` to utilize stored credential keys, reducing unnecessary database lookups. - Simplified error handling in the `get_stream_info_for_switch` function to ensure proper stream release on exceptions. - Enhanced tests for connection pool management and error handling in the ServerGroupsTable component to improve reliability and user feedback. --- apps/channels/models.py | 23 ++- .../tests/test_get_stream_assignment.py | 173 ++++++++++++++++++ apps/m3u/connection_pool.py | 32 ++-- apps/m3u/tests/test_connection_pool.py | 28 +++ apps/proxy/live_proxy/channel_status.py | 10 +- apps/proxy/live_proxy/url_utils.py | 16 +- .../ServerGroupsManagerModal.test.jsx | 2 + .../components/tables/ServerGroupsTable.jsx | 17 +- .../__tests__/ServerGroupsTable.test.jsx | 41 ++++- 9 files changed, 293 insertions(+), 49 deletions(-) create mode 100644 apps/channels/tests/test_get_stream_assignment.py diff --git a/apps/channels/models.py b/apps/channels/models.py index e84f9768..f900a817 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -228,7 +228,7 @@ class Stream(models.Model): ] for profile in profiles: - logger.info(profile) + logger.debug("Evaluating profile %s for stream %s", profile.id, self.id) # Skip inactive profiles if profile.is_active == False: continue @@ -607,6 +607,23 @@ class Channel(models.Model): ChannelState.CONNECTING, ) + def _stream_assignment_is_reusable(self, redis_client, stream_id: int) -> bool: + """ + Return True when an existing channel_stream assignment should be reused. + + Reuse when the proxy is active, or when metadata is not written yet + (between get_stream() reserving slots and initialize_channel() starting). + When metadata exists but the proxy is inactive, the assignment is stale. + """ + if self._channel_proxy_is_active(redis_client): + return True + + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + if not redis_client.exists(metadata_key): + return redis_client.get(f"stream_profile:{stream_id}") is not None + + return False + def _release_stale_stream_assignment(self, redis_client, stream_id: int) -> None: """Release pool counters and remove stale channel/stream assignment keys.""" profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") @@ -654,13 +671,13 @@ class Channel(models.Model): stream_id = None if stream_id is not None: - if self._channel_proxy_is_active(redis_client): + if self._stream_assignment_is_reusable(redis_client, stream_id): profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") if profile_id_bytes: try: profile_id = int(profile_id_bytes) logger.debug( - f"Channel {self.uuid}: reusing active assignment " + f"Channel {self.uuid}: reusing stream assignment " f"stream={stream_id} profile={profile_id}" ) return stream_id, profile_id, None, False diff --git a/apps/channels/tests/test_get_stream_assignment.py b/apps/channels/tests/test_get_stream_assignment.py new file mode 100644 index 00000000..bd5fea2e --- /dev/null +++ b/apps/channels/tests/test_get_stream_assignment.py @@ -0,0 +1,173 @@ +"""Tests for Channel.get_stream() assignment reuse and stale cleanup.""" + +from unittest.mock import patch + +from django.test import TestCase + +from apps.channels.models import Channel, ChannelStream, Stream +from apps.m3u.models import M3UAccount, M3UAccountProfile +from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState +from apps.proxy.live_proxy.redis_keys import RedisKeys + + +class FakeAssignmentRedis: + """In-memory Redis for channel_stream assignment tests.""" + + def __init__(self): + self._strings = {} + self._hashes = {} + + def _decode(self, value): + if isinstance(value, bytes): + return value.decode() + return value + + def get(self, key): + value = self._strings.get(key) + if value is None: + return None + if isinstance(value, int): + return str(value).encode() + return str(value).encode() + + def set(self, key, value): + self._strings[key] = value + + def delete(self, key): + self._strings.pop(key, None) + self._hashes.pop(key, None) + + def exists(self, key): + return key in self._strings or key in self._hashes + + def hget(self, key, field): + return self._hashes.get(key, {}).get(field) + + def hset(self, key, mapping=None, **kwargs): + bucket = self._hashes.setdefault(key, {}) + if mapping: + bucket.update(mapping) + bucket.update(kwargs) + + def incr(self, key): + current = int(self._decode(self.get(key)) or 0) + current += 1 + self._strings[key] = current + return current + + def decr(self, key): + current = int(self._decode(self.get(key)) or 0) + current -= 1 + self._strings[key] = current + return current + + +class ChannelGetStreamAssignmentTests(TestCase): + def setUp(self): + self.redis = FakeAssignmentRedis() + self.account = M3UAccount.objects.create( + name="assignment-test", + account_type="XC", + username="user", + password="pass", + max_streams=5, + ) + self.profile = M3UAccountProfile.objects.get( + m3u_account=self.account, is_default=True + ) + self.profile.max_streams = 2 + self.profile.save() + + self.stream = Stream.objects.create( + name="Test Stream", + url="http://example.com/live/user/pass/1.ts", + m3u_account=self.account, + ) + self.channel = Channel.objects.create(channel_number=501, name="Assignment Ch") + ChannelStream.objects.create(channel=self.channel, stream=self.stream, order=0) + + self.metadata_key = RedisKeys.channel_metadata(str(self.channel.uuid)) + + def _seed_assignment(self): + self.redis.set(f"channel_stream:{self.channel.id}", self.stream.id) + self.redis.set(f"stream_profile:{self.stream.id}", self.profile.id) + + @patch("apps.channels.models.RedisClient.get_client") + @patch("apps.channels.models.reserve_profile_slot") + def test_reuses_assignment_when_proxy_active( + self, mock_reserve, mock_get_client + ): + mock_get_client.return_value = self.redis + self._seed_assignment() + self.redis.hset( + self.metadata_key, + {ChannelMetadataField.STATE: ChannelState.ACTIVE}, + ) + + stream_id, profile_id, error, slot_reserved = self.channel.get_stream() + + self.assertEqual(stream_id, self.stream.id) + self.assertEqual(profile_id, self.profile.id) + self.assertIsNone(error) + self.assertFalse(slot_reserved) + mock_reserve.assert_not_called() + + @patch("apps.channels.models.RedisClient.get_client") + @patch("apps.channels.models.reserve_profile_slot") + def test_reuses_assignment_during_init_before_metadata( + self, mock_reserve, mock_get_client + ): + mock_get_client.return_value = self.redis + self._seed_assignment() + + stream_id, profile_id, error, slot_reserved = self.channel.get_stream() + + self.assertEqual(stream_id, self.stream.id) + self.assertEqual(profile_id, self.profile.id) + self.assertIsNone(error) + self.assertFalse(slot_reserved) + mock_reserve.assert_not_called() + + @patch("apps.channels.models.RedisClient.get_client") + @patch("apps.channels.models.release_profile_slot") + @patch("apps.channels.models.reserve_profile_slot") + def test_releases_stale_assignment_when_proxy_stopped( + self, mock_reserve, mock_release, mock_get_client + ): + mock_get_client.return_value = self.redis + mock_reserve.return_value = (True, 1, None) + self._seed_assignment() + self.redis.hset( + self.metadata_key, + {ChannelMetadataField.STATE: ChannelState.STOPPED}, + ) + + stream_id, profile_id, error, slot_reserved = self.channel.get_stream() + + mock_release.assert_called_once_with(self.profile.id, self.redis) + mock_reserve.assert_called_once() + self.assertEqual(stream_id, self.stream.id) + self.assertEqual(profile_id, self.profile.id) + self.assertTrue(slot_reserved) + + @patch("apps.channels.models.RedisClient.get_client") + def test_stream_assignment_is_reusable_during_init_pending(self, mock_get_client): + mock_get_client.return_value = self.redis + self._seed_assignment() + + self.assertTrue( + self.channel._stream_assignment_is_reusable(self.redis, self.stream.id) + ) + + @patch("apps.channels.models.RedisClient.get_client") + def test_stream_assignment_not_reusable_when_stopped(self, mock_get_client): + mock_get_client.return_value = self.redis + self._seed_assignment() + self.redis.hset( + self.metadata_key, + {ChannelMetadataField.STATE: ChannelState.STOPPED}, + ) + + self.assertFalse( + self.channel._stream_assignment_is_reusable(self.redis, self.stream.id) + ) diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py index 2d4c5830..0fa0864f 100644 --- a/apps/m3u/connection_pool.py +++ b/apps/m3u/connection_pool.py @@ -172,7 +172,7 @@ def group_has_capacity_for_profile(profile, redis_client) -> bool: cred_key = _credential_counter_key(profile, group) if not cred_key: return True - return get_credential_connection_count(profile, redis_client) < profile.max_streams + return int(redis_client.get(cred_key) or 0) < profile.max_streams def pool_has_capacity_for_profile(profile, redis_client) -> bool: @@ -326,22 +326,26 @@ def reserve_profile_slot( def release_profile_slot(profile_id: int, redis_client) -> None: """Release profile and shared credential slots after a stream end.""" - from apps.m3u.models import M3UAccountProfile - released_via_stored_key = _release_credential_slot_by_profile_id( profile_id, redis_client ) - try: - profile = M3UAccountProfile.objects.get(id=profile_id) - except M3UAccountProfile.DoesNotExist: - profile = None - profile_key = profile_connections_key(profile_id) - if profile is None or profile.max_streams > 0: - current = int(redis_client.get(profile_key) or 0) - if current > 0: - redis_client.decr(profile_key) + current = int(redis_client.get(profile_key) or 0) + if current > 0: + redis_client.decr(profile_key) - if profile and not released_via_stored_key: - _release_server_group_slot_for_profile(profile, redis_client) + if released_via_stored_key: + return + + # Legacy fallback for reservations made before credential release keys were stored. + from apps.m3u.models import M3UAccountProfile + + try: + profile = M3UAccountProfile.objects.select_related( + "m3u_account__server_group" + ).get(id=profile_id) + except M3UAccountProfile.DoesNotExist: + return + + _release_server_group_slot_for_profile(profile, redis_client) diff --git a/apps/m3u/tests/test_connection_pool.py b/apps/m3u/tests/test_connection_pool.py index 5b05535a..443c5bd8 100644 --- a/apps/m3u/tests/test_connection_pool.py +++ b/apps/m3u/tests/test_connection_pool.py @@ -186,6 +186,18 @@ class PoolEnforcementTests(TestCase): self.profile.max_streams = 1 self.profile.save() + def test_group_has_capacity_reads_credential_counter_directly(self): + cred_key = server_group_connections_key( + self.group.id, + get_profile_credential_fingerprint(self.profile), + ) + self.redis.set(cred_key, 1) + + self.assertFalse(group_has_capacity_for_profile(self.profile, self.redis)) + + self.redis.set(cred_key, 0) + self.assertTrue(group_has_capacity_for_profile(self.profile, self.redis)) + def test_reserve_and_release_both_counters(self): reserved, count, _ = reserve_profile_slot(self.profile, self.redis) self.assertTrue(reserved) @@ -301,6 +313,22 @@ class PoolEnforcementTests(TestCase): self.assertEqual(self.redis._data[cred_key], 0) self.assertNotIn(profile_credential_release_key(profile_id), self.redis._data) + def test_release_uses_stored_credential_key_without_db_lookup(self): + profile_id = self.profile.id + cred_key = server_group_connections_key( + self.group.id, + get_profile_credential_fingerprint(self.profile), + ) + + self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0]) + + with patch("apps.m3u.models.M3UAccountProfile.objects.get") as mock_get: + release_profile_slot(profile_id, self.redis) + mock_get.assert_not_called() + + self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0) + self.assertEqual(self.redis._data[cred_key], 0) + def test_reserve_returns_failure_reason_without_extra_checks(self): account2 = M3UAccount.objects.create( name="Reason Account", diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index f52a5fbb..9bd1d284 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -501,15 +501,7 @@ class ChannelStatus: m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE) if m3u_profile_id: try: - profile_id_int = int(m3u_profile_id) - info['m3u_profile_id'] = profile_id_int - try: - from apps.m3u.models import M3UAccountProfile - m3u_profile = M3UAccountProfile.objects.filter(id=profile_id_int).first() - if m3u_profile: - info['m3u_profile_name'] = m3u_profile.name - except Exception as e: - logger.warning(f"Failed to get M3U profile name for ID {profile_id_int}: {e}") + info['m3u_profile_id'] = int(m3u_profile_id) except ValueError: logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}") diff --git a/apps/proxy/live_proxy/url_utils.py b/apps/proxy/live_proxy/url_utils.py index 899ffc9a..7cdc92ef 100644 --- a/apps/proxy/live_proxy/url_utils.py +++ b/apps/proxy/live_proxy/url_utils.py @@ -203,6 +203,8 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] Returns: dict: Stream information including URL, user agent and transcode flag """ + slot_reserved = False + channel = None try: from core.utils import RedisClient @@ -268,28 +270,18 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] m3u_profile_id = selected_profile.id else: - stream_id, m3u_profile_id, error_reason, _slot_reserved = channel.get_stream() + stream_id, m3u_profile_id, error_reason, slot_reserved = channel.get_stream() if stream_id is None or m3u_profile_id is None: return {'error': error_reason or 'No stream assigned to channel'} - # Get the stream and profile objects directly stream = get_object_or_404(Stream, pk=stream_id) profile = get_object_or_404(M3UAccountProfile, pk=m3u_profile_id) - # Check connections left m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id) - #connections_left = get_connections_left(m3u_profile_id) - - #if connections_left <= 0: - #logger.warning(f"No connections left for M3U account {m3u_account.id}") - #return {'error': 'No connections left'} - - # Get the user agent from the M3U account user_agent = m3u_account.get_user_agent().user_agent stream_url = _resolve_live_stream_url(stream, m3u_account, profile) - # Get transcode info from the channel's stream profile stream_profile = channel.get_stream_profile() transcode = not (stream_profile.is_proxy() or stream_profile is None) profile_value = stream_profile.id @@ -304,6 +296,8 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] 'stream_name': stream.name, } except Exception as e: + if slot_reserved and channel is not None: + channel.release_stream() logger.error(f"Error getting stream info for switch: {e}", exc_info=True) return {'error': f'Error: {str(e)}'} diff --git a/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx b/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx index a60ae4db..c35b1be1 100644 --- a/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx +++ b/frontend/src/components/__tests__/ServerGroupsManagerModal.test.jsx @@ -16,6 +16,8 @@ vi.mock('../../store/serverGroups', () => ({ { id: 1, name: 'Pool A' }, { id: 2, name: 'Pool B' }, ], + isLoading: false, + error: null, fetchServerGroups: vi.fn().mockResolvedValue(undefined), }), })); diff --git a/frontend/src/components/tables/ServerGroupsTable.jsx b/frontend/src/components/tables/ServerGroupsTable.jsx index 54500294..ee3d07e4 100644 --- a/frontend/src/components/tables/ServerGroupsTable.jsx +++ b/frontend/src/components/tables/ServerGroupsTable.jsx @@ -56,6 +56,8 @@ const ServerGroupsTable = ({ onGroupCreated, openCreateOnMount = false }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const serverGroups = useServerGroupsStore((state) => state.serverGroups); + const isLoading = useServerGroupsStore((state) => state.isLoading); + const error = useServerGroupsStore((state) => state.error); const fetchServerGroups = useServerGroupsStore( (state) => state.fetchServerGroups ); @@ -106,8 +108,6 @@ const ServerGroupsTable = ({ onGroupCreated, openCreateOnMount = false }) => { [] ); - const [isLoading, setIsLoading] = useState(true); - const editServerGroup = (group = null) => { setServerGroup(group); setServerGroupModalOpen(true); @@ -147,7 +147,7 @@ const ServerGroupsTable = ({ onGroupCreated, openCreateOnMount = false }) => { }; useEffect(() => { - fetchServerGroups().finally(() => setIsLoading(false)); + fetchServerGroups(); }, [fetchServerGroups]); useEffect(() => { @@ -179,7 +179,6 @@ const ServerGroupsTable = ({ onGroupCreated, openCreateOnMount = false }) => { columns, data: tableData, allRowIds: tableData.map((group) => group.id), - enableColumnResizing: false, bodyCellRenderFns: { actions: renderBodyCell, }, @@ -198,6 +197,16 @@ const ServerGroupsTable = ({ onGroupCreated, openCreateOnMount = false }) => { ); } + if (error) { + return ( +
+ + {error} + +
+ ); + } + return ( diff --git a/frontend/src/components/tables/__tests__/ServerGroupsTable.test.jsx b/frontend/src/components/tables/__tests__/ServerGroupsTable.test.jsx index 73719512..3896d133 100644 --- a/frontend/src/components/tables/__tests__/ServerGroupsTable.test.jsx +++ b/frontend/src/components/tables/__tests__/ServerGroupsTable.test.jsx @@ -3,6 +3,16 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MantineProvider } from '@mantine/core'; import ServerGroupsTable from '../ServerGroupsTable'; +const mockServerGroupsState = vi.hoisted(() => ({ + serverGroups: [ + { id: 1, name: 'Pool A' }, + { id: 2, name: 'Pool B' }, + ], + isLoading: false, + error: null, + fetchServerGroups: vi.fn().mockResolvedValue(undefined), +})); + const renderTable = () => render( @@ -17,14 +27,7 @@ vi.mock('../../../api', () => ({ })); vi.mock('../../../store/serverGroups', () => ({ - default: (selector) => - selector({ - serverGroups: [ - { id: 1, name: 'Pool A' }, - { id: 2, name: 'Pool B' }, - ], - fetchServerGroups: vi.fn().mockResolvedValue(undefined), - }), + default: (selector) => selector(mockServerGroupsState), })); vi.mock('../../../store/playlists', () => ({ @@ -44,6 +47,15 @@ vi.mock('../../forms/ServerGroup', () => ({ describe('ServerGroupsTable', () => { beforeEach(() => { vi.clearAllMocks(); + mockServerGroupsState.serverGroups = [ + { id: 1, name: 'Pool A' }, + { id: 2, name: 'Pool B' }, + ]; + mockServerGroupsState.isLoading = false; + mockServerGroupsState.error = null; + mockServerGroupsState.fetchServerGroups = vi + .fn() + .mockResolvedValue(undefined); }); it('renders server groups without tooltip errors', async () => { @@ -55,4 +67,17 @@ describe('ServerGroupsTable', () => { expect(screen.getByText('Accounts')).toBeInTheDocument(); }); }); + + it('shows store error message when fetch fails', async () => { + mockServerGroupsState.serverGroups = []; + mockServerGroupsState.error = 'Failed to load server groups.'; + + renderTable(); + + await waitFor(() => { + expect( + screen.getByText('Failed to load server groups.') + ).toBeInTheDocument(); + }); + }); }); From c6b23690f4e4aedbe15b748cdda841e7e0dcbeec Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Jun 2026 18:09:55 -0500 Subject: [PATCH 10/11] changelog: enhance M3U connection management and server group features --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b27745..19ff2f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Manual Server Groups for shared M3U connection limits.** The existing `ServerGroup` model and account FK are now wired into live and VOD playback. Accounts assigned to the same group share a credential-scoped Redis counter when their provider logins match (hashed fingerprint); unrelated logins in the same group keep separate counters. Enforcement uses each profile's `max_streams` - not a group-wide cap - and profiles with `max_streams=0` skip credential pooling for that profile while still rotating on their own per-profile counter. New `apps/m3u/connection_pool.py` centralizes reserve/release, profile rotation, and credential moves on profile switch. (Closes #1137) — Thanks [@Goldenfreddy0703](https://github.com/Goldenfreddy0703) + - **Server Groups manager UI** on the M3U Accounts page: create, rename, and delete groups with account counts and delete confirmation. Groups load on login via a new Zustand store and REST helpers (`/api/m3u/server-groups/`). + - **M3U account form** adds a Server Group picker (including inline “add group”), reorganized into three columns (source/auth, connection limits, sync/content), and a Manage server groups shortcut. + - **VOD profile selection** uses `pool_has_capacity_for_profile()` so grouped accounts respect shared credential limits before a profile is chosen (non-grouped accounts behave as before). + - **Live profile switches** move the shared credential counter when the new profile uses a different provider login; same-login switches leave the credential counter unchanged. + - **Credential release keys** stored at reserve time allow counters to be released even if the M3U profile row is deleted afterward. + ### Changed +- **Live and VOD connection slot logic now routes through `connection_pool`.** `Channel.get_stream()`, direct `Stream.get_stream()`, VOD profile selection, and the multi-worker VOD connection manager all call `reserve_profile_slot()` / `release_profile_slot()` instead of inline Redis INCR/DECR. VOD profile selection also checks `pool_has_capacity_for_profile()` before choosing a profile. +- **Live XC upstream URLs use current credentials.** `_resolve_live_stream_url()` builds `/live/{user}/{pass}/{stream_id}.ts` from transformed account credentials and the stream's provider `stream_id`, so playback stays aligned with the active login after credential or profile changes instead of reusing a stale `stream.url` from sync. +- **Channel stream switches check pooled capacity.** `get_stream_info_for_switch()` uses `profile_available_for_channel_switch()` when targeting a specific stream, and releases a reserved slot if URL assembly fails after `get_stream()` allocated one. +- **Live proxy init reads Redis assignment once.** After `generate_stream_url()` reserves a slot, the stream handler reads `channel_stream` / `stream_profile` from Redis instead of calling `get_stream()` again, avoiding double INCR under concurrent release. - **EPG auto-match overhaul** — matching logic moved to `apps/channels/epg_matching.py`; Celery tasks in `tasks.py` are thin wrappers. - Single-channel auto-match is now asynchronous: the API returns `202 Accepted` and pushes the result over WebSocket (`single_channel_epg_match`), so large EPG libraries no longer hit the previous 30-second HTTP timeout. - Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers. @@ -27,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Stale `channel_stream` Redis keys after a channel stopped could skip connection accounting on retune.** On `dev`, `get_stream()` reused any existing `channel_stream` / `stream_profile` assignment without reserving a new slot. If those keys were left behind after a stop, the next tune-in could reach the provider without incrementing Redis counters. `get_stream()` now releases stale assignments when proxy metadata shows the channel is inactive, then reserves fresh slots. - **EPG auto-match reliability fixes.** - Memory could spike to multiple GB on large EPG sources when building a full in-memory catalog before fuzzy matching; single-channel matching now streams rows and bounds ML work to a small candidate set. - Wrong channel assignments from global ML similarity; ML validation now checks the fuzzy best match (or top fuzzy candidates as a last resort) instead of scoring the entire catalog. From 95975d5756858da2597a3830cd6a2c92d20beb4e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Jun 2026 18:38:52 -0500 Subject: [PATCH 11/11] refactor(connection_pool): streamline credential slot management - Removed redundant logic for releasing server group slots in `move_credential_slot_on_profile_switch` and `release_profile_slot`. - Simplified the `_release_server_group_slot_for_profile` function by eliminating it, as its functionality was integrated into other methods. --- apps/m3u/connection_pool.py | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/apps/m3u/connection_pool.py b/apps/m3u/connection_pool.py index 0fa0864f..375714ee 100644 --- a/apps/m3u/connection_pool.py +++ b/apps/m3u/connection_pool.py @@ -209,9 +209,7 @@ def move_credential_slot_on_profile_switch( if old_fp == new_fp: return True - released = _release_credential_slot_by_profile_id(old_profile.id, redis_client) - if not released: - _release_server_group_slot_for_profile(old_profile, redis_client) + _release_credential_slot_by_profile_id(old_profile.id, redis_client) cred_reserved, cred_key = _reserve_server_group_slot_for_profile( new_profile, redis_client @@ -279,15 +277,6 @@ def _reserve_server_group_slot_for_profile( return False, None -def _release_server_group_slot_for_profile(profile, redis_client) -> None: - group = get_enforced_server_group_for_profile(profile) - if not group or profile.max_streams == 0: - return - cred_key = _credential_counter_key(profile, group) - if cred_key: - _safe_decr(redis_client, cred_key) - - def reserve_profile_slot( profile, redis_client ) -> Tuple[bool, int, Optional[ReserveFailureReason]]: @@ -326,26 +315,9 @@ def reserve_profile_slot( def release_profile_slot(profile_id: int, redis_client) -> None: """Release profile and shared credential slots after a stream end.""" - released_via_stored_key = _release_credential_slot_by_profile_id( - profile_id, redis_client - ) + _release_credential_slot_by_profile_id(profile_id, redis_client) profile_key = profile_connections_key(profile_id) current = int(redis_client.get(profile_key) or 0) if current > 0: redis_client.decr(profile_key) - - if released_via_stored_key: - return - - # Legacy fallback for reservations made before credential release keys were stored. - from apps.m3u.models import M3UAccountProfile - - try: - profile = M3UAccountProfile.objects.select_related( - "m3u_account__server_group" - ).get(id=profile_id) - except M3UAccountProfile.DoesNotExist: - return - - _release_server_group_slot_for_profile(profile, redis_client)