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; };