mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
d89f634213
commit
bb9b95b4a4
16 changed files with 701 additions and 464 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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}/`);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
90
frontend/src/components/forms/ServerGroup.jsx
Normal file
90
frontend/src/components/forms/ServerGroup.jsx
Normal 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;
|
||||
203
frontend/src/components/tables/ServerGroupsTable.jsx
Normal file
203
frontend/src/components/tables/ServerGroupsTable.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
40
frontend/src/store/serverGroups.jsx
Normal file
40
frontend/src/store/serverGroups.jsx
Normal 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;
|
||||
|
|
@ -50,5 +50,9 @@ export const prepareSubmitValues = (values, expDate) => {
|
|||
prepared.user_agent = null;
|
||||
}
|
||||
|
||||
if (prepared.server_group == '0') {
|
||||
prepared.server_group = null;
|
||||
}
|
||||
|
||||
return prepared;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue