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 <cursoragent@cursor.com>
This commit is contained in:
Goldenfreddy0703 2026-05-23 20:18:45 -04:00
parent d89f634213
commit bb9b95b4a4
16 changed files with 701 additions and 464 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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),
]

View file

@ -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."""

View file

@ -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)

View file

@ -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})")

View file

@ -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

View file

@ -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}")

View file

@ -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}/`);

View file

@ -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')}
/>
<Select
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)"
{...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: `${group.id}`,
}))
)}
/>
<Select
id="user_agent"
name="user_agent"

View file

@ -0,0 +1,90 @@
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import { Button, Flex, Modal, NumberInput, TextInput } from '@mantine/core';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
max_streams: Yup.number()
.min(0, 'Must be 0 or greater')
.required('Max streams is required'),
});
const ServerGroupForm = ({ serverGroup = null, isOpen, onClose }) => {
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 (
<Modal opened={isOpen} onClose={onClose} title="Server Group">
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
label="Name"
{...register('name')}
error={errors.name?.message}
/>
<NumberInput
label="Max Streams"
description="Shared connection limit for all accounts in this group (0 = unlimited)"
min={0}
value={maxStreams}
onChange={(value) => setValue('max_streams', value ?? 0)}
error={errors.max_streams?.message}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button size="small" type="submit" disabled={isSubmitting}>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default ServerGroupForm;

View file

@ -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 (
<>
<ActionIcon
variant="transparent"
size="sm"
color="yellow.5"
onClick={() => editServerGroup(row.original)}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
onClick={() => deleteServerGroup(row.original.id)}
>
<SquareMinus size="18" />
</ActionIcon>
</>
);
};
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 (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
};
const renderBodyCell = ({ row }) => {
return (
<RowActions
row={row}
editServerGroup={editServerGroup}
deleteServerGroup={deleteServerGroup}
/>
);
};
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 (
<Center>
<Text size="sm">Loading server groups...</Text>
</Center>
);
}
return (
<Stack gap={0} style={{ padding: 0 }}>
<Paper>
<Box
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: 10,
}}
>
<Flex gap={6}>
<Tooltip label="Create a shared connection pool for multiple accounts">
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editServerGroup()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add Server Group
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<Box
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 300,
width: '100%',
overflow: 'hidden',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<div style={{ minWidth: 400 }}>
<CustomTable table={table} />
</div>
</Box>
</Box>
<ServerGroupForm
serverGroup={serverGroup}
isOpen={serverGroupModalOpen}
onClose={closeServerGroupForm}
/>
</Stack>
);
};
export default ServerGroupsTable;

View file

@ -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 = () => {
</AccordionPanel>
</AccordionItem>
<AccordionItem value="server-groups">
<AccordionControl>Server Groups</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<ServerGroupsTable
active={accordianValue === 'server-groups'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="user-agents">
<AccordionControl>User-Agents</AccordionControl>
<AccordionPanel>

View file

@ -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(),
]);

View file

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

View file

@ -50,5 +50,9 @@ export const prepareSubmitValues = (values, expDate) => {
prepared.user_agent = null;
}
if (prepared.server_group == '0') {
prepared.server_group = null;
}
return prepared;
};