Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/firestaerter3/1125

This commit is contained in:
SergeantPanda 2026-04-13 17:42:43 -05:00
commit d73071b20f
209 changed files with 28228 additions and 6493 deletions

View file

@ -6,6 +6,7 @@ from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.response import Response
from rest_framework import viewsets, status, serializers
from rest_framework.throttling import AnonRateThrottle
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
import json
@ -20,9 +21,14 @@ from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
logger = logging.getLogger(__name__)
class LoginRateThrottle(AnonRateThrottle):
scope = "login"
class TokenObtainPairView(TokenObtainPairView):
throttle_classes = [LoginRateThrottle]
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
# Log blocked login attempt due to network restrictions
from core.utils import log_system_event
@ -153,12 +159,11 @@ class AuthViewSet(viewsets.ViewSet):
Login doesn't require auth, but logout does
"""
if self.action == 'logout':
from rest_framework.permissions import IsAuthenticated
return [IsAuthenticated()]
return [Authenticated()]
return []
@extend_schema(
description="Authenticate and log in a user",
description="Alias for POST /api/accounts/token/ — returns JWT access and refresh tokens.",
request=inline_serializer(
name="LoginRequest",
fields={
@ -168,55 +173,10 @@ class AuthViewSet(viewsets.ViewSet):
),
)
def login(self, request):
"""Logs in a user and returns user details"""
username = request.data.get("username")
password = request.data.get("password")
user = authenticate(request, username=username, password=password)
# Get client info for logging
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
logger.debug(f"Login attempt via session: user={username} ip={client_ip}")
if user:
login(request, user)
# Update last_login timestamp
from django.utils import timezone
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
# Log successful login
log_system_event(
event_type='login_success',
user=username,
client_ip=client_ip,
user_agent=user_agent,
)
logger.info(f"Login success via session: user={username} ip={client_ip}")
return Response(
{
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list("name", flat=True)),
},
}
)
# Log failed login attempt
log_system_event(
event_type='login_failed',
user=username or 'unknown',
client_ip=client_ip,
user_agent=user_agent,
reason='Invalid credentials',
)
logger.info(f"Login failed via session: user={username} ip={client_ip}")
return Response({"error": "Invalid credentials"}, status=400)
"""Delegates to TokenObtainPairView (JWT login). Throttling, logging, and
network access checks are handled there."""
view = TokenObtainPairView.as_view()
return view(request._request)
@extend_schema(
description="Log out the current user",
@ -287,11 +247,18 @@ class UserViewSet(viewsets.ModelViewSet):
if request.method == "PATCH":
ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"}
disallowed = set(request.data.keys()) - ALLOWED_FIELDS
if disallowed:
return Response(
{"detail": f"Fields not allowed for self-update: {', '.join(disallowed)}"},
status=400,
)
for key in disallowed:
request.data.pop(key, None)
# Strip admin-managed keys from custom_properties so users cannot
# set their own XC credentials via this endpoint.
ADMIN_ONLY_PROPS = {"xc_password"}
cp = request.data.get("custom_properties")
if isinstance(cp, dict):
for key in ADMIN_ONLY_PROPS:
cp.pop(key, None)
serializer = UserSerializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()

View file

@ -1,9 +1,46 @@
from rest_framework import authentication
from rest_framework import exceptions
from django.conf import settings
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from .models import User
class JWTAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "rest_framework_simplejwt.authentication.JWTAuthentication"
name = "jwtAuth"
def get_security_definition(self, auto_schema):
return {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": (
"JWT Bearer authentication.\n\n"
"Obtain a token pair via `POST /api/accounts/token/` using your username and password, "
"then paste the **access token** here — Swagger adds the `Bearer ` prefix automatically.\n\n"
"Access tokens expire after 30 minutes. Refresh using `POST /api/accounts/token/refresh/`."
),
}
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "apps.accounts.authentication.ApiKeyAuthentication"
name = "ApiKeyAuth"
def get_security_definition(self, auto_schema):
return {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": (
"API key authentication.\n\n"
"Pass your personal API key in the `X-API-Key` request header. "
"Keys can be generated via `POST /api/accounts/api-keys/generate/` "
"and revoked via `POST /api/accounts/api-keys/revoke/`."
),
}
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""
Accepts header `Authorization: ApiKey <key>` or `X-API-Key: <key>`.

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-03-19 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_alter_user_managers'),
]
operations = [
migrations.AddField(
model_name='user',
name='stream_limit',
field=models.IntegerField(default=0),
),
]

View file

@ -30,6 +30,7 @@ class User(AbstractUser):
user_level = models.IntegerField(default=UserLevel.STREAMER)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
stream_limit = models.IntegerField(default=0)
def __str__(self):
return self.username

View file

@ -65,6 +65,7 @@ class UserSerializer(serializers.ModelSerializer):
"channel_profiles",
"custom_properties",
"avatar_config",
"stream_limit",
"is_staff",
"is_superuser",
"last_login",

View file

@ -13,6 +13,7 @@ from rest_framework.permissions import AllowAny
from apps.accounts.permissions import IsAdmin
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from core.utils import safe_upload_path
from . import services
from .tasks import create_backup_task, restore_backup_task
@ -267,10 +268,18 @@ def upload_backup(request):
try:
backup_dir = services.get_backup_dir()
filename = uploaded.name or "uploaded-backup.zip"
# Sanitize filename: strip directory components to prevent path traversal
filename = Path(uploaded.name or "uploaded-backup.zip").name
if not filename:
filename = "uploaded-backup.zip"
try:
safe_upload_path(filename, str(backup_dir))
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
# Ensure unique filename
backup_file = backup_dir / filename
backup_file = (backup_dir / filename).resolve()
counter = 1
while backup_file.exists():
name_parts = filename.rsplit(".", 1)

View file

@ -28,10 +28,36 @@ def _is_postgresql() -> bool:
def _get_pg_env() -> dict:
"""Get environment variables for PostgreSQL commands."""
"""Get environment variables for PostgreSQL commands.
Includes PGPASSWORD for password auth and PGSSL* variables for TLS.
Reads TLS config from DATABASES['default']['OPTIONS'], which is
populated by settings.py when POSTGRES_SSL=true.
"""
db_config = settings.DATABASES["default"]
env = os.environ.copy()
env["PGPASSWORD"] = db_config.get("PASSWORD", "")
password = db_config.get("PASSWORD", "")
if password:
env["PGPASSWORD"] = password
else:
env.pop("PGPASSWORD", None)
# Propagate TLS configuration from Django OPTIONS to libpq env vars.
options = db_config.get("OPTIONS", {})
_ssl_env_map = {
"sslmode": "PGSSLMODE",
"sslrootcert": "PGSSLROOTCERT",
"sslcert": "PGSSLCERT",
"sslkey": "PGSSLKEY",
}
# Always strip inherited PGSSL* vars first, then set only what is explicitly configured
for opt_key, env_key in _ssl_env_map.items():
env.pop(env_key, None)
value = options.get(opt_key)
if value:
env[env_key] = value
return env

View file

@ -15,6 +15,96 @@ from . import services
User = get_user_model()
class PgEnvTlsTestCase(TestCase):
"""Test that _get_pg_env includes TLS and password env vars correctly."""
databases = []
@patch('apps.backups.services.settings')
def test_pg_env_includes_ssl_vars_when_tls_enabled(self, mock_settings):
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "testpass",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {
"sslmode": "verify-full",
"sslrootcert": "/certs/ca.crt",
"sslcert": "/certs/client.crt",
"sslkey": "/certs/client.key",
},
}
}
env = services._get_pg_env()
self.assertEqual(env["PGSSLMODE"], "verify-full")
self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt")
self.assertEqual(env["PGSSLCERT"], "/certs/client.crt")
self.assertEqual(env["PGSSLKEY"], "/certs/client.key")
self.assertEqual(env["PGPASSWORD"], "testpass")
@patch('apps.backups.services.settings')
def test_pg_env_no_ssl_vars_when_tls_disabled(self, mock_settings):
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "testpass",
"HOST": "localhost",
"PORT": 5432,
}
}
env = services._get_pg_env()
self.assertNotIn("PGSSLMODE", env)
self.assertNotIn("PGSSLROOTCERT", env)
self.assertNotIn("PGSSLCERT", env)
self.assertNotIn("PGSSLKEY", env)
self.assertEqual(env["PGPASSWORD"], "testpass")
@patch('apps.backups.services.settings')
def test_pg_env_no_password_when_empty(self, mock_settings):
"""Cert-only auth: PGPASSWORD must not be set when password is empty."""
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {"sslmode": "verify-full"},
}
}
env = services._get_pg_env()
self.assertNotIn("PGPASSWORD", env)
self.assertEqual(env["PGSSLMODE"], "verify-full")
@patch('apps.backups.services.settings')
def test_pg_env_partial_ssl_options(self, mock_settings):
"""Server-only TLS: only sslmode and CA cert, no client cert/key."""
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "pass",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {
"sslmode": "verify-ca",
"sslrootcert": "/certs/ca.crt",
},
}
}
env = services._get_pg_env()
self.assertEqual(env["PGSSLMODE"], "verify-ca")
self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt")
self.assertNotIn("PGSSLCERT", env)
self.assertNotIn("PGSSLKEY", env)
class BackupServicesTestCase(TestCase):
"""Test cases for backup services"""

View file

@ -24,12 +24,13 @@ from apps.accounts.permissions import (
)
from core.models import UserAgent, CoreSettings
from core.utils import RedisClient
from core.utils import RedisClient, safe_upload_path
from .models import (
Stream,
Channel,
ChannelGroup,
ChannelStream,
Logo,
ChannelProfile,
ChannelProfileMembership,
@ -630,6 +631,53 @@ class ChannelViewSet(viewsets.ModelViewSet):
context["include_streams"] = include_streams
return context
@extend_schema(
methods=["PATCH"],
description=(
"Bulk edit multiple channels in a single request. "
"Accepts a JSON array of channel update objects. Each object must include `id` (the channel's primary key). "
"All other fields are optional and support partial updates. "
"The `streams` field accepts a list of stream IDs and will replace the channel's current stream assignments. "
"All updates are validated before any changes are applied and executed in a single database transaction."
),
request=inline_serializer(
name="ChannelBulkEditRequest",
fields={
"id": serializers.IntegerField(help_text="ID of the channel to update (required)."),
"name": serializers.CharField(required=False),
"channel_number": serializers.FloatField(required=False),
"channel_group_id": serializers.IntegerField(required=False, allow_null=True),
"streams": serializers.ListField(
child=serializers.IntegerField(),
required=False,
help_text="List of stream IDs to assign to this channel (replaces existing assignments).",
),
"stream_profile_id": serializers.IntegerField(required=False, allow_null=True),
"logo_id": serializers.IntegerField(required=False, allow_null=True),
"tvg_id": serializers.CharField(required=False, allow_blank=True),
"tvc_guide_stationid": serializers.CharField(required=False, allow_blank=True),
"epg_data_id": serializers.IntegerField(required=False, allow_null=True),
"user_level": serializers.IntegerField(required=False),
"is_adult": serializers.BooleanField(required=False),
},
many=True,
),
responses={
200: inline_serializer(
name="ChannelBulkEditResponse",
fields={
"message": serializers.CharField(),
"channels": ChannelSerializer(many=True),
},
),
400: inline_serializer(
name="ChannelBulkEditErrorResponse",
fields={
"errors": serializers.ListField(child=serializers.DictField()),
},
),
},
)
@action(detail=False, methods=["patch"], url_path="edit/bulk")
def edit_bulk(self, request):
"""
@ -709,19 +757,24 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Apply all updates in a transaction
with transaction.atomic():
streams_updates = []
for channel, validated_data in validated_updates:
# Pop streams before setattr loop — M2M fields can't be set via setattr
streams = validated_data.pop("streams", None)
if streams is not None:
streams_updates.append((channel, streams))
for key, value in validated_data.items():
setattr(channel, key, value)
# Single bulk_update query instead of individual saves
channels_to_update = [channel for channel, _ in validated_updates]
if channels_to_update:
# Collect all unique field names from all updates
# Collect all unique field names from all updates (streams already popped)
all_fields = set()
for _, validated_data in validated_updates:
all_fields.update(validated_data.keys())
# Only call bulk_update if there are fields to update
# Only call bulk_update if there are non-M2M fields to update
if all_fields:
Channel.objects.bulk_update(
channels_to_update,
@ -729,6 +782,32 @@ class ChannelViewSet(viewsets.ModelViewSet):
batch_size=100
)
# Handle streams M2M updates separately
for channel, streams in streams_updates:
normalized_ids = [
stream.id if hasattr(stream, "id") else stream for stream in streams
]
current_links = {
cs.stream_id: cs for cs in channel.channelstream_set.all()
}
existing_ids = set(current_links.keys())
new_ids = set(normalized_ids)
to_remove = existing_ids - new_ids
if to_remove:
channel.channelstream_set.filter(stream_id__in=to_remove).delete()
for order, stream_id in enumerate(normalized_ids):
if stream_id in current_links:
cs = current_links[stream_id]
if cs.order != order:
cs.order = order
cs.save(update_fields=["order"])
else:
ChannelStream.objects.create(
channel=channel, stream_id=stream_id, order=order
)
# Return the updated objects (already in memory)
serialized_channels = ChannelSerializer(
[channel for channel, _ in validated_updates],
@ -1078,6 +1157,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
elif channel_number == 0:
# Special case: 0 means ignore provider numbers and auto-assign
channel_number = None
elif channel_number == -1:
# Special case: -1 means assign the number after the current highest
highest = Channel.objects.order_by('-channel_number').values_list('channel_number', flat=True).first()
channel_number = (int(highest) + 1) if highest is not None else 1
if channel_number is None:
# Still None, auto-assign the next available channel number
@ -1482,7 +1565,11 @@ class ChannelViewSet(viewsets.ModelViewSet):
name="EpgAssociation",
fields={
"channel_id": serializers.IntegerField(),
"epg_data_id": serializers.IntegerField(),
"epg_data_id": serializers.IntegerField(
required=False,
allow_null=True,
help_text="EPG data ID to link. Pass null to remove EPG linkage.",
),
},
),
)
@ -1658,7 +1745,12 @@ class BulkDeleteLogosAPIView(APIView):
"logo_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Logo IDs to delete",
)
),
"delete_files": serializers.BooleanField(
required=False,
default=False,
help_text="Whether to also delete local logo files from disk.",
),
},
),
)
@ -1897,10 +1989,13 @@ class LogoViewSet(viewsets.ModelViewSet):
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
)
file_name = file.name
file_path = os.path.join("/data/logos", file_name)
# Sanitize filename: strip directory components to prevent path traversal
try:
file_path = safe_upload_path(file.name, "/data/logos")
except ValueError:
return Response({"error": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
os.makedirs("/data/logos", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
@ -1920,7 +2015,7 @@ class LogoViewSet(viewsets.ModelViewSet):
# Get custom name from request data, fallback to filename
custom_name = request.data.get('name', '').strip()
logo_name = custom_name if custom_name else file_name
logo_name = custom_name if custom_name else os.path.basename(file_path)
logo, _ = Logo.objects.get_or_create(
url=file_path,
@ -2670,7 +2765,49 @@ class RecordingViewSet(viewsets.ModelViewSet):
recording_id = instance.pk
channel_name = instance.channel.name
# Capture state before the DB row is deleted
# Attempt to close the DVR client connection for this channel if active
try:
channel_uuid = str(instance.channel.uuid)
# Lazy imports to avoid module overhead if proxy isn't used
from core.utils import RedisClient
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.services.channel_service import ChannelService
r = RedisClient.get_client()
if r:
client_set_key = RedisKeys.clients(channel_uuid)
client_ids = r.smembers(client_set_key) or []
stopped = 0
for cid in client_ids:
try:
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
ua = r.hget(meta_key, "user_agent")
# Identify DVR recording client by its user agent
if ua and "Dispatcharr-DVR" in ua:
try:
ChannelService.stop_client(channel_uuid, cid)
stopped += 1
except Exception as inner_e:
logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}")
except Exception as inner:
logger.debug(f"Error while checking client metadata: {inner}")
if stopped:
logger.info(f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation")
# If no clients remain after stopping DVR clients, proactively stop the channel
try:
remaining = r.scard(client_set_key) or 0
except Exception:
remaining = 0
if remaining == 0:
try:
ChannelService.stop_channel(channel_uuid)
logger.info(f"Stopped channel {channel_uuid} (no clients remain)")
except Exception as sc_e:
logger.debug(f"Unable to stop channel {channel_uuid}: {sc_e}")
except Exception as e:
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
# Capture paths before deletion
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
file_path = cp.get("file_path")

View file

@ -1,7 +1,7 @@
# Generated by Django 5.1.6 on 2025-03-19 16:33
import datetime
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.db import migrations, models
@ -22,7 +22,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='stream',
name='last_seen',
field=models.DateTimeField(db_index=True, default=datetime.datetime.now),
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='channel',

View file

@ -1,6 +1,6 @@
# Generated by Django 5.2.9 on 2026-01-09 18:19
import datetime
import django.utils.timezone
from django.db import migrations, models
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='channelgroupm3uaccount',
name='last_seen',
field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'),
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Last time this group was seen in the M3U source during a refresh'),
),
migrations.AddField(
model_name='stream',

View file

@ -7,7 +7,7 @@ from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.constants import ChannelMetadataField
import logging
import uuid
from datetime import datetime
from django.utils import timezone
import hashlib
import json
from apps.epg.models import EPGData
@ -95,7 +95,7 @@ class Stream(models.Model):
help_text="Unique hash for this stream from the M3U account",
db_index=True,
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
last_seen = models.DateTimeField(db_index=True, default=timezone.now)
is_stale = models.BooleanField(
default=False,
db_index=True,
@ -204,7 +204,7 @@ class Stream(models.Model):
return stream_profile
def get_stream(self):
def get_stream(self, requester=None):
"""
Finds an available profile for this stream and reserves a connection slot.
@ -400,6 +400,144 @@ class Channel(models.Model):
return stream_profile
def _pick_channel_to_preempt(
self,
profile_id,
requester_level,
redis_client,
exclude_channel_ids=None,
cooldown_seconds=30,
):
"""
Pick the lowest-impact channel to terminate on the given profile.
Returns: Optional[int] channel_id to preempt
"""
exclude_channel_ids = set(exclude_channel_ids or [])
candidates = []
# 1) Try to get active channel IDs for this profile from an index set if available
ch_set_key = f"ts_proxy:profile:{profile_id}:channels"
try:
ch_ids = { (int(x) if not isinstance(x, int) else x) for x in (redis_client.smembers(ch_set_key) or set()) }
except Exception:
ch_ids = set()
logger.debug("Candidate channels for preemption:")
logger.debug(ch_ids)
# 2) Fallback: scan metadata keys and filter by m3u_profile == profile_id
if not ch_ids:
cursor = 0
pattern = "ts_proxy:channel:*:metadata"
while True:
cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=500)
if keys:
# Prefer HGET m3u_profile if metadata is a hash
pipe = redis_client.pipeline()
for k in keys:
pipe.hget(k, "m3u_profile")
prof_vals = pipe.execute()
for k, prof_val in zip(keys, prof_vals):
try:
pid = int(prof_val) if prof_val is not None else None
except Exception:
pid = None
if pid == profile_id:
parts = k.split(":") # ts_proxy:channel:{id}:metadata
if len(parts) >= 4:
try:
ch_ids.add(int(parts[2]))
except Exception:
pass
if cursor == 0:
break
logger.debug("Candidate channels for preemption:")
logger.debug(ch_ids)
if not ch_ids:
return None
# 3) Score candidates
for ch_id in ch_ids:
if ch_id in exclude_channel_ids:
continue
# Skip if recently preempted
last_preempt_key = f"ts_proxy:channel:{ch_id}:last_preempt"
try:
last_preempt = float(redis_client.get(last_preempt_key) or 0.0)
except Exception:
last_preempt = 0.0
if last_preempt and (time.time() - last_preempt) < cooldown_seconds:
continue
# Clients and their levels
clients_key = f"ts_proxy:channel:{ch_id}:clients"
member_ids = list(redis_client.smembers(clients_key) or [])
viewer_count = len(member_ids)
max_viewer_level = 0
if viewer_count:
pipe = redis_client.pipeline()
for cid in member_ids:
pipe.hget(f"ts_proxy:channel:{ch_id}:clients:{cid}", "user_level")
levels_raw = pipe.execute()
levels = []
for lv in levels_raw:
try:
levels.append(int(lv or 0))
except Exception:
levels.append(0)
max_viewer_level = max(levels or [0])
# Only preempt if requester strictly outranks this channel's viewers
if requester_level <= max_viewer_level:
continue
# Metadata (protected/recording/started_at_ts)
meta_key = f"ts_proxy:channel:{ch_id}:metadata"
try:
protected, recording, started_at_ts = redis_client.hmget(
meta_key, "protected", "recording", "started_at_ts"
)
except Exception:
protected = recording = started_at_ts = None
protected = str(protected or "0") in ("1", "true", "True")
recording = str(recording or "0") in ("1", "true", "True")
if protected or recording:
continue
try:
started_at_ts = float(started_at_ts) if started_at_ts is not None else None
except Exception:
started_at_ts = None
if started_at_ts is None:
started_at_ts = time.time() # treat unknown as newest
# Score: lower is safer to terminate
has_viewers = 1 if viewer_count > 0 else 0
score = (has_viewers, max_viewer_level, viewer_count, started_at_ts)
candidates.append((score, ch_id))
logger.debug("Candidate channels after scoring:")
logger.debug(candidates)
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
victim_id = candidates[0][1]
# Mark preempt timestamp to avoid thrashing
try:
redis_client.set(f"ts_proxy:channel:{victim_id}:last_preempt", str(time.time()), ex=3600)
except Exception:
pass
return victim_id
def _check_and_reserve_profile_slot(self, profile, redis_client):
"""
Atomically check and reserve a connection slot for the given profile.
@ -432,7 +570,7 @@ class Channel(models.Model):
redis_client.decr(profile_connections_key)
return (False, new_count - 1)
def get_stream(self):
def get_stream(self, requester=None):
"""
Finds an available stream for the requested channel and returns the selected stream and profile.
@ -513,6 +651,17 @@ class Channel(models.Model):
None,
) # Return newly assigned stream and matched profile
else:
# At capacity: try to preempt a lower-impact channel on this profile
victim_channel_id = self._pick_channel_to_preempt(
profile_id=profile.id,
requester_level=requester.user_level if requester else 100,
redis_client=redis_client,
exclude_channel_ids=None,
)
if victim_channel_id:
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
# return self.id, profile.id, victim_channel_id
# This profile is at max connections
has_streams_but_maxed_out = True
logger.debug(
@ -739,7 +888,7 @@ class ChannelGroupM3UAccount(models.Model):
help_text='Starting channel number for auto-created channels in this group'
)
last_seen = models.DateTimeField(
default=datetime.now,
default=timezone.now,
db_index=True,
help_text='Last time this group was seen in the M3U source during a refresh'
)

View file

@ -19,6 +19,7 @@ from rapidfuzz import fuzz
from apps.channels.models import Channel
from apps.epg.models import EPGData
from core.models import CoreSettings
from core.utils import acquire_task_lock, release_task_lock
from django.db import OperationalError, close_old_connections
from channels.layers import get_channel_layer
@ -1070,12 +1071,39 @@ def match_single_channel_epg(channel_id):
def evaluate_series_rules_impl(tvg_id: str | None = None):
"""Synchronous implementation of series rule evaluation; returns details for debugging."""
result = {"scheduled": 0, "details": []}
# Serialize all invocations to prevent concurrent evaluations from
# racing to create duplicate recordings (e.g. multiple EPG sources
# refreshing simultaneously each firing evaluate_series_rules.delay()).
# If Redis is unavailable, proceed without lock — the primary and
# secondary dedup guards still prevent duplicates.
lock_acquired = False
try:
lock_acquired = acquire_task_lock('evaluate_series_rules', 'all')
if not lock_acquired:
result["details"].append({"status": "skipped", "reason": "concurrent evaluation in progress"})
return result
except (ConnectionError, OSError, AttributeError):
logger.warning("Could not acquire series rule evaluation lock (Redis unavailable), proceeding without lock")
try:
return _evaluate_series_rules_locked(tvg_id, result)
finally:
if lock_acquired:
try:
release_task_lock('evaluate_series_rules', 'all')
except (ConnectionError, OSError, AttributeError):
logger.warning("Could not release series rule evaluation lock")
def _evaluate_series_rules_locked(tvg_id, result):
"""Inner implementation of series rule evaluation, called under lock."""
from django.utils import timezone
from apps.channels.models import Recording, Channel
from apps.epg.models import EPGData, ProgramData
rules = CoreSettings.get_dvr_series_rules()
result = {"scheduled": 0, "details": []}
if not isinstance(rules, list) or not rules:
return result
@ -1089,14 +1117,23 @@ def evaluate_series_rules_impl(tvg_id: str | None = None):
now = timezone.now()
horizon = now + timedelta(days=7)
# Preload existing recordings' program ids to avoid duplicates
existing_program_ids = set()
for rec in Recording.objects.all().only("custom_properties"):
# Preload existing recordings keyed by stable program attributes that
# survive EPG refreshes (tvg_id + original start/end times stored in
# custom_properties). ProgramData.id changes on every EPG refresh so
# it cannot be used for deduplication. Only load future recordings
# to bound the set size — past recordings cannot collide with newly
# scheduled future programs.
existing_program_keys = set()
for cp in Recording.objects.filter(
end_time__gte=now,
).values_list("custom_properties", flat=True):
try:
pid = rec.custom_properties.get("program", {}).get("id") if rec.custom_properties else None
if pid is not None:
# Normalize to string for consistent comparisons
existing_program_ids.add(str(pid))
prog_data = (cp or {}).get("program", {})
tvg_id_val = prog_data.get("tvg_id")
st = prog_data.get("start_time")
et = prog_data.get("end_time")
if tvg_id_val and st and et:
existing_program_keys.add((str(tvg_id_val), str(st), str(et)))
except Exception:
continue
@ -1191,17 +1228,21 @@ def evaluate_series_rules_impl(tvg_id: str | None = None):
created_here = 0
for prog in unique_programs:
try:
# Skip if already scheduled by program id
if str(prog.id) in existing_program_ids:
# Skip if a recording already exists for this exact airing
# (keyed by tvg_id + original program times, which are stable
# across EPG refreshes unlike ProgramData.id).
prog_key = (str(prog.tvg_id), prog.start_time.isoformat(), prog.end_time.isoformat())
if prog_key in existing_program_keys:
continue
# Extra guard: skip if a recording exists for the same channel + timeslot
# Extra guard: DB query using the same stable attributes
# stored in custom_properties (unadjusted program times,
# not offset-adjusted Recording.start_time/end_time).
try:
from django.db.models import Q
if Recording.objects.filter(
channel=channel,
start_time=prog.start_time,
end_time=prog.end_time,
).filter(Q(custom_properties__program__id=prog.id) | Q(custom_properties__program__title=prog.title)).exists():
custom_properties__program__tvg_id=prog.tvg_id,
custom_properties__program__start_time=prog.start_time.isoformat(),
custom_properties__program__end_time=prog.end_time.isoformat(),
).exists():
continue
except Exception:
continue # already scheduled/recorded
@ -1245,7 +1286,7 @@ def evaluate_series_rules_impl(tvg_id: str | None = None):
}
},
)
existing_program_ids.add(str(prog.id))
existing_program_keys.add(prog_key)
created_here += 1
try:
prefetch_recording_artwork.apply_async(args=[rec.id], countdown=1)
@ -2452,15 +2493,12 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
metadata_key = RedisKeys.channel_metadata(str(channel.uuid))
md = r.hgetall(metadata_key)
if md:
def _gv(bkey):
return md.get(bkey.encode('utf-8'))
def _d(bkey, cast=str):
v = _gv(bkey)
v = md.get(bkey)
try:
if v is None:
return None
s = v.decode('utf-8')
s = v
return cast(s) if cast is not str else s
except Exception:
return None
@ -3338,6 +3376,10 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None
elif starting_channel_number == 0:
# Mode 2: Start from lowest available number
next_number = 1
elif starting_channel_number == -1:
# Mode 4: Start after the current highest channel number
highest = Channel.objects.order_by('-channel_number').values_list('channel_number', flat=True).first()
next_number = (int(highest) + 1) if highest is not None else 1
else:
# Mode 3: Start from specified number
next_number = starting_channel_number

View file

@ -0,0 +1,718 @@
"""Tests for series rule evaluation deduplication.
Unit tests verify the dedup logic in evaluate_series_rules_impl.
Integration tests exercise the full path: EPG refresh series rule
evaluation Recording creation post_save signal chain.
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, Recording
from apps.epg.models import EPGSource, EPGData, ProgramData
from core.models import CoreSettings
def _set_series_rules(rules):
"""Helper to store series rules in CoreSettings."""
CoreSettings.set_dvr_series_rules(rules)
def _set_dvr_offsets(pre_min=0, post_min=0):
"""Helper to store DVR pre/post offsets."""
CoreSettings._update_group("dvr_settings", "DVR Settings", {
"pre_offset_minutes": pre_min,
"post_offset_minutes": post_min,
})
class SeriesRuleDedupBaseTestCase(TestCase):
"""Shared setup for series rule dedup tests."""
def setUp(self):
self.now = timezone.now()
self.epg_source = EPGSource.objects.create(
name="Test EPG", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test.channel.1",
name="Test Channel EPG",
epg_source=self.epg_source,
)
self.channel = Channel.objects.create(
channel_number=1, name="Test Channel", epg_data=self.epg
)
_set_series_rules([{
"tvg_id": "test.channel.1",
"mode": "all",
"title": "Test Show",
}])
_set_dvr_offsets(pre_min=0, post_min=0)
def _create_program(self, hours_from_now=1, title="Test Show",
sub_title="Episode 1", tvg_id="test.channel.1"):
"""Create a ProgramData at the given offset."""
start = self.now + timedelta(hours=hours_from_now)
end = start + timedelta(hours=1)
return ProgramData.objects.create(
epg=self.epg,
tvg_id=tvg_id,
start_time=start,
end_time=end,
title=title,
sub_title=sub_title,
)
def _simulate_epg_refresh(self, programs_data):
"""Delete all ProgramData and recreate with new IDs (simulates EPG refresh)."""
ProgramData.objects.filter(epg=self.epg).delete()
new_programs = []
for data in programs_data:
prog = ProgramData.objects.create(epg=self.epg, **data)
new_programs.append(prog)
return new_programs
def _program_data_for_refresh(self, prog):
"""Build the dict needed by _simulate_epg_refresh from a ProgramData."""
return {
"tvg_id": prog.tvg_id,
"start_time": prog.start_time,
"end_time": prog.end_time,
"title": prog.title,
"sub_title": prog.sub_title,
}
# ---------------------------------------------------------------------------
# Unit tests: dedup logic in evaluate_series_rules_impl
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class ProgramIdStabilityTests(SeriesRuleDedupBaseTestCase):
"""Verify dedup works after EPG refresh changes ProgramData IDs."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_no_duplicate_after_epg_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Same program should not be recorded twice after EPG refresh."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
old_id = prog.id
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
new_programs = self._simulate_epg_refresh(
[self._program_data_for_refresh(prog)]
)
self.assertNotEqual(old_id, new_programs[0].id)
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result2["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_no_duplicate_with_offsets_after_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Dedup works when DVR offsets shift Recording times away from program times."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=5)
prog = self._create_program(hours_from_now=2)
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
rec = Recording.objects.first()
self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5))
self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=5))
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_different_episodes_still_recorded(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Different episodes on the same channel should each get a recording."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2, sub_title="Episode 1")
self._create_program(hours_from_now=4, sub_title="Episode 2")
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 2)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_new_episode_after_refresh_is_recorded(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""A genuinely new episode appearing after EPG refresh should be recorded."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self._simulate_epg_refresh([
self._program_data_for_refresh(prog),
{
"tvg_id": "test.channel.1",
"start_time": prog.end_time,
"end_time": prog.end_time + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 2",
},
])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
self.assertEqual(result2["scheduled"], 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_multiple_epg_refreshes_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Multiple consecutive EPG refreshes should not accumulate duplicates."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
for _ in range(5):
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class ConcurrencyGuardTests(SeriesRuleDedupBaseTestCase):
"""Verify the task lock prevents concurrent evaluation."""
def test_lock_acquired_and_released(self, mock_schedule, mock_artwork):
"""evaluate_series_rules_impl acquires and releases the task lock."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock", return_value=True) as mock_lock, \
patch("apps.channels.tasks.release_task_lock") as mock_release:
evaluate_series_rules_impl()
mock_lock.assert_called_once_with('evaluate_series_rules', 'all')
mock_release.assert_called_once_with('evaluate_series_rules', 'all')
def test_skips_when_lock_held(self, mock_schedule, mock_artwork):
"""Returns early with skip reason when lock is already held."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock", return_value=False):
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 0)
self.assertTrue(
any(d.get("reason") == "concurrent evaluation in progress"
for d in result["details"]),
)
self.assertEqual(Recording.objects.count(), 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_lock_released_on_exception(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Lock is released even if the inner implementation raises."""
from apps.channels.tasks import evaluate_series_rules_impl
with patch("apps.channels.tasks._evaluate_series_rules_locked",
side_effect=RuntimeError("test error")):
with self.assertRaises(RuntimeError):
evaluate_series_rules_impl()
mock_release.assert_called_once_with('evaluate_series_rules', 'all')
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class SecondaryGuardTests(SeriesRuleDedupBaseTestCase):
"""Verify the secondary DB guard uses stable program attributes."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_secondary_guard_catches_duplicate_with_offsets(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Secondary guard works with stale program IDs and DVR offsets."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=10, post_min=10)
prog = self._create_program(hours_from_now=2)
# Pre-existing recording with a stale program ID (from previous EPG refresh)
Recording.objects.create(
channel=self.channel,
start_time=prog.start_time - timedelta(minutes=10),
end_time=prog.end_time + timedelta(minutes=10),
custom_properties={
"program": {
"id": 99999,
"tvg_id": prog.tvg_id,
"title": prog.title,
"start_time": prog.start_time.isoformat(),
"end_time": prog.end_time.isoformat(),
}
},
)
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result["scheduled"], 0)
# ---------------------------------------------------------------------------
# Integration tests: full path from EPG refresh through recording creation
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class IntegrationEPGRefreshTests(SeriesRuleDedupBaseTestCase):
"""End-to-end tests simulating the EPG refresh → evaluate → record flow.
These exercise the full signal chain: evaluate_series_rules_impl creates
a Recording, the post_save signal fires schedule_recording_task, and
subsequent evaluations (after EPG refresh) must not create duplicates.
"""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_single_episode_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Simulate: create rule → evaluate → EPG refresh → re-evaluate.
The full recording lifecycle must result in exactly 1 recording.
"""
from apps.channels.tasks import evaluate_series_rules_impl
# Initial EPG data
prog = self._create_program(hours_from_now=2, sub_title="Pilot")
# First evaluation creates the recording
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
# Verify the recording was created with correct program metadata
rec = Recording.objects.first()
self.assertEqual(rec.custom_properties["program"]["tvg_id"], "test.channel.1")
self.assertEqual(rec.custom_properties["program"]["title"], "Test Show")
self.assertEqual(
rec.custom_properties["program"]["start_time"],
prog.start_time.isoformat()
)
# Verify the post_save signal scheduled a task
mock_schedule.assert_called()
initial_schedule_count = mock_schedule.call_count
# Simulate EPG refresh (programs get new DB IDs)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
# Re-evaluate after refresh (this is what EPG refresh triggers)
result2 = evaluate_series_rules_impl()
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
# No additional task scheduling should have occurred
self.assertEqual(mock_schedule.call_count, initial_schedule_count)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_with_offsets_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Full flow with DVR offsets: recording times differ from program times."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=10)
prog = self._create_program(hours_from_now=3, sub_title="Episode 1")
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
rec = Recording.objects.first()
# Verify offset-adjusted recording times
self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5))
self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=10))
# Verify original (unadjusted) program times in custom_properties
self.assertEqual(
rec.custom_properties["program"]["start_time"],
prog.start_time.isoformat()
)
self.assertEqual(
rec.custom_properties["program"]["end_time"],
prog.end_time.isoformat()
)
# EPG refresh + re-evaluate
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result2["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_multiple_episodes_across_refreshes(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""New episodes appear across multiple EPG refreshes; each recorded once."""
from apps.channels.tasks import evaluate_series_rules_impl
ep1 = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh adds episode 2 alongside episode 1
ep1_data = self._program_data_for_refresh(ep1)
ep2_start = ep1.end_time
ep2_data = {
"tvg_id": "test.channel.1",
"start_time": ep2_start,
"end_time": ep2_start + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 2",
}
self._simulate_epg_refresh([ep1_data, ep2_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
# Another EPG refresh adds episode 3
ep3_start = ep2_start + timedelta(hours=1)
ep3_data = {
"tvg_id": "test.channel.1",
"start_time": ep3_start,
"end_time": ep3_start + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 3",
}
self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 3)
# Final EPG refresh with no new episodes — count must stay at 3
self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 3)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_multiple_series_rules(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Multiple series rules on different channels, each evaluated correctly."""
from apps.channels.tasks import evaluate_series_rules_impl
# Second channel with its own EPG
epg2 = EPGData.objects.create(
tvg_id="test.channel.2",
name="Channel 2 EPG",
epg_source=self.epg_source,
)
channel2 = Channel.objects.create(
channel_number=2, name="Test Channel 2", epg_data=epg2
)
_set_series_rules([
{"tvg_id": "test.channel.1", "mode": "all", "title": "Show A"},
{"tvg_id": "test.channel.2", "mode": "all", "title": "Show B"},
])
# Programs on both channels
start1 = self.now + timedelta(hours=2)
prog1 = ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Show A", sub_title="Episode 1",
)
start2 = self.now + timedelta(hours=3)
prog2 = ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Show B", sub_title="Episode 1",
)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
self.assertEqual(Recording.objects.filter(channel=self.channel).count(), 1)
self.assertEqual(Recording.objects.filter(channel=channel2).count(), 1)
# EPG refresh for both channels
ProgramData.objects.filter(epg=self.epg).delete()
ProgramData.objects.filter(epg=epg2).delete()
ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Show A", sub_title="Episode 1",
)
ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Show B", sub_title="Episode 1",
)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2,
"No duplicates across multiple series rules after EPG refresh")
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_rapid_epg_refreshes_simulate_user_report(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Reproduce the user-reported scenario: series rule + multiple EPG refreshes
causing count to balloon from 6 to 25 and 5 simultaneous recordings.
Simulates 6 episodes with 5 EPG refreshes (each assigning new ProgramData IDs).
"""
from apps.channels.tasks import evaluate_series_rules_impl
# Create 6 episodes (the user had "next of 6")
episodes = []
for i in range(6):
start = self.now + timedelta(hours=2 + i * 2)
episodes.append({
"tvg_id": "test.channel.1",
"start_time": start,
"end_time": start + timedelta(hours=1),
"title": "Test Show",
"sub_title": f"Episode {i + 1}",
})
# Create initial ProgramData
for ep in episodes:
ProgramData.objects.create(epg=self.epg, **ep)
# First evaluation: should create exactly 6 recordings
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 6)
# Simulate 5 EPG refreshes (the user saw count balloon to 25)
for refresh_num in range(5):
self._simulate_epg_refresh(episodes)
result = evaluate_series_rules_impl()
self.assertEqual(
Recording.objects.count(), 6,
f"After EPG refresh #{refresh_num + 1}, expected 6 recordings "
f"but got {Recording.objects.count()}"
)
self.assertEqual(result["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_recording_survives_program_removal_and_readd(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Program temporarily disappears from EPG then reappears — no duplicate."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh removes the program entirely
self._simulate_epg_refresh([])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Existing recording preserved when program disappears from EPG")
# EPG refresh adds the program back (new ID)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"No duplicate when program reappears with new ID")
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_celery_task_wrapper_calls_impl(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""The @shared_task evaluate_series_rules delegates to _impl correctly."""
from apps.channels.tasks import evaluate_series_rules
self._create_program(hours_from_now=2)
result = evaluate_series_rules()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
# Call again (simulating a second EPG refresh trigger)
result2 = evaluate_series_rules()
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_tvg_id_scoped_evaluation(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Scoped evaluation (tvg_id parameter) still prevents duplicates."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
result1 = evaluate_series_rules_impl(tvg_id="test.channel.1")
self.assertEqual(result1["scheduled"], 1)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl(tvg_id="test.channel.1")
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_offset_change_between_refreshes(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Changing DVR offsets between EPG refreshes doesn't create duplicates.
Even though Recording.start_time/end_time change when offsets change,
the dedup key uses the original program times from custom_properties.
"""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=5)
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
rec = Recording.objects.first()
original_start = rec.start_time
original_end = rec.end_time
# Change offsets
_set_dvr_offsets(pre_min=10, post_min=15)
# EPG refresh
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Changing offsets between refreshes should not create duplicates")
self.assertEqual(result["scheduled"], 0)
# ---------------------------------------------------------------------------
# Edge case tests: Redis unavailability, non-series recordings, robustness
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class RedisUnavailabilityTests(SeriesRuleDedupBaseTestCase):
"""Verify evaluation works when Redis is unavailable (lock cannot be acquired)."""
def test_proceeds_when_redis_down(self, mock_schedule, mock_artwork):
"""Evaluation succeeds (with dedup guards) when Redis raises on lock acquire."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
def test_dedup_still_works_without_lock(self, mock_schedule, mock_artwork):
"""Dedup guards prevent duplicates even when the lock is unavailable."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
# First call: Redis down, proceeds without lock
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
# Second call: Redis still down
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Dedup guards prevent duplicates even without lock")
self.assertEqual(result["scheduled"], 0)
def test_lock_not_released_when_not_acquired(self, mock_schedule, mock_artwork):
"""release_task_lock is not called if acquire raised an exception."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")), \
patch("apps.channels.tasks.release_task_lock") as mock_release:
evaluate_series_rules_impl()
mock_release.assert_not_called()
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class NonSeriesRecordingTests(SeriesRuleDedupBaseTestCase):
"""Verify non-series recordings don't interfere with series rule dedup."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_manual_recording_without_program_data_ignored(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings without custom_properties.program are skipped by dedup key builder."""
from apps.channels.tasks import evaluate_series_rules_impl
# Manual recording with no program metadata
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties={},
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_recurring_rule_recording_does_not_interfere(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings from recurring rules (custom_properties.rule) don't block series rules."""
from apps.channels.tasks import evaluate_series_rules_impl
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties={"rule": {"id": 1, "name": "Daily News"}},
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_recording_with_null_custom_properties_ignored(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings with None custom_properties don't crash the dedup key builder."""
from apps.channels.tasks import evaluate_series_rules_impl
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties=None,
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)

View file

@ -0,0 +1,385 @@
"""Tests for ghost client detection and cleanup.
Covers:
- ClientManager.remove_ghost_clients() pipelined EXISTS logic
- channel_status detailed stats path removes ghost clients from Redis SET
- channel_status basic stats path removes ghost clients and corrects count
- _check_orphaned_metadata() validates client SET entries and cleans up
channels where all clients are ghosts
"""
from unittest.mock import MagicMock, patch, PropertyMock
from django.test import TestCase
from apps.proxy.ts_proxy.client_manager import ClientManager
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
from apps.proxy.ts_proxy.redis_keys import RedisKeys
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CHANNEL_ID = "00000000-0000-0000-0000-000000000001"
def _make_proxy_server(redis_client=None):
"""Create a minimal mock ProxyServer with a redis_client."""
server = MagicMock()
server.redis_client = redis_client or MagicMock()
server.stream_managers = {}
server.client_managers = {}
server.worker_id = "test-worker-1"
return server
def _metadata_for_channel(state="active"):
"""Return a plausible channel metadata dict (bytes keys/values)."""
return {
ChannelMetadataField.STATE.encode(): state.encode(),
ChannelMetadataField.URL.encode(): b"http://example.com/stream",
ChannelMetadataField.STREAM_PROFILE.encode(): b"default",
ChannelMetadataField.OWNER.encode(): b"test-worker-1",
ChannelMetadataField.INIT_TIME.encode(): b"1773500000.0",
}
# ---------------------------------------------------------------------------
# Unit tests for ClientManager.remove_ghost_clients()
# ---------------------------------------------------------------------------
class RemoveGhostClientsTests(TestCase):
"""Directly exercises the static method that all callers rely on."""
def test_ghost_removed_and_returned(self):
"""Client ID in SET with no metadata hash should be SREM'd."""
redis = MagicMock()
redis.smembers.return_value = {b"ghost_001"}
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = [False] # EXISTS → False
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(result, [b"ghost_001"])
redis.srem.assert_called_once()
def test_live_client_preserved(self):
"""Client with valid metadata hash should NOT be removed."""
redis = MagicMock()
redis.smembers.return_value = {b"live_001"}
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = [True] # EXISTS → True
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(result, [])
redis.srem.assert_not_called()
def test_mixed_ghost_and_live(self):
"""Only ghost clients should be removed; live ones preserved."""
redis = MagicMock()
redis.smembers.return_value = {b"ghost_001", b"live_001"}
pipe = MagicMock()
redis.pipeline.return_value = pipe
# Order matches list(smembers), which is non-deterministic —
# map both IDs so the test is stable regardless of iteration order.
client_id_list = list(redis.smembers.return_value)
def exists_results():
return [
b"ghost_001" not in cid.decode() == False
for cid in client_id_list
]
# Simpler: mock based on key content
def pipe_exists(key):
pass # just enqueued; results come from execute()
pipe.exists.side_effect = pipe_exists
pipe.execute.return_value = [
"live" in cid.decode() for cid in client_id_list
]
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(len(result), 1)
self.assertTrue(any(b"ghost" in cid for cid in result))
redis.srem.assert_called_once()
def test_empty_set_returns_empty(self):
"""No clients means nothing to clean."""
redis = MagicMock()
redis.smembers.return_value = set()
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(result, [])
redis.pipeline.assert_not_called()
def test_pre_fetched_client_ids_skips_smembers(self):
"""When client_ids is passed, SMEMBERS should not be called."""
redis = MagicMock()
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = [False]
pre_fetched = {b"ghost_001"}
result = ClientManager.remove_ghost_clients(
redis, CHANNEL_ID, client_ids=pre_fetched
)
redis.smembers.assert_not_called()
self.assertEqual(len(result), 1)
# ---------------------------------------------------------------------------
# Detailed stats path: exercises get_detailed_channel_info()
# ---------------------------------------------------------------------------
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
class DetailedStatsGhostClientTests(TestCase):
"""get_detailed_channel_info() should remove ghost clients whose metadata
hash has expired from the Redis client SET."""
def _setup_redis(self, mock_proxy_cls, client_ids, hgetall_side_effect):
"""Wire up a mock ProxyServer with controlled Redis responses."""
redis = MagicMock()
server = _make_proxy_server(redis)
mock_proxy_cls.get_instance.return_value = server
redis.hgetall.side_effect = hgetall_side_effect
redis.smembers.return_value = client_ids
# buffer_index, ttl, exists all need safe defaults
redis.get.return_value = b"10"
redis.ttl.return_value = 300
redis.exists.return_value = True
return redis
def test_ghost_client_removed_from_set(self, mock_proxy_cls):
"""Ghost client should be SREM'd and excluded from result."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
def hgetall_side_effect(key):
if "clients:" in key:
return {} # ghost — metadata expired
return _metadata_for_channel()
redis = self._setup_redis(
mock_proxy_cls, {b"ghost_001"}, hgetall_side_effect
)
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
self.assertEqual(result['client_count'], 0)
self.assertEqual(len(result['clients']), 0)
redis.srem.assert_called_once()
def test_live_client_preserved(self, mock_proxy_cls):
"""Client with valid metadata should appear in results."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
def hgetall_side_effect(key):
if "clients:" in key:
return {
b'user_agent': b'VLC/3.0',
b'worker_id': b'test-worker-1',
b'connected_at': b'1773500000.0',
}
return _metadata_for_channel()
redis = self._setup_redis(
mock_proxy_cls, {b"live_001"}, hgetall_side_effect
)
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
self.assertEqual(result['client_count'], 1)
self.assertEqual(len(result['clients']), 1)
redis.srem.assert_not_called()
def test_mixed_ghost_and_live(self, mock_proxy_cls):
"""Only ghost clients should be removed; live ones preserved."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
def hgetall_side_effect(key):
if "clients:" in key:
if "ghost" in key:
return {}
return {
b'user_agent': b'VLC/3.0',
b'worker_id': b'test-worker-1',
}
return _metadata_for_channel()
redis = self._setup_redis(
mock_proxy_cls, {b"ghost_001", b"live_001"}, hgetall_side_effect
)
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
self.assertEqual(result['client_count'], 1)
self.assertEqual(len(result['clients']), 1)
redis.srem.assert_called_once()
# ---------------------------------------------------------------------------
# Basic stats path: exercises get_basic_channel_info()
# ---------------------------------------------------------------------------
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
class BasicStatsGhostClientTests(TestCase):
"""get_basic_channel_info() should call remove_ghost_clients(), skip
ghosts from display, and correct client_count."""
def _setup_redis(self, mock_proxy_cls, client_ids, ghost_ids):
"""Wire up mock ProxyServer. ghost_ids controls which EXISTS return False."""
redis = MagicMock()
server = _make_proxy_server(redis)
mock_proxy_cls.get_instance.return_value = server
redis.hgetall.return_value = _metadata_for_channel()
redis.get.return_value = b"10" # buffer_index
redis.scard.return_value = len(client_ids)
redis.smembers.return_value = client_ids
redis.hget.return_value = None # individual field lookups
# Pipeline for remove_ghost_clients
pipe = MagicMock()
redis.pipeline.return_value = pipe
client_id_list = list(client_ids)
pipe.execute.return_value = [
cid not in ghost_ids for cid in client_id_list
]
return redis
def test_ghost_removed_and_count_corrected(self, mock_proxy_cls):
"""Ghost client should be cleaned and client_count decremented."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
redis = self._setup_redis(
mock_proxy_cls,
client_ids={b"ghost_001"},
ghost_ids={b"ghost_001"},
)
result = ChannelStatus.get_basic_channel_info(CHANNEL_ID)
self.assertIsNotNone(result)
self.assertEqual(result['client_count'], 0)
redis.srem.assert_called_once()
def test_live_client_count_preserved(self, mock_proxy_cls):
"""Live clients should be counted correctly."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
redis = self._setup_redis(
mock_proxy_cls,
client_ids={b"live_001"},
ghost_ids=set(),
)
result = ChannelStatus.get_basic_channel_info(CHANNEL_ID)
self.assertIsNotNone(result)
self.assertEqual(result['client_count'], 1)
redis.srem.assert_not_called()
# ---------------------------------------------------------------------------
# Orphaned channel cleanup: exercises _check_orphaned_metadata()
# ---------------------------------------------------------------------------
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
class OrphanedChannelGhostValidationTests(TestCase):
"""_check_orphaned_metadata() should validate client SET entries when
owner is dead and client_count > 0. If all clients are ghosts, it
should clean up the channel."""
def _make_server_for_orphan_check(self, mock_proxy_cls, channel_id,
client_ids, ghost_ids, owner="dead-worker"):
"""Build a mock ProxyServer whose Redis state simulates an orphaned channel."""
redis = MagicMock()
server = _make_proxy_server(redis)
mock_proxy_cls.get_instance.return_value = server
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = _metadata_for_channel()
metadata[ChannelMetadataField.OWNER.encode()] = owner.encode()
# scan returns the one channel metadata key
redis.scan.return_value = (0, [metadata_key.encode()])
redis.hgetall.return_value = metadata
redis.scard.return_value = len(client_ids)
redis.smembers.return_value = client_ids
# Owner heartbeat is dead
redis.exists.side_effect = lambda key: (
False if "heartbeat" in key else True
)
# Pipeline for remove_ghost_clients
pipe = MagicMock()
redis.pipeline.return_value = pipe
client_id_list = list(client_ids)
pipe.execute.return_value = [
cid not in ghost_ids for cid in client_id_list
]
return server, redis
def test_all_ghosts_triggers_cleanup(self, mock_proxy_cls):
"""When all clients are ghosts, channel should be cleaned up."""
from apps.proxy.ts_proxy.server import ProxyServer
channel_id = "00000000-0000-0000-0000-000000000005"
server, redis = self._make_server_for_orphan_check(
mock_proxy_cls, channel_id,
client_ids={b"ghost_001", b"ghost_002"},
ghost_ids={b"ghost_001", b"ghost_002"},
)
# Call the real method on a real-ish ProxyServer
# The method lives on the server instance, so invoke it directly.
# We need to call _check_orphaned_metadata on the actual server mock,
# but it's a MagicMock. Instead, test via remove_ghost_clients directly
# and verify the cleanup decision logic.
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
real_count = max(0, len({b"ghost_001", b"ghost_002"}) - len(stale_ids))
self.assertEqual(len(stale_ids), 2)
self.assertEqual(real_count, 0)
redis.srem.assert_called_once()
def test_mixed_preserves_live_clients(self, mock_proxy_cls):
"""When some clients are live, real_count should be > 0."""
channel_id = "00000000-0000-0000-0000-000000000006"
server, redis = self._make_server_for_orphan_check(
mock_proxy_cls, channel_id,
client_ids={b"ghost_001", b"live_001"},
ghost_ids={b"ghost_001"},
)
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
real_count = max(0, 2 - len(stale_ids))
self.assertEqual(len(stale_ids), 1)
self.assertEqual(real_count, 1)
def test_no_ghosts_no_cleanup(self, mock_proxy_cls):
"""When all clients are live, no SREM should be called."""
channel_id = "00000000-0000-0000-0000-000000000007"
server, redis = self._make_server_for_orphan_check(
mock_proxy_cls, channel_id,
client_ids={b"live_001"},
ghost_ids=set(),
)
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
self.assertEqual(len(stale_ids), 0)
redis.srem.assert_not_called()

View file

@ -0,0 +1,231 @@
"""Tests for stuck INITIALIZING state fix.
Covers:
- stream_manager.run() finally block: ownership check + state guard fallback
- ChannelState.PRE_ACTIVE contains the correct states
- INITIALIZING is included in the cleanup task grace period check
"""
import time
import threading
from unittest.mock import MagicMock, patch
from django.test import TestCase
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.stream_manager import StreamManager
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CHANNEL_ID = "00000000-0000-0000-0000-000000000001"
def _make_stream_manager(tried_stream_ids=None, max_retries=3):
"""Build a StreamManager via __new__ (bypasses __init__) with the
minimum attributes required by the run() finally block."""
sm = StreamManager.__new__(StreamManager)
sm.channel_id = CHANNEL_ID
sm.worker_id = "worker-1"
sm.max_retries = max_retries
sm.tried_stream_ids = tried_stream_ids if tried_stream_ids is not None else set()
sm.running = False # while-loop exits immediately
sm.connected = False
sm.transcode_process_active = False
sm._buffer_check_timers = []
sm.url = "http://example.com/stream"
sm.url_switching = False
sm.url_switch_start_time = 0
sm.url_switch_timeout = 30
sm.stop_requested = False
sm.stopping = False
sm.socket = None
sm.transcode_process = None
sm.current_response = None
sm.current_session = None
sm.current_stream_id = None
buffer = MagicMock()
buffer.redis_client = MagicMock()
buffer.channel_id = CHANNEL_ID
sm.buffer = buffer
return sm
def _run_finally_block(sm, owner_value, current_state):
"""Invoke StreamManager.run() so its finally block executes against real code.
Patches threading.Thread and ConfigHelper so the try-block is inert
(self.running=False makes the while-loop exit immediately).
Returns True if the finally block wrote ERROR to Redis.
"""
redis = sm.buffer.redis_client
# Mock the owner key GET — the finally block calls redis.get(owner_key)
def get_side_effect(key):
if "owner" in key:
return owner_value
return None
redis.get.side_effect = get_side_effect
# Mock hget for state field lookup in the PRE_ACTIVE guard
if current_state is not None:
redis.hget.return_value = current_state.encode('utf-8')
else:
redis.hget.return_value = None
# Reset hset so we can detect whether ERROR was written
redis.hset.reset_mock()
redis.setex.reset_mock()
with patch.object(threading, 'Thread', return_value=MagicMock()):
with patch('apps.proxy.ts_proxy.stream_manager.ConfigHelper') as mock_cfg:
mock_cfg.max_stream_switches.return_value = 0
mock_cfg.max_retries.return_value = sm.max_retries
sm.run()
# Check if hset was called with ERROR state
if redis.hset.called:
mapping = redis.hset.call_args[1].get('mapping', {})
return mapping.get(ChannelMetadataField.STATE) == ChannelState.ERROR
return False
# ---------------------------------------------------------------------------
# stream_manager.run() finally block: ownership + state guard behavior
# ---------------------------------------------------------------------------
class StreamManagerFinallyBlockTests(TestCase):
"""The run() finally block writes ERROR if the worker is still the owner
(normal case) OR if ownership expired and the channel is still in a
pre-active state (no new owner has taken over)."""
# --- Owner still valid: always write ERROR ---
def test_owner_writes_error_regardless_of_state(self):
"""When we're still the owner, always write ERROR."""
sm = _make_stream_manager()
owner = sm.worker_id.encode('utf-8')
self.assertTrue(_run_finally_block(sm, owner, ChannelState.ACTIVE))
def test_owner_writes_error_on_initializing(self):
"""Owner + INITIALIZING = write ERROR."""
sm = _make_stream_manager()
owner = sm.worker_id.encode('utf-8')
self.assertTrue(_run_finally_block(sm, owner, ChannelState.INITIALIZING))
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
self.assertEqual(mapping[ChannelMetadataField.STATE], ChannelState.ERROR)
# --- Ownership expired, no new owner: use state guard ---
def test_no_owner_initializing_writes_error(self):
"""Ownership expired + INITIALIZING = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.INITIALIZING))
def test_no_owner_connecting_writes_error(self):
"""Ownership expired + CONNECTING = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.CONNECTING))
def test_no_owner_buffering_writes_error(self):
"""Ownership expired + BUFFERING = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.BUFFERING))
def test_no_owner_waiting_for_clients_writes_error(self):
"""Ownership expired + WAITING_FOR_CLIENTS = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.WAITING_FOR_CLIENTS))
def test_no_owner_active_does_not_write(self):
"""Ownership expired + ACTIVE = do NOT write ERROR."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, None, ChannelState.ACTIVE))
def test_no_owner_error_does_not_write(self):
"""Ownership expired + already ERROR = do NOT write again."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, None, ChannelState.ERROR))
def test_no_owner_no_state_does_not_write(self):
"""Ownership expired + no state metadata = do NOT write."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, None, None))
# --- New owner took over: never clobber ---
def test_new_owner_initializing_does_not_write(self):
"""Another worker owns the channel — do NOT clobber."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.INITIALIZING))
def test_new_owner_active_does_not_write(self):
"""Another worker owns the channel and is ACTIVE — do NOT write."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.ACTIVE))
# --- Stopping key and error messages ---
def test_stopping_key_set_on_error_update(self):
"""When ERROR is written, stopping key must also be set."""
sm = _make_stream_manager()
_run_finally_block(sm, None, ChannelState.INITIALIZING)
sm.buffer.redis_client.setex.assert_called_once()
args = sm.buffer.redis_client.setex.call_args[0]
self.assertIn("stopping", args[0])
self.assertEqual(args[1], 60)
def test_error_message_includes_stream_count(self):
"""When multiple streams were tried, error message reflects that."""
sm = _make_stream_manager(tried_stream_ids={1, 2, 3})
_run_finally_block(sm, None, ChannelState.INITIALIZING)
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE]
self.assertIn("3 stream options failed", error_msg)
def test_error_message_with_no_streams_tried(self):
"""When no alternate streams were tried, shows retry count."""
sm = _make_stream_manager(tried_stream_ids=set(), max_retries=5)
_run_finally_block(sm, None, ChannelState.INITIALIZING)
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE]
self.assertIn("5", error_msg)
# ---------------------------------------------------------------------------
# ChannelState.PRE_ACTIVE: verify contents and immutability
# ---------------------------------------------------------------------------
class PreActiveStateTests(TestCase):
"""Verify PRE_ACTIVE contains the correct states and is immutable."""
def test_initializing_in_pre_active(self):
self.assertIn(ChannelState.INITIALIZING, ChannelState.PRE_ACTIVE)
def test_connecting_in_pre_active(self):
self.assertIn(ChannelState.CONNECTING, ChannelState.PRE_ACTIVE)
def test_buffering_in_pre_active(self):
self.assertIn(ChannelState.BUFFERING, ChannelState.PRE_ACTIVE)
def test_waiting_for_clients_in_pre_active(self):
self.assertIn(ChannelState.WAITING_FOR_CLIENTS, ChannelState.PRE_ACTIVE)
def test_active_not_in_pre_active(self):
self.assertNotIn(ChannelState.ACTIVE, ChannelState.PRE_ACTIVE)
def test_error_not_in_pre_active(self):
self.assertNotIn(ChannelState.ERROR, ChannelState.PRE_ACTIVE)
def test_pre_active_is_frozenset(self):
self.assertIsInstance(ChannelState.PRE_ACTIVE, frozenset)

View file

@ -85,6 +85,13 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
gen.stream_manager = None # non-owner worker
gen.consecutive_empty = consecutive_empty
# Attributes added by health-check throttling (set in __init__)
gen._last_health_check_time = 0.0
gen._last_health_check_result = False
gen._health_check_interval = 2.0
gen.proxy_server = None
return gen
def _mock_proxy_server(self, last_data_value):

View file

@ -0,0 +1,195 @@
"""
Unit tests for the keepalive duration cap in StreamGenerator._stream_data_generator.
Verifies that a client held in keepalive mode is disconnected after
MAX_KEEPALIVE_DURATION seconds, and that the timer resets when real data resumes.
"""
import time
from unittest.mock import MagicMock, patch, call
from django.test import TestCase
def _make_generator(consecutive_empty=10, local_index=10, buffer_index=10):
"""Minimal StreamGenerator stub for testing _stream_data_generator logic."""
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "00000000-0000-0000-0000-000000000099"
gen.client_id = "test-client-duration"
gen.consecutive_empty = consecutive_empty
gen.empty_reads = 0
gen.local_index = local_index
gen.bytes_sent = 0
gen.chunks_sent = 0
gen.last_yield_time = time.time()
gen.stream_start_time = time.time()
gen.last_stats_time = time.time()
gen.last_stats_bytes = 0
gen.current_rate = 0.0
gen.last_ttl_refresh = time.time()
gen.ttl_refresh_interval = 3
gen.is_owner_worker = False
gen.stream_manager = None
gen._last_health_check_time = 0.0
gen._last_health_check_result = False
gen._health_check_interval = 2.0
gen.proxy_server = None
buffer = MagicMock()
buffer.index = buffer_index
buffer.get_optimized_client_data.return_value = ([], local_index)
buffer.find_oldest_available_chunk.return_value = None
gen.buffer = buffer
return gen
class KeepaliveDurationCapTests(TestCase):
"""MAX_KEEPALIVE_DURATION cap disconnects clients stuck in keepalive mode."""
def _run_generator_to_break(self, gen, max_iterations=20):
"""Drive _stream_data_generator until it breaks or hits iteration limit."""
iterations = 0
for _ in gen._stream_data_generator():
iterations += 1
if iterations >= max_iterations:
break
return iterations
def test_cap_fires_after_max_duration_exceeded(self):
"""Generator exits when keepalive has run longer than MAX_KEEPALIVE_DURATION."""
gen = _make_generator()
with patch.object(gen, '_check_resources', return_value=True), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent') as mock_gevent, \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
MockConfig.MAX_KEEPALIVE_DURATION = 30
# First call: keepalive_start_time not yet set (returns current)
# Second call: inside the cap check — simulate time elapsed > 30s
mock_time.time.side_effect = [
1000.0, # keepalive_start_time assignment
1031.0, # cap check: 31s elapsed > 30s limit
]
packets = list(gen._stream_data_generator())
# No packets should be yielded — cap fires before yield
self.assertEqual(len(packets), 0)
def test_cap_does_not_fire_before_max_duration(self):
"""Generator yields keepalive packets while within MAX_KEEPALIVE_DURATION."""
gen = _make_generator()
call_count = 0
def time_side_effect():
nonlocal call_count
call_count += 1
# keepalive_start_time set at t=1000; cap checks always see <30s elapsed
if call_count == 1:
return 1000.0 # keepalive_start_time
return 1010.0 # always 10s elapsed — under the 30s cap
with patch.object(gen, '_check_resources', side_effect=[True, True, False]), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
MockConfig.MAX_KEEPALIVE_DURATION = 30
mock_time.time.side_effect = time_side_effect
packets = list(gen._stream_data_generator())
# Two iterations with _check_resources=True should yield two keepalive packets
self.assertGreater(len(packets), 0)
def test_timer_resets_when_real_data_resumes(self):
"""keepalive_start_time is cleared to None when real chunks are received."""
gen = _make_generator()
chunk = b'\x47' * 188
real_chunks = ([chunk], gen.local_index + 1)
no_chunks = ([], gen.local_index)
# Sequence: no data (keepalive), then real data, then stop
gen.buffer.get_optimized_client_data.side_effect = [
no_chunks, # iteration 1: keepalive
real_chunks, # iteration 2: real data — should reset timer
no_chunks, # iteration 3: keepalive again — timer restarts fresh
]
captured_start_times = []
original_gen = gen
with patch.object(gen, '_check_resources', side_effect=[True, True, True, False]), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch.object(gen, '_process_chunks', return_value=iter([chunk])), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
MockConfig.MAX_KEEPALIVE_DURATION = 300
mock_time.time.return_value = 1000.0
list(gen._stream_data_generator())
# Test passes if no exception and generator completes normally —
# if the timer were NOT reset, the second keepalive block would
# carry over the old start time rather than starting fresh.
def test_cap_uses_config_value(self):
"""Cap threshold reads MAX_KEEPALIVE_DURATION from Config, not a hardcoded value."""
gen = _make_generator()
with patch.object(gen, '_check_resources', return_value=True), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
# Set a custom cap of 60s
MockConfig.MAX_KEEPALIVE_DURATION = 60
mock_time.time.side_effect = [
1000.0, # keepalive_start_time
1050.0, # cap check: 50s elapsed — under 60s, should NOT fire
1000.0, # last_yield_time update
1070.0, # cap check on next iteration: 70s elapsed — fires
]
packets = list(gen._stream_data_generator())
# First iteration: 50s < 60s cap — one keepalive yielded
# Second iteration: 70s > 60s cap — generator exits
self.assertEqual(len(packets), 1)

View file

@ -1,9 +1,10 @@
from rest_framework import viewsets, status
from rest_framework import viewsets, status, serializers
from rest_framework.pagination import PageNumberPagination
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.response import Response
from rest_framework.decorators import action
from django.utils import timezone
from drf_spectacular.utils import extend_schema, inline_serializer
from .models import Integration, EventSubscription, DeliveryLog
from .serializers import (
IntegrationSerializer,
@ -37,6 +38,33 @@ class IntegrationViewSet(viewsets.ModelViewSet):
serializer = EventSubscriptionSerializer(qs, many=True)
return Response(serializer.data)
@extend_schema(
methods=["PUT"],
description=(
"Replace the integration's event subscriptions with the provided list. "
"Accepts a JSON array of subscription objects. "
"Existing subscriptions not in the list will be deleted. "
"The 'payload_template' field is only relevant for webhook integrations."
),
request=inline_serializer(
name="SetSubscriptionsRequest",
fields={
"event": serializers.CharField(help_text="Event name (e.g. 'channel_start')."),
"enabled": serializers.BooleanField(required=False, default=True),
"payload_template": serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="Custom payload template (webhook integrations only)."),
},
many=True,
),
responses={200: inline_serializer(
name="SetSubscriptionsResponse",
fields={
"event": serializers.CharField(),
"enabled": serializers.BooleanField(),
"payload_template": serializers.CharField(allow_null=True),
},
many=True,
)},
)
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
def set_subscriptions(self, request, pk=None):
"""

View file

@ -427,7 +427,13 @@ class EPGImportAPIView(APIView):
return [Authenticated()]
@extend_schema(
description="Triggers an EPG data import",
description="Triggers an EPG data refresh for the given source.",
request=inline_serializer(
name="EPGImportRequest",
fields={
"id": serializers.IntegerField(help_text="ID of the EPG source to refresh."),
},
),
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
@ -449,7 +455,7 @@ class EPGImportAPIView(APIView):
refresh_epg_data.delay(epg_id) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response(
{"success": True, "message": "EPG data import initiated."},
{"success": True, "message": "EPG data refresh initiated."},
status=status.HTTP_202_ACCEPTED,
)

View file

@ -2,6 +2,7 @@
import logging
import gzip
import html.entities
import os
import uuid
import requests
@ -15,7 +16,7 @@ import zipfile
from celery import shared_task
from django.conf import settings
from django.db import transaction
from django.db import connection, transaction
from django.utils import timezone
from apps.channels.models import Channel
from core.models import UserAgent, CoreSettings
@ -28,6 +29,103 @@ from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, se
logger = logging.getLogger(__name__)
# DOCTYPE internal subset for XMLTV files. Declares all 252 HTML 4 named
# entities so lxml/libxml2 can resolve references like &eacute; correctly
# instead of silently dropping them in recovery mode.
# The 5 XML-predefined entities (amp, lt, gt, quot, apos) are always
# recognised by the XML spec and must not be redeclared.
_XML_ENTITIES = frozenset({'amp', 'lt', 'gt', 'quot', 'apos'})
def _build_html_entity_doctype() -> bytes:
"""Build a DOCTYPE internal subset declaring all HTML 4 named entities."""
lines = [b'<!DOCTYPE tv [\n']
for name, codepoint in sorted(html.entities.name2codepoint.items()):
if name not in _XML_ENTITIES:
# Numeric character references are always valid XML regardless of codepoint.
lines.append(f'<!ENTITY {name} "&#x{codepoint:X};">\n'.encode('ascii'))
lines.append(b']>\n')
return b''.join(lines)
_HTML_ENTITY_DOCTYPE = _build_html_entity_doctype()
class _PrependStream:
"""Wraps an open binary file and prepends a bytes prefix to its content.
Used by _open_xmltv_file to inject a DOCTYPE entity block before the
file content reaches lxml's iterparse, with zero disk I/O.
"""
__slots__ = ('_prefix', '_prefix_pos', '_file')
def __init__(self, prefix: bytes, file_obj):
self._prefix = prefix
self._prefix_pos = 0
self._file = file_obj
def read(self, size=-1):
prefix_len = len(self._prefix)
if self._prefix_pos >= prefix_len:
return self._file.read(size)
remaining = prefix_len - self._prefix_pos
if size < 0:
chunk = self._prefix[self._prefix_pos:] + self._file.read()
self._prefix_pos = prefix_len
return chunk
if size <= remaining:
chunk = self._prefix[self._prefix_pos:self._prefix_pos + size]
self._prefix_pos += size
return chunk
chunk = self._prefix[self._prefix_pos:]
self._prefix_pos = prefix_len
return chunk + self._file.read(size - remaining)
def close(self):
self._file.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _open_xmltv_file(file_path: str):
"""Open an XMLTV file for lxml iterparse, injecting an HTML entity DOCTYPE.
Prepends a <!DOCTYPE tv [...]> block that declares all 252 HTML 4 named
entities so lxml/libxml2 resolves references like &eacute; correctly
instead of silently dropping them in recovery mode. This involves zero
disk I/O the DOCTYPE is streamed in-memory before the file content.
If the file already contains a <!DOCTYPE> declaration the file is returned
unchanged; a second DOCTYPE would be invalid XML.
The caller is responsible for closing the returned object.
"""
f = open(file_path, 'rb')
start = f.read(512)
# Do not inject if the file already declares a DOCTYPE.
if b'<!DOCTYPE' in start or b'<!doctype' in start.lower():
f.seek(0)
return f
# Insert the DOCTYPE after the XML declaration if one is present.
xml_pos = start.find(b'<?xml')
if xml_pos >= 0:
decl_end = start.find(b'?>', xml_pos)
if decl_end >= 0:
xml_decl = start[:decl_end + 2]
f.seek(decl_end + 2)
return _PrependStream(xml_decl + b'\n' + _HTML_ENTITY_DOCTYPE, f)
# No XML declaration — insert DOCTYPE at the very start of the file.
f.seek(0)
return _PrependStream(_HTML_ENTITY_DOCTYPE, f)
def validate_icon_url_fast(icon_url, max_length=None):
"""
@ -146,7 +244,7 @@ def refresh_all_epg_data():
return "EPG data refreshed."
@shared_task(time_limit=1800, soft_time_limit=1700)
@shared_task(time_limit=14400)
def refresh_epg_data(source_id):
if not acquire_task_lock('refresh_epg_data', source_id):
logger.debug(f"EPG refresh for {source_id} already running")
@ -397,42 +495,41 @@ def fetch_xmltv(source):
# Download to temporary file
with open(temp_download_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=16384): # Increased chunk size for better performance
if chunk:
f.write(chunk)
for chunk in response.iter_content(chunk_size=16384):
f.write(chunk)
downloaded += len(chunk)
elapsed_time = time.time() - start_time
downloaded += len(chunk)
elapsed_time = time.time() - start_time
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
# Calculate progress percentage
if total_size and total_size > 0:
progress = min(100, int((downloaded / total_size) * 100))
else:
# If no content length header, estimate progress
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
# Calculate progress percentage
if total_size and total_size > 0:
progress = min(100, int((downloaded / total_size) * 100))
else:
# If no content length header, estimate progress
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
# Only send updates at specified intervals to avoid flooding
current_time = time.time()
if current_time - last_update_time >= update_interval and progress > 0:
last_update_time = current_time
send_epg_update(
source.id,
"downloading",
progress,
speed=round(speed, 2),
elapsed_time=round(elapsed_time, 1),
time_remaining=round(time_remaining, 1),
downloaded=f"{downloaded / (1024 * 1024):.2f} MB"
)
# Only send updates at specified intervals to avoid flooding
current_time = time.time()
if current_time - last_update_time >= update_interval and progress > 0:
last_update_time = current_time
send_epg_update(
source.id,
"downloading",
progress,
speed=round(speed, 2),
elapsed_time=round(elapsed_time, 1),
time_remaining=round(time_remaining, 1),
downloaded=f"{downloaded / (1024 * 1024):.2f} MB"
)
# Explicitly delete the chunk to free memory immediately
del chunk
# Explicitly delete the chunk to free memory immediately
del chunk
# Send completion notification
send_epg_update(source.id, "downloading", 100)
@ -526,6 +623,7 @@ def fetch_xmltv(source):
source.save(update_fields=['status'])
logger.info(f"Cached EPG file saved to {source.file_path}")
return True
except requests.exceptions.HTTPError as e:
@ -840,7 +938,8 @@ def parse_channels_only(source):
# Replace full dictionary load with more efficient lookup set
existing_tvg_ids = set()
existing_epgs = {} # Initialize the dictionary that will lazily load objects
existing_epgs = {}
scanned_tvg_ids = set() # Track tvg_ids seen in the current scan for stale cleanup
last_id = 0
chunk_size = 5000
@ -888,7 +987,7 @@ def parse_channels_only(source):
# Open the file - no need to check file type since it's always XML now
logger.debug(f"Opening file for channel parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
if process:
logger.debug(f"[parse_channels_only] Memory after opening file: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -908,6 +1007,7 @@ def parse_channels_only(source):
channel_count += 1
tvg_id = elem.get('id', '').strip()
if tvg_id:
scanned_tvg_ids.add(tvg_id)
display_name = None
icon_url = None
for child in elem:
@ -1055,6 +1155,16 @@ def parse_channels_only(source):
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"])
logger.debug(f"[parse_channels_only] Updated final batch of {len(epgs_to_update)} EPG entries")
# Clean up stale EPGData: entries that existed before the scan but weren't seen, and aren't mapped to any channel.
# Use existing_tvg_ids - scanned_tvg_ids to avoid a full-table scan with a large EXCLUDE list.
potentially_stale = existing_tvg_ids - scanned_tvg_ids
if potentially_stale:
stale_qs = EPGData.objects.filter(epg_source=source, tvg_id__in=potentially_stale, channels__isnull=True)
deleted_count, _ = stale_qs.delete()
if deleted_count:
logger.info(f"[parse_channels_only] Cleaned up {deleted_count} stale EPG entries not in current scan and unmapped to any channel")
if process:
logger.debug(f"[parse_channels_only] Memory after final batch creation: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -1121,6 +1231,9 @@ def parse_channels_only(source):
existing_epgs = None
epgs_to_create = None
epgs_to_update = None
if 'scanned_tvg_ids' in locals() and scanned_tvg_ids is not None:
scanned_tvg_ids.clear()
scanned_tvg_ids = None
cleanup_memory(log_usage=should_log_memory, force_collection=True)
except Exception as e:
logger.warning(f"Cleanup error: {e}")
@ -1271,7 +1384,7 @@ def parse_programs_for_tvg_id(epg_id):
try:
# Open the file directly - no need to check compression
logger.debug(f"Opening file for parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
@ -1544,7 +1657,7 @@ def parse_programs_for_source(epg_source, tvg_id=None):
try:
logger.debug(f"Opening file for single-pass parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
@ -1657,6 +1770,10 @@ def parse_programs_for_source(epg_source, tvg_id=None):
batch_size = 1000
try:
with transaction.atomic():
# Kill any individual statement that hangs longer than 10 minutes.
# SET LOCAL automatically resets when this transaction ends (commit or rollback).
with connection.cursor() as cursor:
cursor.execute("SET LOCAL statement_timeout = '10min'")
# Delete existing programs for mapped EPGs
deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0]
logger.debug(f"Deleted {deleted_count} existing programs")

View file

@ -0,0 +1,195 @@
import os
import tempfile
from django.test import TestCase
from apps.epg.tasks import (
_NAMED_ENTITY_RE,
_detect_xml_encoding,
_replace_html_entity,
_resolve_html_entities,
)
class ReplaceHtmlEntityTests(TestCase):
"""Tests for the regex callback that resolves individual HTML entities."""
def _sub(self, text):
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
def test_french_accented(self):
self.assertEqual(self._sub("Cha&icirc;ne T&eacute;l&eacute;"), "Chaîne Télé")
def test_german_umlauts(self):
self.assertEqual(self._sub("M&uuml;nchen &Uuml;bersicht &szlig;"), "München Übersicht ß")
def test_spanish(self):
self.assertEqual(self._sub("Espa&ntilde;a &iquest;Qu&eacute;?"), "España ¿Qué?")
def test_portuguese(self):
self.assertEqual(self._sub("Comunica&ccedil;&atilde;o"), "Comunicação")
def test_scandinavian(self):
self.assertEqual(self._sub("Norsk &oslash; &aring; &aelig;"), "Norsk ø å æ")
def test_greek_letters(self):
self.assertEqual(self._sub("&alpha;&beta;&gamma;"), "αβγ")
def test_currency_and_symbols(self):
self.assertEqual(self._sub("&copy; &euro; &pound; &yen;"), "© € £ ¥")
def test_preserves_xml_amp(self):
self.assertEqual(self._sub("A &amp; B"), "A &amp; B")
def test_preserves_xml_lt_gt(self):
self.assertEqual(self._sub("&lt;tag&gt;"), "&lt;tag&gt;")
def test_preserves_xml_quot_apos(self):
self.assertEqual(self._sub("&quot;hello&apos;"), "&quot;hello&apos;")
def test_preserves_uppercase_xml_entities(self):
"""&AMP;, &LT;, &GT;, &QUOT; resolve to XML-special chars; must not be replaced."""
self.assertEqual(self._sub("&AMP;"), "&AMP;")
self.assertEqual(self._sub("&LT;"), "&LT;")
self.assertEqual(self._sub("&GT;"), "&GT;")
self.assertEqual(self._sub("&QUOT;"), "&QUOT;")
def test_partial_entity_match_preserved(self):
"""html.unescape can partially match &amp inside &ampersand; — must not corrupt."""
self.assertEqual(self._sub("&ampersand;"), "&ampersand;")
def test_mixed_html_and_xml_entities(self):
self.assertEqual(
self._sub("R&eacute;sum&eacute; &amp; Co &lt;test&gt;"),
"Résumé &amp; Co &lt;test&gt;",
)
def test_plain_ascii_unchanged(self):
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
def test_direct_utf8_unchanged(self):
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
def test_unknown_entity_preserved(self):
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
class ResolveHtmlEntitiesFileTests(TestCase):
"""Tests for the file-level preprocessing function."""
def _make_file(self, content):
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
return path
def test_resolves_entities_in_file(self):
path = self._make_file(
'<?xml version="1.0"?>\n<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>'
)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Télé", content)
self.assertNotIn("&eacute;", content)
def test_preserves_xml_entities_in_file(self):
path = self._make_file("<tv><desc>A &amp; B &lt;C&gt;</desc></tv>")
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("&amp;", content)
self.assertIn("&lt;", content)
self.assertIn("&gt;", content)
def test_no_temp_file_left_on_success(self):
path = self._make_file("<tv>test</tv>")
_resolve_html_entities(path)
self.assertFalse(os.path.exists(path + ".entity_tmp"))
def test_plain_file_unchanged(self):
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_utf8_content_preserved(self):
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("日本語テレビ", content)
def test_iso_8859_1_encoding(self):
"""Files declaring ISO-8859-1 should be read in that encoding."""
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("Cha\u00eene", content)
self.assertNotIn("&icirc;", content)
def test_detect_encoding_utf8_default(self):
"""Headers without an encoding declaration default to UTF-8."""
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
def test_detect_encoding_iso_8859_1(self):
"""Encoding is read from the XML declaration."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
"ISO-8859-1",
)
def test_detect_encoding_single_quotes(self):
"""Encoding detection works with single-quoted attributes."""
self.assertEqual(
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
"windows-1252",
)
def test_detect_encoding_unknown_falls_back(self):
"""Unrecognized encoding falls back to UTF-8."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
"utf-8",
)
def test_iso_8859_1_with_entities_roundtrip(self):
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
# Mix of direct ISO-8859-1 chars and HTML entities
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml_str.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
self.assertIn("\xe9mission", content, "Entity should be resolved")
self.assertNotIn("&eacute;", content)
def test_mismatched_encoding_leaves_file_untouched(self):
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(raw)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
original_bytes = raw # save for comparison
_resolve_html_entities(path)
with open(path, "rb") as f:
result_bytes = f.read()
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")

View file

@ -1,6 +1,7 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
@ -46,6 +47,7 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
# 🔹 2) Discover API
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
@ -98,6 +100,7 @@ class DiscoverAPIView(APIView):
# 🔹 3) Lineup API
class LineupAPIView(APIView):
"""Returns available channel lineup"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve the available channel lineup",
@ -138,6 +141,7 @@ class LineupAPIView(APIView):
# 🔹 4) Lineup Status API
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
@ -155,6 +159,7 @@ class LineupStatusAPIView(APIView):
# 🔹 5) Device XML API
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve the HDHomeRun device XML configuration",

View file

@ -20,6 +20,7 @@ import json
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from core.utils import safe_upload_path
from apps.channels.models import ChannelGroupM3UAccount
from core.serializers import UserAgentSerializer
from apps.vod.models import M3UVODCategoryRelation
@ -40,7 +41,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
queryset = M3UAccount.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).prefetch_related("channel_group")
).prefetch_related("channel_group", "profiles")
serializer_class = M3UAccountSerializer
def get_permissions(self):
@ -54,10 +55,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
file_path = None
if "file" in request.FILES:
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join("/data/uploads/m3us", file_name)
try:
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
os.makedirs("/data/uploads/m3us", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
@ -117,10 +120,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
file_path = None
if "file" in request.FILES:
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join("/data/uploads/m3us", file_name)
try:
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
os.makedirs("/data/uploads/m3us", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)

View file

@ -0,0 +1,57 @@
# Generated by Django 6.0.3 on 2026-03-14 19:41
from datetime import datetime, timezone
from django.db import migrations, models
def populate_exp_date_from_custom_properties(apps, schema_editor):
"""Backfill exp_date from custom_properties['user_info']['exp_date']."""
M3UAccountProfile = apps.get_model('m3u', 'M3UAccountProfile')
profiles_to_update = []
for profile in M3UAccountProfile.objects.filter(
custom_properties__isnull=False,
).exclude(custom_properties={}):
user_info = profile.custom_properties.get('user_info', {})
raw_exp = user_info.get('exp_date')
if raw_exp is None:
continue
parsed = None
try:
if isinstance(raw_exp, (int, float)):
parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
elif isinstance(raw_exp, str):
try:
parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
except ValueError:
parsed = datetime.fromisoformat(raw_exp)
except (ValueError, TypeError, OSError):
pass
if parsed is not None:
profile.exp_date = parsed
profiles_to_update.append(profile)
if profiles_to_update:
M3UAccountProfile.objects.bulk_update(profiles_to_update, ['exp_date'], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
('m3u', '0018_add_profile_custom_properties'),
]
operations = [
migrations.AddField(
model_name='m3uaccountprofile',
name='exp_date',
field=models.DateTimeField(blank=True, help_text='Account expiration date, auto-synced from custom_properties on save', null=True),
),
migrations.RunPython(
populate_exp_date_from_custom_properties,
reverse_code=migrations.RunPython.noop,
),
]

View file

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from django.db import models
from django.core.exceptions import ValidationError
from core.models import UserAgent
@ -264,11 +265,16 @@ class M3UAccountProfile(models.Model):
)
current_viewers = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
default=dict,
blank=True,
null=True,
help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)"
)
exp_date = models.DateTimeField(
null=True,
blank=True,
help_text="Account expiration date, auto-synced from custom_properties on save",
)
class Meta:
constraints = [
@ -280,36 +286,51 @@ class M3UAccountProfile(models.Model):
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
def get_account_expiration(self):
"""Get account expiration date from custom properties if available"""
def save(self, *args, **kwargs):
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
For non-XC accounts, exp_date is set directly and left untouched here."""
parsed = self._parse_exp_date_from_custom_properties()
if parsed is not None:
# XC account with exp_date in custom_properties — always sync
self.exp_date = parsed
# else: keep whatever exp_date is already set (manual entry for non-XC)
super().save(*args, **kwargs)
@staticmethod
def _parse_exp_date(raw_value):
"""Parse a raw exp_date value (unix timestamp or ISO string) into a datetime."""
if raw_value is None:
return None
try:
if isinstance(raw_value, (int, float)):
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
elif isinstance(raw_value, str):
try:
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
except ValueError:
return datetime.fromisoformat(raw_value)
except (ValueError, TypeError, OSError):
pass
return None
def _parse_exp_date_from_custom_properties(self):
"""Extract exp_date from custom_properties JSON."""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
exp_date = user_info.get('exp_date')
if exp_date:
try:
from datetime import datetime
# XC exp_date is typically a Unix timestamp
if isinstance(exp_date, (int, float)):
return datetime.fromtimestamp(exp_date)
elif isinstance(exp_date, str):
# Try to parse as timestamp first, then as ISO date
try:
return datetime.fromtimestamp(float(exp_date))
except ValueError:
return datetime.fromisoformat(exp_date)
except (ValueError, TypeError):
pass
return None
return self._parse_exp_date(user_info.get('exp_date'))
def get_account_expiration(self):
"""Get account expiration date — uses the dedicated field if set, otherwise parses JSON."""
if self.exp_date:
return self.exp_date
return self._parse_exp_date_from_custom_properties()
def get_account_status(self):
"""Get account status from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('status')
@ -317,7 +338,7 @@ class M3UAccountProfile(models.Model):
"""Get maximum connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('max_connections')
@ -325,7 +346,7 @@ class M3UAccountProfile(models.Model):
"""Get active connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('active_cons')
@ -333,7 +354,7 @@ class M3UAccountProfile(models.Model):
"""Get last refresh timestamp from custom properties if available"""
if not self.custom_properties:
return None
last_refresh = self.custom_properties.get('last_refresh')
if last_refresh:
try:
@ -341,7 +362,7 @@ class M3UAccountProfile(models.Model):
return datetime.fromisoformat(last_refresh)
except (ValueError, TypeError):
pass
return None

View file

@ -7,6 +7,7 @@ from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
)
from datetime import timezone as dt_tz
import logging
import json
@ -52,12 +53,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
"search_pattern",
"replace_pattern",
"custom_properties",
"exp_date",
"account",
]
read_only_fields = ["id", "account"]
extra_kwargs = {
'search_pattern': {'required': False, 'allow_blank': True},
'replace_pattern': {'required': False, 'allow_blank': True},
'exp_date': {'required': False, 'allow_null': True},
}
def create(self, validated_data):
@ -90,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
if instance.is_default:
# For default profiles, only allow updating name and custom_properties (for notes)
allowed_fields = {'name', 'custom_properties'}
# For default profiles, only allow updating name, custom_properties, and exp_date
allowed_fields = {'name', 'custom_properties', 'exp_date'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
f"Default profiles can only modify name and notes. "
f"Default profiles can only modify name, notes, and expiration. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)
@ -117,6 +120,12 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = serializers.SerializerMethodField()
earliest_expiration = serializers.SerializerMethodField()
all_expirations = serializers.SerializerMethodField()
exp_date = serializers.DateTimeField(
required=False, allow_null=True, write_only=True,
help_text="Expiration date for the default profile (write-through)",
)
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@ -172,6 +181,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"auto_enable_new_groups_live",
"auto_enable_new_groups_vod",
"auto_enable_new_groups_series",
"earliest_expiration",
"all_expirations",
"exp_date",
]
extra_kwargs = {
"password": {
@ -200,9 +212,24 @@ class M3UAccountSerializer(serializers.ModelSerializer):
ct = instance.refresh_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
data["cron_expression"] = cron_expr
# Surface default profile's exp_date for the form.
# Use prefetch cache (obj.profiles.all()) to avoid an extra query per account.
# Always emit a Z-suffix UTC string so JS new Date() never misinterprets it as local.
default_profile = next((p for p in instance.profiles.all() if p.is_default), None)
exp = default_profile.exp_date if default_profile else None
if exp:
exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
data["exp_date"] = exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
else:
data["exp_date"] = None
return data
def update(self, instance, validated_data):
# Pop exp_date — it's written to the default profile, not the account
exp_date = validated_data.pop("exp_date", "__NOT_SET__")
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if "cron_expression" in validated_data:
@ -264,9 +291,26 @@ class M3UAccountSerializer(serializers.ModelSerializer):
memberships_to_update, ["enabled"]
)
# Write exp_date through to the default profile.
# Use a fresh DB query (not the prefetch cache) so we get the profile
# object AFTER the post_save signal (create_profile_for_m3u_account)
# has already updated max_streams, avoiding a stale-value overwrite.
if exp_date != "__NOT_SET__":
default_profile = instance.profiles.filter(is_default=True).first()
if default_profile:
default_profile.exp_date = exp_date
default_profile.save(update_fields=['exp_date'])
# Invalidate the profiles prefetch cache so to_representation
# sees the updated exp_date rather than the pre-request snapshot.
if '_prefetched_objects_cache' in instance.__dict__:
instance._prefetched_objects_cache.pop('profiles', None)
return instance
def create(self, validated_data):
# Pop exp_date — it's written to the default profile after creation
exp_date = validated_data.pop("exp_date", None)
# Pop cron_expression — it's not a model field
cron_expr = validated_data.pop("cron_expression", "")
@ -290,12 +334,47 @@ class M3UAccountSerializer(serializers.ModelSerializer):
instance = M3UAccount(**validated_data)
instance._cron_expression = cron_expr
instance.save()
# Write exp_date through to the default profile created by post_save signal
if exp_date is not None:
default_profile = instance.profiles.filter(is_default=True).first()
if default_profile:
default_profile.exp_date = exp_date
default_profile.save()
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
def get_earliest_expiration(self, obj):
"""Return the soonest exp_date across all active profiles for this account."""
# Filter in Python over the prefetch cache to avoid an extra query per account.
expiring = [p.exp_date for p in obj.profiles.all() if p.is_active and p.exp_date]
if not expiring:
return None
exp = min(expiring)
exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
return exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
def get_all_expirations(self, obj):
"""Return exp_date info for every profile that has one (for tooltip)."""
# Filter in Python over the prefetch cache to avoid an extra query per account.
profiles = sorted(
(p for p in obj.profiles.all() if p.exp_date),
key=lambda p: p.exp_date,
)
return [
{
"profile_id": p.id,
"profile_name": p.name,
"exp_date": (p.exp_date.astimezone(dt_tz.utc) if p.exp_date.tzinfo else p.exp_date.replace(tzinfo=dt_tz.utc)).strftime('%Y-%m-%dT%H:%M:%SZ'),
"is_active": p.is_active,
}
for p in profiles
]
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""

View file

@ -1,7 +1,7 @@
# apps/m3u/signals.py
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import M3UAccount
from .models import M3UAccount, M3UAccountProfile
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
import json
@ -68,6 +68,62 @@ def create_or_update_refresh_task(sender, instance, created, update_fields=None,
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
@receiver(post_save, sender=M3UAccountProfile)
def update_profile_expiration_notification(sender, instance, created, update_fields=None, **kwargs):
"""
When a profile's exp_date is set or changed, immediately update its expiration notification
so the frontend reflects the new state without waiting for the daily celery task.
"""
# Only act when exp_date was involved in the save
if not created and update_fields is not None and "exp_date" not in update_fields:
return
try:
if not instance.exp_date:
# exp_date was cleared — remove any existing notifications immediately
from core.models import SystemNotification
from core.utils import send_notification_dismissed
keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
return
from apps.m3u.tasks import evaluate_profile_expiration_notification
evaluate_profile_expiration_notification(instance)
except Exception as e:
logger.error(f"Error updating expiration notification for profile {instance.id}: {str(e)}")
@receiver(post_delete, sender=M3UAccountProfile)
def cleanup_profile_notifications(sender, instance, **kwargs):
"""
Delete expiration notifications for a profile when it is deleted.
Handles both direct deletion and cascade deletion from M3UAccount.
"""
try:
from core.models import SystemNotification
from core.utils import send_notification_dismissed
keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
if deleted_keys:
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
logger.debug(f"Cleaned up {len(deleted_keys)} notifications for deleted profile {instance.id}")
except Exception as e:
logger.error(f"Error cleaning up notifications for profile {instance.id}: {str(e)}")
@receiver(post_delete, sender=M3UAccount)
def delete_refresh_task(sender, instance, **kwargs):
"""
@ -107,6 +163,34 @@ def update_status_on_active_change(sender, instance, **kwargs):
else:
# When deactivating, set status to disabled
instance.status = M3UAccount.Status.DISABLED
# Clean up any expiration notifications for all profiles of this account
try:
from core.models import SystemNotification
from core.utils import send_notification_dismissed
profile_ids = list(
M3UAccountProfile.objects.filter(m3u_account=instance)
.values_list("id", flat=True)
)
keys = [
key
for pid in profile_ids
for key in [f"xc-exp-warning-{pid}", f"xc-exp-expired-{pid}"]
]
if keys:
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
if deleted_keys:
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
logger.debug(
f"Cleaned up {len(deleted_keys)} notifications for deactivated M3U account {instance.id}"
)
except Exception as notify_err:
logger.error(f"Error cleaning up notifications on account deactivation: {notify_err}")
except M3UAccount.DoesNotExist:
# New record, will use default status
pass

View file

@ -1,6 +1,7 @@
# apps/m3u/tasks.py
import logging
import re
import regex
import requests
import os
import gc
@ -11,7 +12,7 @@ from celery.result import AsyncResult
from celery import shared_task, current_app, group
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db import models, transaction
from .models import M3UAccount
from apps.channels.models import Stream, ChannelGroup, ChannelGroupM3UAccount
from asgiref.sync import async_to_sync
@ -38,6 +39,8 @@ logger = logging.getLogger(__name__)
BATCH_SIZE = 1500 # Optimized batch size for threading
m3u_dir = os.path.join(settings.MEDIA_ROOT, "cached_m3u")
_EXTINF_ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2')
def fetch_m3u_lines(account, use_cache=False):
os.makedirs(m3u_dir, exist_ok=True)
@ -484,18 +487,13 @@ def parse_extinf_line(line: str) -> dict:
return None
content = line[len("#EXTINF:") :].strip()
# Single pass: extract all attributes AND track the last attribute position
# This regex matches both key="value" and key='value' patterns
# Single pass: extract all attributes AND track the last attribute position.
# Keys are normalised to lowercase so downstream code can use plain dict.get()
attrs = {}
last_attr_end = 0
# Use a single regex that handles both quote types.
# Keys must stop at '=' so values like base64-padded URLs ending with '=='
# don't get folded into the preceding attribute name.
for match in re.finditer(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2', content):
key = match.group(1)
value = match.group(3)
attrs[key] = value
for match in _EXTINF_ATTR_RE.finditer(content):
attrs[match.group(1).lower()] = match.group(3)
last_attr_end = match.end()
# Everything after the last attribute (skipping leading comma and whitespace) is the display name
@ -513,15 +511,80 @@ def parse_extinf_line(line: str) -> dict:
else:
display_name = content.strip()
# Use tvg-name attribute if available; otherwise try tvc-guide-title, then fall back to display name.
name = get_case_insensitive_attr(attrs, "tvg-name", None)
if not name:
name = get_case_insensitive_attr(attrs, "tvc-guide-title", None)
if not name:
name = display_name
# Per the base #EXTINF spec, the comma text is the canonical human-readable title.
# Fall back to tvc-guide-title, then tvg-name (which some providers use as an EPG key,
# not a display label), and finally the raw content if everything else is empty.
name = display_name or attrs.get("tvc-guide-title") or attrs.get("tvg-name") or content.strip()
return {"attributes": attrs, "display_name": display_name, "name": name}
def iter_m3u_entries(lines):
"""
Generator that yields fully-assembled M3U stream entries from raw lines.
Each yielded dict is guaranteed to contain a ``url`` key in addition to the
fields produced by :func:`parse_extinf_line` (``attributes``, ``display_name``,
``name``). Recognised extended-tag lines that appear *between* an ``#EXTINF``
and its URL are accumulated into the pending entry so they are available for
downstream processing:
- ``#EXTGRP`` — sets ``attributes["group-title"]`` when no ``group-title``
attribute was present on the ``#EXTINF`` line (explicit attribute wins).
- ``#EXTVLCOPT`` — stored as a list under the ``vlc_opts`` key.
Unknown directives (``#KODIPROP``, etc.) and blank lines are
silently skipped while keeping the pending entry intact. A second ``#EXTINF``
before a URL discards the first entry with a warning. A trailing ``#EXTINF``
at end-of-file with no URL is also discarded.
Adding support for a new directive requires only a new ``elif`` branch here;
no other code needs to change.
"""
pending = None
pending_line_num = None
for line_num, raw_line in enumerate(lines, 1):
line = raw_line.strip()
if not line:
continue
if line.startswith("#EXTINF"):
if pending is not None:
logger.warning(
f"Line {pending_line_num}: #EXTINF had no URL (next #EXTINF at line {line_num}); "
f"discarding entry: {list(pending['attributes'].items())[:3]}"
)
parsed = parse_extinf_line(line)
if parsed is None:
logger.warning(f"Line {line_num}: Failed to parse #EXTINF: {line[:200]}")
pending = parsed # None if malformed; URL branch guards on `pending is not None`
pending_line_num = line_num
elif line.startswith("#EXTGRP:"):
# Only apply when group-title is absent — explicit attribute wins.
if pending is not None and "group-title" not in pending["attributes"]:
pending["attributes"]["group-title"] = line[len("#EXTGRP:"):].strip()
# else: #EXTGRP outside an entry, or group-title already set — silently skip
elif line.startswith("#EXTVLCOPT:"):
if pending is not None:
pending.setdefault("vlc_opts", []).append(line[len("#EXTVLCOPT:"):])
# else: #EXTVLCOPT outside an entry — silently skip
elif pending is not None and line.startswith(("http", "rtsp", "rtp", "udp")):
pending["url"] = normalize_stream_url(line) if line.startswith("udp") else line
yield pending
pending = None
pending_line_num = None
# else: unknown directive or bare content — skip, keeping pending intact
if pending is not None:
logger.warning(
f"Line {pending_line_num}: #EXTINF at end of file had no URL; "
f"discarding entry: {list(pending['attributes'].items())[:3]}"
)
@shared_task
def refresh_m3u_accounts():
"""Queue background parse for all active M3UAccounts."""
@ -1114,7 +1177,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
"m3u_account": account,
"channel_group_id": int(groups.get(group_title)),
"stream_hash": stream_hash,
"custom_properties": stream_info["attributes"],
"custom_properties": {**stream_info["attributes"], "vlc_opts": stream_info["vlc_opts"]} if "vlc_opts" in stream_info else stream_info["attributes"],
"is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)),
"is_stale": False,
"stream_id": provider_stream_id,
@ -1482,7 +1545,6 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
else:
# Here's the key change - use the success flag from fetch_m3u_lines
lines, success = fetch_m3u_lines(account, use_cache)
if not success:
# If fetch failed, don't continue processing
@ -1493,71 +1555,20 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
# Log basic file structure for debugging
logger.debug(f"Processing {len(lines)} lines from M3U file")
line_count = 0
extinf_count = 0
url_count = 0
valid_stream_count = 0
problematic_lines = []
for line_index, line in enumerate(lines):
line_count += 1
line = line.strip()
for entry in iter_m3u_entries(lines):
valid_stream_count += 1
group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "")
if group_title_attr and group_title_attr not in groups:
logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'")
groups[group_title_attr] = {}
extinf_data.append(entry)
if line.startswith("#EXTINF"):
extinf_count += 1
parsed = parse_extinf_line(line)
if parsed:
group_title_attr = get_case_insensitive_attr(
parsed["attributes"], "group-title", ""
)
if group_title_attr:
group_name = group_title_attr
# Log new groups as they're discovered
if group_name not in groups:
logger.debug(
f"Found new group for M3U account {account_id}: '{group_name}'"
)
groups[group_name] = {}
if valid_stream_count % 1000 == 0:
logger.debug(f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}")
extinf_data.append(parsed)
else:
# Log problematic EXTINF lines
logger.warning(
f"Failed to parse EXTINF at line {line_index+1}: {line[:200]}"
)
problematic_lines.append((line_index + 1, line[:200]))
elif extinf_data and (line.startswith("http") or line.startswith("rtsp") or line.startswith("rtp") or line.startswith("udp")):
url_count += 1
# Normalize UDP URLs only (e.g., remove VLC-specific @ prefix)
normalized_url = normalize_stream_url(line) if line.startswith("udp") else line
# Associate URL with the last EXTINF line
extinf_data[-1]["url"] = normalized_url
valid_stream_count += 1
# Periodically log progress for large files
if valid_stream_count % 1000 == 0:
logger.debug(
f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}"
)
# Log summary statistics
logger.info(
f"M3U parsing complete - Lines: {line_count}, EXTINF: {extinf_count}, URLs: {url_count}, Valid streams: {valid_stream_count}"
)
if problematic_lines:
logger.warning(
f"Found {len(problematic_lines)} problematic lines during parsing"
)
for i, (line_num, content) in enumerate(
problematic_lines[:10]
): # Log max 10 examples
logger.warning(f"Problematic line #{i+1} at line {line_num}: {content}")
if len(problematic_lines) > 10:
logger.warning(
f"... and {len(problematic_lines) - 10} more problematic lines"
)
logger.info(f"M3U parsing complete - Valid streams: {valid_stream_count}")
# Log group statistics
logger.info(
@ -2399,11 +2410,13 @@ def get_transformed_credentials(account, profile=None):
# Apply profile-specific transformations if profile is provided
if profile and profile.search_pattern and profile.replace_pattern:
try:
# Handle backreferences in the replacement pattern
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
# Handle backreferences: convert JS-style $<name> -> \g<name>, $1 -> \1
# regex module accepts JS-style (?<name>...) named groups natively
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', profile.replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
# Apply transformation to the complete URL
transformed_complete_url = re.sub(profile.search_pattern, safe_replace_pattern, complete_url)
transformed_complete_url = regex.sub(profile.search_pattern, safe_replace_pattern, complete_url)
logger.info(f"Transformed complete URL: {complete_url} -> {transformed_complete_url}")
# Extract components from the transformed URL
@ -3185,3 +3198,163 @@ def send_m3u_update(account_id, action, progress, **kwargs):
# Explicitly clear data reference to help garbage collection
data = None
def evaluate_profile_expiration_notification(profile):
"""
Evaluate a single M3UAccountProfile's expiration date and create, update,
or delete the corresponding SystemNotification accordingly.
Returns the notification key that should remain active (warning or expired),
or None if the profile is not expiring soon and any stale notifications were removed.
This return value is used by the bulk task to track active keys for stale cleanup.
"""
from core.models import SystemNotification
from core.utils import send_websocket_notification, send_notification_dismissed
exp = profile.exp_date
if not exp:
return None
now = timezone.now()
warning_threshold = now + timezone.timedelta(days=7)
warning_key = f"xc-exp-warning-{profile.id}"
expired_key = f"xc-exp-expired-{profile.id}"
if exp <= now:
# Already expired — delete warning, create/update expired notification
deleted_warning = list(
SystemNotification.objects.filter(notification_key=warning_key)
.values_list("notification_key", flat=True)
)
SystemNotification.objects.filter(notification_key=warning_key).delete()
for key in deleted_warning:
send_notification_dismissed(key)
notification, created = SystemNotification.objects.update_or_create(
notification_key=expired_key,
defaults={
"notification_type": SystemNotification.NotificationType.WARNING,
"priority": SystemNotification.Priority.HIGH,
"title": f"Account Expired: {profile.name}",
"message": (
f'Profile "{profile.name}" on M3U account '
f'"{profile.m3u_account.name}" has expired '
f"(expired {exp.strftime('%Y-%m-%d %H:%M UTC')})."
),
"action_data": {
"profile_id": profile.id,
"account_id": profile.m3u_account.id,
"account_name": profile.m3u_account.name,
"profile_name": profile.name,
"exp_date": exp.isoformat(),
},
"is_active": True,
"admin_only": True,
},
)
send_websocket_notification(notification)
return expired_key
elif exp <= warning_threshold:
# Expiring within 7 days — delete expired notification, create/update warning
deleted_expired = list(
SystemNotification.objects.filter(notification_key=expired_key)
.values_list("notification_key", flat=True)
)
SystemNotification.objects.filter(notification_key=expired_key).delete()
for key in deleted_expired:
send_notification_dismissed(key)
days_left = (exp - now).days
if days_left == 0:
expires_in_str = "today"
elif days_left == 1:
expires_in_str = "in 1 day"
else:
expires_in_str = f"in {days_left} days"
notification, created = SystemNotification.objects.update_or_create(
notification_key=warning_key,
defaults={
"notification_type": SystemNotification.NotificationType.WARNING,
"priority": SystemNotification.Priority.NORMAL,
"title": f"Account Expiring: {profile.name}",
"message": (
f'Profile "{profile.name}" on M3U account '
f'"{profile.m3u_account.name}" expires {expires_in_str} '
f"(expires {exp.strftime('%Y-%m-%d %H:%M UTC')})."
),
"action_data": {
"profile_id": profile.id,
"account_id": profile.m3u_account.id,
"account_name": profile.m3u_account.name,
"profile_name": profile.name,
"exp_date": exp.isoformat(),
},
"is_active": True,
"admin_only": True,
},
)
send_websocket_notification(notification)
return warning_key
else:
# Not expiring soon — delete any stale notifications
deleted_keys = list(
SystemNotification.objects.filter(
notification_key__in=[warning_key, expired_key]
).values_list("notification_key", flat=True)
)
SystemNotification.objects.filter(
notification_key__in=[warning_key, expired_key]
).delete()
for key in deleted_keys:
send_notification_dismissed(key)
return None
@shared_task
def check_account_expirations():
"""
Daily task: check all account profiles for upcoming expirations.
Creates/updates SystemNotifications for profiles expiring within 7 days.
Uses separate notification keys for warning vs expired so users can
dismiss the 7-day warning and still receive the expired notification.
"""
from apps.m3u.models import M3UAccountProfile
from core.models import SystemNotification
from core.utils import send_notification_dismissed
# Find all active profiles with an exp_date that is set
expiring_profiles = (
M3UAccountProfile.objects.filter(
m3u_account__is_active=True,
is_active=True,
exp_date__isnull=False,
)
.select_related("m3u_account")
)
active_notification_keys = set()
for profile in expiring_profiles:
active_key = evaluate_profile_expiration_notification(profile)
if active_key:
active_notification_keys.add(active_key)
# Delete stale notifications for profiles whose expiration was extended
stale = SystemNotification.objects.filter(
is_active=True,
).filter(
models.Q(notification_key__startswith="xc-exp-warning-") |
models.Q(notification_key__startswith="xc-exp-expired-")
).exclude(notification_key__in=active_notification_keys)
stale_keys = list(stale.values_list("notification_key", flat=True))
stale.delete()
for key in stale_keys:
send_notification_dismissed(key)
logger.info(
f"Account expiration check complete: {len(active_notification_keys)} active notifications"
)

View file

@ -0,0 +1,216 @@
"""
Tests for evaluate_profile_expiration_notification.
Covers all four branches:
- no exp_date returns None, touches nothing
- already expired creates/updates expired notification, removes warning
- expiring within 7d creates/updates warning notification, removes expired
- not expiring soon removes any stale notifications, returns None
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import SimpleTestCase
from django.utils import timezone
def _make_profile(exp_date, profile_id=1, profile_name="Test Profile",
account_id=10, account_name="Test Account"):
"""Return a minimal mock M3UAccountProfile."""
profile = MagicMock()
profile.id = profile_id
profile.name = profile_name
profile.exp_date = exp_date
profile.m3u_account.id = account_id
profile.m3u_account.name = account_name
return profile
class EvaluateProfileExpirationNotificationTests(SimpleTestCase):
def setUp(self):
# These three names are local imports inside evaluate_profile_expiration_notification,
# so we must patch them at their source modules rather than on apps.m3u.tasks.
self.mock_sn = MagicMock()
self.mock_send_ws = patch("core.utils.send_websocket_notification").start()
self.mock_dismissed = patch("core.utils.send_notification_dismissed").start()
patch("core.models.SystemNotification", self.mock_sn).start()
def tearDown(self):
patch.stopall()
def _run(self, profile):
from apps.m3u.tasks import evaluate_profile_expiration_notification
return evaluate_profile_expiration_notification(profile)
# ------------------------------------------------------------------ #
# No expiration date
# ------------------------------------------------------------------ #
def test_no_exp_date_returns_none(self):
profile = _make_profile(exp_date=None)
result = self._run(profile)
self.assertIsNone(result)
self.mock_sn.objects.update_or_create.assert_not_called()
self.mock_send_ws.assert_not_called()
# ------------------------------------------------------------------ #
# Already expired
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_expired_creates_expired_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now - timedelta(days=1))
# No existing warning notification to delete
self.mock_sn.objects.filter.return_value.values_list.return_value = []
notification = MagicMock()
self.mock_sn.objects.update_or_create.return_value = (notification, True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-expired-{profile.id}")
self.mock_sn.objects.update_or_create.assert_called_once()
call_kwargs = self.mock_sn.objects.update_or_create.call_args
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}")
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
self.mock_send_ws.assert_called_once_with(notification)
@patch("apps.m3u.tasks.timezone")
def test_expired_removes_stale_warning_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now - timedelta(hours=1))
warning_key = f"xc-exp-warning-{profile.id}"
# Simulate an existing warning notification
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
self._run(profile)
self.mock_dismissed.assert_any_call(warning_key)
# ------------------------------------------------------------------ #
# Expiring within 7 days
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_warning_window_creates_warning_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=3))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
notification = MagicMock()
self.mock_sn.objects.update_or_create.return_value = (notification, True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-warning-{profile.id}")
call_kwargs = self.mock_sn.objects.update_or_create.call_args
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}")
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
self.mock_send_ws.assert_called_once_with(notification)
@patch("apps.m3u.tasks.timezone")
def test_warning_message_says_today_when_same_day(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(hours=2))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
self._run(profile)
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
self.assertIn("today", defaults["message"])
@patch("apps.m3u.tasks.timezone")
def test_warning_message_says_1_day(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(hours=30))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
self._run(profile)
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
self.assertIn("in 1 day", defaults["message"])
@patch("apps.m3u.tasks.timezone")
def test_warning_removes_stale_expired_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=5))
expired_key = f"xc-exp-expired-{profile.id}"
self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key]
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
self._run(profile)
self.mock_dismissed.assert_any_call(expired_key)
# ------------------------------------------------------------------ #
# Not expiring soon (> 7 days away)
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_not_expiring_soon_returns_none(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=30))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
result = self._run(profile)
self.assertIsNone(result)
self.mock_sn.objects.update_or_create.assert_not_called()
self.mock_send_ws.assert_not_called()
@patch("apps.m3u.tasks.timezone")
def test_not_expiring_soon_removes_stale_notifications(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=30))
warning_key = f"xc-exp-warning-{profile.id}"
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
self._run(profile)
self.mock_dismissed.assert_called_once_with(warning_key)
# ------------------------------------------------------------------ #
# Boundary: exactly at the 7-day warning threshold
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_exactly_7_days_away_triggers_warning(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
# exp_date == now + 7 days → exp <= warning_threshold → warning
profile = _make_profile(exp_date=now + timedelta(days=7))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-warning-{profile.id}")

View file

@ -194,7 +194,7 @@ def generate_m3u(request, profile_name=None, user=None):
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
# Optionally preserve certain query parameters
preserved_params = ['tvg_id_source', 'cachedlogos', 'days']
preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days']
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
if query_params:
from urllib.parse import urlencode
@ -1268,7 +1268,28 @@ def generate_epg(request, profile_name=None, user=None):
"""
# Check cache for recent identical request (helps with double-GET from browsers)
from django.core.cache import cache
cache_params = f"{profile_name or 'all'}:{user.username if user else 'anonymous'}:{request.GET.urlencode()}"
# Resolve all effective parameter values once here so they are reused for both
# the cache key and inside epg_generator() via closure.
# The cache key is built from resolved values only — not from the raw query string —
# so equivalent requests (e.g. days=7 via URL param vs. user default of 7) share
# the same cache entry regardless of how the value was supplied.
user_custom = (user.custom_properties or {}) if user else {}
try:
num_days = int(request.GET.get('days', user_custom.get('epg_days', 0)))
num_days = max(0, min(num_days, 365))
except (ValueError, TypeError):
num_days = 0
try:
prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0)))
prev_days = max(0, min(prev_days, 30))
except (ValueError, TypeError):
prev_days = 0
use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false'
tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
cache_params = (
f"{profile_name or 'all'}:{user.username if user else 'anonymous'}"
f":d={num_days}:p={prev_days}:logos={use_cached_logos}:tvgid={tvg_id_source}"
)
content_cache_key = f"epg_content:{cache_params}"
cached_content = cache.get(content_cache_key)
@ -1331,29 +1352,14 @@ def generate_epg(request, profile_name=None, user=None):
else:
channels = Channel.objects.all().order_by("channel_number")
# Check if the request wants to use direct logo URLs instead of cache
use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false'
# Get the source to use for tvg-id value
# Options: 'channel_number' (default), 'tvg_id', 'gracenote'
tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
# Get the number of days for EPG data
try:
# Default to 0 days (everything) for real EPG if not specified
days_param = request.GET.get('days', '0')
num_days = int(days_param)
# Set reasonable limits
num_days = max(0, min(num_days, 365)) # Between 0 and 365 days
except ValueError:
num_days = 0 # Default to all data if invalid value
# For dummy EPG, use either the specified value or default to 3 days
dummy_days = num_days if num_days > 0 else 3
# Calculate cutoff date for EPG data filtering (only if days > 0)
# Calculate cutoff dates for EPG data filtering
now = django_timezone.now()
cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None
lookback_cutoff = now - timedelta(days=prev_days)
# Build collision-free channel number mapping for XC clients (if user is authenticated)
# XC clients require integer channel numbers, so we need to ensure no conflicts
@ -1643,13 +1649,14 @@ def generate_epg(request, profile_name=None, user=None):
# For real EPG data - filter only if days parameter was specified
if num_days > 0:
programs_qs = channel.epg_data.programs.filter(
end_time__gte=now,
end_time__gte=lookback_cutoff,
start_time__lt=cutoff_date
).order_by('id') # Explicit ordering for consistent chunking
else:
# Return all non-expired programs if days=0 or not specified
# Return programs from lookback_cutoff onward (includes recent past
# for catch-up when prev_days > 0, otherwise current/future only)
programs_qs = channel.epg_data.programs.filter(
end_time__gte=now
end_time__gte=lookback_cutoff
).order_by('id')
# Process programs in chunks to avoid cursor timeout issues
@ -2332,6 +2339,20 @@ def xc_get_epg(request, user, short=False):
channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number))
limit = int(request.GET.get('limit', 4))
user_custom = user.custom_properties or {}
try:
num_days = int(request.GET.get('days', user_custom.get('epg_days', 0)))
num_days = max(0, min(num_days, 365))
except (ValueError, TypeError):
num_days = 0
try:
prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0)))
prev_days = max(0, min(prev_days, 30))
except (ValueError, TypeError):
prev_days = 0
now = django_timezone.now()
lookback_cutoff = now - timedelta(days=prev_days)
forward_cutoff = now + timedelta(days=num_days) if num_days > 0 else None
if channel.epg_data:
# Check if this is a dummy EPG that generates on-demand
if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy':
@ -2344,24 +2365,28 @@ def xc_get_epg(request, user, short=False):
)
else:
# Has stored programs, use them
if short == False:
if short:
# Short EPG: current and upcoming only (never historical), limited count
programs = channel.epg_data.programs.filter(
end_time__gt=django_timezone.now()
).order_by('start_time')
else:
programs = channel.epg_data.programs.filter(
end_time__gt=django_timezone.now()
end_time__gt=now
).order_by('start_time')[:limit]
else:
qs = channel.epg_data.programs.filter(end_time__gt=lookback_cutoff)
if forward_cutoff:
qs = qs.filter(start_time__lt=forward_cutoff)
programs = qs.order_by('start_time')
else:
# Regular EPG with stored programs
if short == False:
if short:
# Short EPG: current and upcoming only (never historical), limited count
programs = channel.epg_data.programs.filter(
end_time__gt=django_timezone.now()
).order_by('start_time')
end_time__gt=now
).order_by('start_time')[:limit]
else:
programs = channel.epg_data.programs.filter(
end_time__gt=django_timezone.now()
).order_by('start_time')[:limit]
qs = channel.epg_data.programs.filter(end_time__gt=lookback_cutoff)
if forward_cutoff:
qs = qs.filter(start_time__lt=forward_cutoff)
programs = qs.order_by('start_time')
else:
# No EPG data assigned, generate default dummy
programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None)

View file

@ -8,6 +8,14 @@ from .api_views import (
PluginImportAPIView,
PluginDeleteAPIView,
PluginLogoAPIView,
PluginRepoListCreateAPIView,
PluginRepoPreviewAPIView,
PluginRepoDetailAPIView,
PluginRepoRefreshAPIView,
AvailablePluginsAPIView,
PluginDetailManifestAPIView,
PluginInstallFromRepoAPIView,
PluginRepoSettingsAPIView,
)
app_name = "plugins"
@ -21,4 +29,13 @@ urlpatterns = [
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
# Plugin repos (hub / store) - static paths first, then parametric
path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"),
path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"),
path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"),
path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"),
path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"),
path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"),
path("repos/<int:pk>/", PluginRepoDetailAPIView.as_view(), name="repo-detail"),
path("repos/<int:pk>/refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"),
]

File diff suppressed because it is too large Load diff

View file

@ -52,3 +52,53 @@ class PluginsConfig(AppConfig):
import logging
logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready")
# Register periodic task for refreshing plugin repo manifests
self._setup_repo_refresh_schedule()
# Refresh repo manifests once at startup so the UI always has current data
self._enqueue_startup_refresh()
def _enqueue_startup_refresh(self):
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
try:
from .tasks import refresh_plugin_repos
refresh_plugin_repos.apply_async(countdown=10)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Could not enqueue startup plugin repo refresh (Celery may not be ready yet)"
)
def _setup_repo_refresh_schedule(self):
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
try:
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.models import CoreSettings
from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME
interval = 6
try:
obj = CoreSettings.objects.get(key="plugin_repo_settings")
interval = obj.value.get("refresh_interval_hours", 6)
except CoreSettings.DoesNotExist:
pass
if interval == 0:
delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME)
else:
create_or_update_periodic_task(
task_name=PLUGIN_REPO_REFRESH_TASK_NAME,
celery_task_path="apps.plugins.tasks.refresh_plugin_repos",
interval_hours=interval,
enabled=True,
)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
)

View file

@ -0,0 +1,11 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h
zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv
Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap
MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy
LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ
AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb
yDvGuoZi3I2/GNM/f2qVzFtgPQk=
=Zf/y
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -367,21 +367,35 @@ class PluginManager:
obj.save()
def list_plugins(self) -> List[Dict[str, Any]]:
from .models import PluginConfig
from .models import PluginConfig, PluginRepo
plugins: List[Dict[str, Any]] = []
with self._lock:
registry_snapshot = dict(self._registry)
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").all()}
except Exception as e:
# Database might not be migrated yet; fall back to registry only
logger.warning("PluginConfig table unavailable; listing registry only: %s", e)
configs = {}
# Build repo latest-version lookup from cached manifests
repo_latest = {} # slug -> latest_version
try:
for repo in PluginRepo.objects.filter(enabled=True):
manifest_data = repo.cached_manifest or {}
manifest = manifest_data.get("manifest", manifest_data)
for rp in manifest.get("plugins", []):
s = rp.get("slug", "")
if s:
repo_latest[s] = rp.get("latest_version", "")
except Exception:
pass
# First, include all discovered plugins
for key, lp in registry_snapshot.items():
conf = configs.get(key)
conf_slug = conf.slug if conf else ""
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
logo_url = self._get_logo_url(key, path=lp.path)
plugins.append(
@ -393,7 +407,7 @@ class PluginManager:
"author": getattr(lp, "author", "") or "",
"help_url": getattr(lp, "help_url", "") or "",
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"ever_enabled": conf.ever_enabled if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
@ -402,6 +416,22 @@ class PluginManager:
"loaded": bool(lp.loaded),
"legacy": bool(getattr(lp, "legacy", False)),
"logo_url": logo_url,
"source_repo": conf.source_repo_id if conf else None,
"source_repo_name": conf.source_repo.name if conf and conf.source_repo else None,
"is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official),
"slug": conf_slug,
"is_managed": bool(conf and conf.source_repo_id),
"installed_version_is_prerelease": bool(
conf and conf.installed_version_is_prerelease
),
"update_available": bool(
conf_slug and conf and conf.source_repo_id
and not (conf and conf.installed_version_is_prerelease)
and repo_latest.get(conf_slug)
and lp.version != repo_latest.get(conf_slug)
),
"latest_version": repo_latest.get(conf_slug, ""),
"deprecated": conf.deprecated if conf else False,
}
)
@ -428,6 +458,22 @@ class PluginManager:
"loaded": False,
"legacy": False,
"logo_url": self._get_logo_url(key),
"source_repo": conf.source_repo_id,
"source_repo_name": conf.source_repo.name if conf.source_repo else None,
"is_official_repo": bool(conf.source_repo and conf.source_repo.is_official),
"slug": conf.slug,
"is_managed": bool(conf.source_repo_id),
"installed_version_is_prerelease": bool(
conf.installed_version_is_prerelease
),
"update_available": bool(
conf.slug and conf.source_repo_id
and not conf.installed_version_is_prerelease
and repo_latest.get(conf.slug)
and conf.version != repo_latest.get(conf.slug)
),
"latest_version": repo_latest.get(conf.slug or "", ""),
"deprecated": conf.deprecated,
}
)

View file

@ -0,0 +1,84 @@
import django.db.models.deletion
from django.db import migrations, models
def seed_official_repo(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.get_or_create(
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json",
defaults={
"name": "Dispatcharr Official",
"is_official": True,
"enabled": True,
},
)
def unseed_official_repo(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.filter(is_official=True).delete()
class Migration(migrations.Migration):
dependencies = [
("plugins", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="PluginRepo",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("url", models.URLField(unique=True)),
("is_official", models.BooleanField(default=False)),
("enabled", models.BooleanField(default=True)),
("cached_manifest", models.JSONField(blank=True, default=dict)),
("last_fetched", models.DateTimeField(blank=True, null=True)),
("public_key", models.TextField(blank=True, default="")),
("signature_verified", models.BooleanField(blank=True, default=None, null=True)),
("last_fetch_status", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-is_official", "name"],
},
),
migrations.RunPython(seed_official_repo, unseed_official_repo),
migrations.AddField(
model_name="pluginconfig",
name="source_repo",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="installed_plugins",
to="plugins.pluginrepo",
),
),
migrations.AddField(
model_name="pluginconfig",
name="slug",
field=models.CharField(blank=True, default="", max_length=128),
),
migrations.AddField(
model_name="pluginconfig",
name="installed_version_is_prerelease",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pluginconfig",
name="deprecated",
field=models.BooleanField(default=False),
),
]

View file

@ -12,8 +12,52 @@ class PluginConfig(models.Model):
# Tracks whether this plugin has ever been enabled at least once
ever_enabled = models.BooleanField(default=False)
settings = models.JSONField(default=dict, blank=True)
# Managed plugin fields (populated when installed from a repo)
source_repo = models.ForeignKey(
"PluginRepo",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="installed_plugins",
)
slug = models.CharField(max_length=128, blank=True, default="")
installed_version_is_prerelease = models.BooleanField(default=False)
deprecated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def is_managed(self):
return bool(self.source_repo_id)
def __str__(self) -> str:
return f"{self.name} ({self.key})"
OFFICIAL_REPO_URL = (
"https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
)
class PluginRepo(models.Model):
"""A remote plugin repository manifest URL."""
name = models.CharField(max_length=255)
url = models.URLField(unique=True)
is_official = models.BooleanField(default=False)
enabled = models.BooleanField(default=True)
cached_manifest = models.JSONField(default=dict, blank=True)
public_key = models.TextField(blank=True, default="")
signature_verified = models.BooleanField(null=True, blank=True, default=None)
last_fetched = models.DateTimeField(null=True, blank=True)
last_fetch_status = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-is_official", "name"]
def __str__(self) -> str:
return self.name

View file

@ -1,4 +1,5 @@
from rest_framework import serializers
from .models import PluginRepo
class PluginActionSerializer(serializers.Serializer):
@ -46,3 +47,40 @@ class PluginSerializer(serializers.Serializer):
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)
source_repo = serializers.IntegerField(required=False, allow_null=True)
slug = serializers.CharField(required=False, allow_blank=True)
is_managed = serializers.BooleanField(required=False)
deprecated = serializers.BooleanField(required=False)
class PluginRepoSerializer(serializers.ModelSerializer):
registry_url = serializers.SerializerMethodField()
plugin_count = serializers.SerializerMethodField()
class Meta:
model = PluginRepo
fields = [
"id",
"name",
"url",
"is_official",
"enabled",
"public_key",
"signature_verified",
"registry_url",
"plugin_count",
"last_fetched",
"last_fetch_status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "name", "is_official", "signature_verified", "registry_url", "plugin_count", "last_fetched", "last_fetch_status", "created_at", "updated_at"]
def get_registry_url(self, obj):
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
return manifest.get("registry_url", "") or ""
def get_plugin_count(self, obj):
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
plugins = manifest.get("plugins", [])
return len(plugins) if isinstance(plugins, list) else 0

33
apps/plugins/tasks.py Normal file
View file

@ -0,0 +1,33 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
PLUGIN_REPO_REFRESH_TASK_NAME = "plugin-repo-refresh-task"
@shared_task
def refresh_plugin_repos():
"""Refresh cached manifests for all enabled plugin repos."""
from .models import PluginRepo
from .api_views import _fetch_manifest, _save_fetched_manifest_to_repo, _unmanage_dropped_slugs
from django.utils import timezone
repos = PluginRepo.objects.filter(enabled=True)
for repo in repos:
try:
key_text = repo.public_key if not repo.is_official else None
data, verified = _fetch_manifest(repo.url, public_key_text=key_text)
err = _save_fetched_manifest_to_repo(repo, data, verified)
if err:
logger.warning("Skipping repo '%s': %s", repo.name, err)
continue
_unmanage_dropped_slugs(repo, data)
logger.info("Refreshed plugin repo '%s'", repo.name)
except Exception as e:
resp = getattr(e, 'response', None)
status_str = str(resp.status_code) if resp is not None and hasattr(resp, 'status_code') else type(e).__name__
repo.last_fetch_status = status_str[:255]
repo.last_fetched = timezone.now()
repo.save(update_fields=["last_fetch_status", "last_fetched", "updated_at"])
logger.warning("Failed to refresh plugin repo '%s': %s", repo.name, e)

View file

@ -99,7 +99,7 @@ class TSConfig(BaseConfig):
CLIENT_RECORD_TTL = 60 # How long client records persist in Redis (seconds). Client will be considered MIA after this time.
CLEANUP_CHECK_INTERVAL = 1 # How often to check for disconnected clients (seconds)
CLIENT_HEARTBEAT_INTERVAL = 5 # How often to send client heartbeats (seconds)
GHOST_CLIENT_MULTIPLIER = 6.0 # How many heartbeat intervals before client considered ghost (6 would mean 36 seconds if heartbeat interval is 6)
GHOST_CLIENT_MULTIPLIER = 10.0 # How many heartbeat intervals before client considered ghost (10 = 50s, must exceed STREAM_TIMEOUT + FAILOVER_GRACE_PERIOD = 40s)
CLIENT_WAIT_TIMEOUT = 30 # Seconds to wait for client to connect
# Stream health and recovery settings
@ -108,6 +108,7 @@ class TSConfig(BaseConfig):
MIN_STABLE_TIME_BEFORE_RECONNECT = 30 # Minimum seconds a stream must be stable to try reconnect
FAILOVER_GRACE_PERIOD = 20 # Extra time (seconds) to allow for stream switching before disconnecting clients
URL_SWITCH_TIMEOUT = 20 # Max time allowed for a stream switch operation
MAX_KEEPALIVE_DURATION = 300 # Keepalive packets prevent _is_timeout() from firing, so without this a permanently failed stream holds clients open indefinitely.

View file

@ -4,6 +4,8 @@ import logging
from django.http import StreamingHttpResponse, JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from rest_framework.decorators import api_view, permission_classes
from apps.accounts.permissions import IsAdmin
from .server import ProxyServer, Config
logger = logging.getLogger(__name__)
@ -15,7 +17,7 @@ def stream_endpoint(request, channel_id):
"""Handle HLS manifest requests"""
if channel_id not in proxy_server.stream_managers:
return JsonResponse({'error': 'Channel not found'}, status=404)
response = proxy_server.stream_endpoint(channel_id)
return StreamingHttpResponse(
response[0],
@ -30,10 +32,10 @@ def get_segment(request, segment_name):
try:
segment_num = int(segment_name.split('.')[0])
buffer = proxy_server.stream_buffers.get(segment_num)
if not buffer:
return JsonResponse({'error': 'Segment not found'}, status=404)
return StreamingHttpResponse(
buffer,
content_type='video/MP2T'
@ -44,19 +46,19 @@ def get_segment(request, segment_name):
logger.error(f"Error serving segment: {e}")
return JsonResponse({'error': str(e)}, status=500)
@csrf_exempt
@require_http_methods(["POST"])
@api_view(["POST"])
@permission_classes([IsAdmin])
def change_stream(request, channel_id):
"""Change stream URL for existing channel"""
try:
if channel_id not in proxy_server.stream_managers:
return JsonResponse({'error': 'Channel not found'}, status=404)
data = json.loads(request.body)
new_url = data.get('url')
if not new_url:
return JsonResponse({'error': 'No URL provided'}, status=400)
manager = proxy_server.stream_managers[channel_id]
if manager.update_url(new_url):
return JsonResponse({
@ -64,7 +66,7 @@ def change_stream(request, channel_id):
'channel': channel_id,
'url': new_url
})
return JsonResponse({
'message': 'URL unchanged',
'channel': channel_id,
@ -85,7 +87,7 @@ def initialize_stream(request, channel_id):
url = data.get('url')
if not url:
return JsonResponse({'error': 'No URL provided'}, status=400)
proxy_server.initialize_channel(url, channel_id)
return JsonResponse({
'message': 'Stream initialized',

View file

@ -1,16 +1,11 @@
# yourapp/tasks.py
from celery import shared_task
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
import redis
import json
import logging
import re
import gc # Add import for garbage collection
import gc
from core.utils import RedisClient
from apps.proxy.ts_proxy.channel_status import ChannelStatus
from core.utils import send_websocket_update
from apps.proxy.vod_proxy.connection_manager import get_connection_manager
logger = logging.getLogger(__name__)
@ -31,7 +26,7 @@ def fetch_channel_stats():
while True:
cursor, keys = redis_client.scan(cursor, match=channel_pattern)
for key in keys:
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key.decode('utf-8'))
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key)
if channel_id_match:
ch_id = channel_id_match.group(1)
channel_info = ChannelStatus.get_basic_channel_info(ch_id)
@ -61,12 +56,4 @@ def fetch_channel_stats():
all_channels = None
gc.collect()
@shared_task
def cleanup_vod_connections():
"""Clean up stale VOD connections"""
try:
connection_manager = get_connection_manager()
connection_manager.cleanup_stale_connections(max_age_seconds=3600) # 1 hour
logger.info("VOD connection cleanup completed")
except Exception as e:
logger.error(f"Error in VOD connection cleanup: {e}", exc_info=True)

View file

@ -6,6 +6,7 @@ from .redis_keys import RedisKeys
from .constants import TS_PACKET_SIZE, ChannelMetadataField
from redis.exceptions import ConnectionError, TimeoutError
from .utils import get_logger
from .client_manager import ClientManager
from django.db import DatabaseError # Add import for error handling
logger = get_logger()
@ -38,19 +39,19 @@ class ChannelStatus:
info = {
'channel_id': channel_id,
'state': metadata.get(ChannelMetadataField.STATE.encode('utf-8'), b'unknown').decode('utf-8'),
'url': metadata.get(ChannelMetadataField.URL.encode('utf-8'), b'').decode('utf-8'),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8'), b'').decode('utf-8'),
'started_at': metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0').decode('utf-8'),
'owner': metadata.get(ChannelMetadataField.OWNER.encode('utf-8'), b'unknown').decode('utf-8'),
'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0,
'state': metadata.get(ChannelMetadataField.STATE, 'unknown'),
'url': metadata.get(ChannelMetadataField.URL, ''),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ''),
'started_at': metadata.get(ChannelMetadataField.INIT_TIME, '0'),
'owner': metadata.get(ChannelMetadataField.OWNER, 'unknown'),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
}
# Add stream ID and name information
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID.encode('utf-8'))
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes.decode('utf-8'))
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
@ -65,10 +66,10 @@ class ChannelStatus:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
# Add M3U profile information
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE.encode('utf-8'))
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id_bytes:
try:
m3u_profile_id = int(m3u_profile_id_bytes.decode('utf-8'))
m3u_profile_id = int(m3u_profile_id_bytes)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
@ -83,22 +84,22 @@ class ChannelStatus:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id_bytes}")
# Add timing information
state_changed_field = ChannelMetadataField.STATE_CHANGED_AT.encode('utf-8')
state_changed_field = ChannelMetadataField.STATE_CHANGED_AT
if state_changed_field in metadata:
state_changed_at = float(metadata[state_changed_field].decode('utf-8'))
state_changed_at = float(metadata[state_changed_field])
info['state_changed_at'] = state_changed_at
info['state_duration'] = time.time() - state_changed_at
init_time_field = ChannelMetadataField.INIT_TIME.encode('utf-8')
init_time_field = ChannelMetadataField.INIT_TIME
if init_time_field in metadata:
created_at = float(metadata[init_time_field].decode('utf-8'))
created_at = float(metadata[init_time_field])
info['started_at'] = created_at
info['uptime'] = time.time() - created_at
# Add data throughput information
total_bytes_field = ChannelMetadataField.TOTAL_BYTES.encode('utf-8')
total_bytes_field = ChannelMetadataField.TOTAL_BYTES
if total_bytes_field in metadata:
total_bytes = int(metadata[total_bytes_field].decode('utf-8'))
total_bytes = int(metadata[total_bytes_field])
info['total_bytes'] = total_bytes
# Format total bytes in human-readable form
@ -127,43 +128,58 @@ class ChannelStatus:
client_ids = proxy_server.redis_client.smembers(client_set_key)
clients = []
stale_client_ids = []
for client_id in client_ids:
client_id_str = client_id.decode('utf-8')
client_id_str = client_id
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
client_data = proxy_server.redis_client.hgetall(client_key)
if client_data:
client_info = {
'client_id': client_id_str,
'user_agent': client_data.get(b'user_agent', b'unknown').decode('utf-8'),
'worker_id': client_data.get(b'worker_id', b'unknown').decode('utf-8'),
}
if not client_data:
# Metadata hash expired but SET entry persists (ghost client).
stale_client_ids.append(client_id)
continue
if b'connected_at' in client_data:
connected_at = float(client_data[b'connected_at'].decode('utf-8'))
client_info['connected_at'] = connected_at
client_info['connection_duration'] = time.time() - connected_at
client_info = {
'client_id': client_id_str,
'user_agent': client_data.get('user_agent', 'unknown'),
'worker_id': client_data.get('worker_id', 'unknown'),
'ip_address': client_data.get('ip_address', 'unknown'),
'user_id': client_data.get('user_id', '0'),
}
if b'last_active' in client_data:
last_active = float(client_data[b'last_active'].decode('utf-8'))
client_info['last_active'] = last_active
client_info['last_active_ago'] = time.time() - last_active
if 'connected_at' in client_data:
connected_at = float(client_data['connected_at'])
client_info['connected_at'] = connected_at
client_info['connection_duration'] = time.time() - connected_at
# Add transfer rate statistics
if b'bytes_sent' in client_data:
client_info['bytes_sent'] = int(client_data[b'bytes_sent'].decode('utf-8'))
if 'last_active' in client_data:
last_active = float(client_data['last_active'])
client_info['last_active'] = last_active
client_info['last_active_ago'] = time.time() - last_active
# Add average transfer rate
if b'avg_rate_KBps' in client_data:
client_info['avg_rate_KBps'] = float(client_data[b'avg_rate_KBps'].decode('utf-8'))
elif b'transfer_rate_KBps' in client_data: # For backward compatibility
client_info['avg_rate_KBps'] = float(client_data[b'transfer_rate_KBps'].decode('utf-8'))
# Add transfer rate statistics
if 'bytes_sent' in client_data:
client_info['bytes_sent'] = int(client_data['bytes_sent'])
# Add current transfer rate
if b'current_rate_KBps' in client_data:
client_info['current_rate_KBps'] = float(client_data[b'current_rate_KBps'].decode('utf-8'))
# Add average transfer rate
if 'avg_rate_KBps' in client_data:
client_info['avg_rate_KBps'] = float(client_data['avg_rate_KBps'])
elif 'transfer_rate_KBps' in client_data: # For backward compatibility
client_info['avg_rate_KBps'] = float(client_data['transfer_rate_KBps'])
clients.append(client_info)
# Add current transfer rate
if 'current_rate_KBps' in client_data:
client_info['current_rate_KBps'] = float(client_data['current_rate_KBps'])
clients.append(client_info)
# Clean up stale SET entries so SCARD stays accurate.
if stale_client_ids:
proxy_server.redis_client.srem(client_set_key, *stale_client_ids)
logger.info(
f"Removed {len(stale_client_ids)} ghost client(s) from "
f"channel {channel_id} client set"
)
info['clients'] = clients
info['client_count'] = len(clients)
@ -235,7 +251,7 @@ class ChannelStatus:
while True:
cursor, keys = proxy_server.redis_client.scan(cursor, match=buffer_key_pattern, count=100)
if keys:
all_buffer_keys.extend([k.decode('utf-8') for k in keys])
all_buffer_keys.extend([k for k in keys])
if cursor == 0 or len(all_buffer_keys) >= 20: # Limit to 20 keys
break
@ -265,61 +281,64 @@ class ChannelStatus:
}
# Add FFmpeg stream information
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC.encode('utf-8'))
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC)
if video_codec:
info['video_codec'] = video_codec.decode('utf-8')
info['video_codec'] = video_codec
resolution = metadata.get(ChannelMetadataField.RESOLUTION.encode('utf-8'))
resolution = metadata.get(ChannelMetadataField.RESOLUTION)
if resolution:
info['resolution'] = resolution.decode('utf-8')
info['resolution'] = resolution
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS.encode('utf-8'))
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS)
if source_fps:
info['source_fps'] = float(source_fps.decode('utf-8'))
info['source_fps'] = source_fps
pixel_format = metadata.get(ChannelMetadataField.PIXEL_FORMAT.encode('utf-8'))
pixel_format = metadata.get(ChannelMetadataField.PIXEL_FORMAT)
if pixel_format:
info['pixel_format'] = pixel_format.decode('utf-8')
info['pixel_format'] = pixel_format
source_bitrate = metadata.get(ChannelMetadataField.SOURCE_BITRATE.encode('utf-8'))
source_bitrate = metadata.get(ChannelMetadataField.SOURCE_BITRATE)
if source_bitrate:
info['source_bitrate'] = float(source_bitrate.decode('utf-8'))
info['source_bitrate'] = source_bitrate
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC.encode('utf-8'))
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC)
if audio_codec:
info['audio_codec'] = audio_codec.decode('utf-8')
info['audio_codec'] = audio_codec
sample_rate = metadata.get(ChannelMetadataField.SAMPLE_RATE.encode('utf-8'))
sample_rate = metadata.get(ChannelMetadataField.SAMPLE_RATE)
if sample_rate:
info['sample_rate'] = int(sample_rate.decode('utf-8'))
info['sample_rate'] = sample_rate
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8'))
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS)
if audio_channels:
info['audio_channels'] = audio_channels.decode('utf-8')
info['audio_channels'] = audio_channels
audio_bitrate = metadata.get(ChannelMetadataField.AUDIO_BITRATE.encode('utf-8'))
audio_bitrate = metadata.get(ChannelMetadataField.AUDIO_BITRATE)
if audio_bitrate:
info['audio_bitrate'] = float(audio_bitrate.decode('utf-8'))
info['audio_bitrate'] = audio_bitrate
# Add FFmpeg performance stats
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED.encode('utf-8'))
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED)
if ffmpeg_speed:
info['ffmpeg_speed'] = float(ffmpeg_speed.decode('utf-8'))
info['ffmpeg_speed'] = ffmpeg_speed
ffmpeg_fps = metadata.get(ChannelMetadataField.FFMPEG_FPS.encode('utf-8'))
ffmpeg_fps = metadata.get(ChannelMetadataField.FFMPEG_FPS)
if ffmpeg_fps:
info['ffmpeg_fps'] = float(ffmpeg_fps.decode('utf-8'))
info['ffmpeg_fps'] = ffmpeg_fps
actual_fps = metadata.get(ChannelMetadataField.ACTUAL_FPS.encode('utf-8'))
actual_fps = metadata.get(ChannelMetadataField.ACTUAL_FPS)
if actual_fps:
info['actual_fps'] = float(actual_fps.decode('utf-8'))
info['actual_fps'] = actual_fps
ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE.encode('utf-8'))
ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE)
if ffmpeg_bitrate:
info['ffmpeg_bitrate'] = float(ffmpeg_bitrate.decode('utf-8'))
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8'))
info['ffmpeg_bitrate'] = ffmpeg_bitrate
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE)
if stream_type:
info['stream_type'] = stream_type.decode('utf-8')
info['stream_type'] = stream_type
return info
@ -364,33 +383,27 @@ class ChannelStatus:
client_count = proxy_server.redis_client.scard(client_set_key) or 0
# Calculate uptime
init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0')
created_at = float(init_time_bytes.decode('utf-8'))
init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME, '0')
created_at = float(init_time_bytes)
uptime = time.time() - created_at if created_at > 0 else 0
# Safely decode bytes or use defaults
def safe_decode(bytes_value, default="unknown"):
if bytes_value is None:
return default
return bytes_value.decode('utf-8')
# Simplified info
info = {
'channel_id': channel_id,
'state': safe_decode(metadata.get(ChannelMetadataField.STATE.encode('utf-8'))),
'url': safe_decode(metadata.get(ChannelMetadataField.URL.encode('utf-8')), ""),
'stream_profile': safe_decode(metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8')), ""),
'owner': safe_decode(metadata.get(ChannelMetadataField.OWNER.encode('utf-8'))),
'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0,
'state': metadata.get(ChannelMetadataField.STATE),
'url': metadata.get(ChannelMetadataField.URL, ""),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ""),
'owner': metadata.get(ChannelMetadataField.OWNER),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
'client_count': client_count,
'uptime': uptime
}
# Add stream ID and name information
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID.encode('utf-8'))
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes.decode('utf-8'))
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
@ -405,9 +418,9 @@ class ChannelStatus:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
# Add data throughput information to basic info
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES.encode('utf-8'))
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES)
if total_bytes_bytes:
total_bytes = int(total_bytes_bytes.decode('utf-8'))
total_bytes = int(total_bytes_bytes)
info['total_bytes'] = total_bytes
# Calculate and add bitrate
@ -430,42 +443,53 @@ class ChannelStatus:
clients = []
client_ids = proxy_server.redis_client.smembers(client_set_key)
# Process only if we have clients and keep it limited
if client_ids:
# Get up to 10 clients for the basic view
for client_id in list(client_ids)[:10]:
client_id_str = client_id.decode('utf-8')
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
# Remove ghost SET entries before building the client list.
# Pass the already-fetched client_ids to avoid a redundant SMEMBERS.
stale_client_ids = ClientManager.remove_ghost_clients(
proxy_server.redis_client, channel_id, client_ids=client_ids
)
if stale_client_ids:
client_count = max(0, client_count - len(stale_client_ids))
# Build concise client list (up to 10) from remaining live clients.
if client_ids:
for client_id in list(client_ids)[:10]:
if client_id in stale_client_ids:
continue
client_key = RedisKeys.client_metadata(channel_id, client_id)
# Efficient way - just retrieve the essentials
client_info = {
'client_id': client_id_str,
'client_id': client_id,
}
# Safely get user_agent and ip_address
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
client_info['user_agent'] = safe_decode(user_agent_bytes)
client_info['user_agent'] = user_agent_bytes
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
if ip_address_bytes:
client_info['ip_address'] = safe_decode(ip_address_bytes)
client_info['ip_address'] = ip_address_bytes
# Just get connected_at for client age
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
if connected_at_bytes:
connected_at = float(connected_at_bytes.decode('utf-8'))
connected_at = float(connected_at_bytes)
client_info['connected_since'] = time.time() - connected_at
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
if user_id_bytes:
client_info['user_id'] = user_id_bytes
clients.append(client_info)
# Add clients to info
info['clients'] = clients
info['client_count'] = client_count
# Add M3U profile information
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE.encode('utf-8'))
if m3u_profile_id_bytes:
m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id:
try:
m3u_profile_id = int(m3u_profile_id_bytes.decode('utf-8'))
m3u_profile_id = int(m3u_profile_id)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
@ -477,32 +501,36 @@ class ChannelStatus:
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}")
except ValueError:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id_bytes}")
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}")
# Add stream info to basic info as well
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC.encode('utf-8'))
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC)
if video_codec:
info['video_codec'] = video_codec.decode('utf-8')
info['video_codec'] = video_codec
resolution = metadata.get(ChannelMetadataField.RESOLUTION.encode('utf-8'))
resolution = metadata.get(ChannelMetadataField.RESOLUTION)
if resolution:
info['resolution'] = resolution.decode('utf-8')
info['resolution'] = resolution
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS.encode('utf-8'))
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS)
if source_fps:
info['source_fps'] = float(source_fps.decode('utf-8'))
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED.encode('utf-8'))
info['source_fps'] = float(source_fps)
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED)
if ffmpeg_speed:
info['ffmpeg_speed'] = float(ffmpeg_speed.decode('utf-8'))
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC.encode('utf-8'))
info['ffmpeg_speed'] = float(ffmpeg_speed)
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC)
if audio_codec:
info['audio_codec'] = audio_codec.decode('utf-8')
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8'))
info['audio_codec'] = audio_codec
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS)
if audio_channels:
info['audio_channels'] = audio_channels.decode('utf-8')
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8'))
info['audio_channels'] = audio_channels
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE)
if stream_type:
info['stream_type'] = stream_type.decode('utf-8')
info['stream_type'] = stream_type
return info
except Exception as e:

View file

@ -1,10 +1,8 @@
"""Client connection management for TS streams"""
import threading
import logging
import time
import json
import gevent
from typing import Set, Optional
from apps.proxy.config import TSConfig as Config
from redis.exceptions import ConnectionError, TimeoutError
@ -58,7 +56,8 @@ class ClientManager:
from django.conf import settings
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
redis_client = redis.Redis.from_url(redis_url, decode_responses=True)
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
redis_client = redis.Redis.from_url(redis_url, decode_responses=True, **ssl_params)
all_channels = []
cursor = 0
@ -129,7 +128,7 @@ class ClientManager:
# Check for stale activity using last_active field
last_active = self.redis_client.hget(client_key, "last_active")
if last_active:
last_active_time = float(last_active.decode('utf-8'))
last_active_time = float(last_active)
ghost_timeout = self.heartbeat_interval * getattr(Config, 'GHOST_CLIENT_MULTIPLIER', 5.0)
if current_time - last_active_time > ghost_timeout:
@ -158,9 +157,8 @@ class ClientManager:
if time_since_heartbeat < self.heartbeat_interval * 0.5: # Only heartbeat at half interval minimum
continue
# Only update clients that remain
# Only refresh TTL - do NOT update last_active
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
pipe.hset(client_key, "last_active", str(current_time))
pipe.expire(client_key, self.client_ttl)
# Keep client in the set with TTL
@ -230,7 +228,7 @@ class ClientManager:
except Exception as e:
logger.error(f"Error notifying owner of client activity: {e}")
def add_client(self, client_id, client_ip, user_agent=None):
def add_client(self, client_id, client_ip, user_agent=None, user=None):
"""Add a client with duplicate prevention"""
if client_id in self._registered_clients:
logger.debug(f"Client {client_id} already registered, skipping")
@ -248,7 +246,9 @@ class ClientManager:
"ip_address": client_ip,
"connected_at": current_time,
"last_active": current_time,
"worker_id": self.worker_id or "unknown"
"worker_id": self.worker_id or "unknown",
"user_id": str(user.id) if user is not None else "0",
# "user_level": user.user_level if user is not None else 100, # default to a high value since no user means the non-user specific M3U/HDHR
}
try:
@ -278,7 +278,8 @@ class ClientManager:
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time()
"timestamp": time.time(),
"username": user.username if user is not None else "unknown"
}
if user_agent:
@ -309,8 +310,6 @@ class ClientManager:
def remove_client(self, client_id):
"""Remove a client from this channel and Redis"""
client_ip = None
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
@ -321,13 +320,11 @@ class ClientManager:
self.last_active_time = time.time()
if self.redis_client:
# Get client IP before removing the data
# Get client data before removing the data
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
client_data = self.redis_client.hgetall(client_key)
if client_data and b'ip_address' in client_data:
client_ip = client_data[b'ip_address'].decode('utf-8')
elif client_data and 'ip_address' in client_data:
client_ip = client_data['ip_address']
client_username = self.redis_client.hget(client_key, "username") or "unknown"
if isinstance(client_username, bytes):
client_username = client_username.decode("utf-8")
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
@ -368,7 +365,8 @@ class ClientManager:
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining
"remaining_clients": remaining,
"username": client_username
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
@ -413,3 +411,41 @@ class ClientManager:
self.redis_client.expire(self.client_set_key, self.client_ttl)
except Exception as e:
logger.error(f"Error refreshing client TTL: {e}")
@staticmethod
def remove_ghost_clients(redis_client, channel_id, client_ids=None):
"""Remove client SET entries whose metadata hash has expired.
Returns the list of removed (stale) client IDs, or an empty list
if none were found. Uses a pipelined EXISTS check for efficiency.
Args:
client_ids: Optional pre-fetched result of SMEMBERS for this
channel. Pass this to avoid a redundant SMEMBERS
call when the caller has already fetched it.
"""
client_set_key = RedisKeys.clients(channel_id)
if client_ids is None:
client_ids = redis_client.smembers(client_set_key)
if not client_ids:
return []
client_id_list = list(client_ids)
pipe = redis_client.pipeline()
for cid in client_id_list:
pipe.exists(RedisKeys.client_metadata(channel_id, cid))
results = pipe.execute()
stale_ids = [
cid for cid, exists in zip(client_id_list, results)
if not exists
]
if stale_ids:
redis_client.srem(client_set_key, *stale_ids)
logger.info(
f"Removed {len(stale_ids)} ghost client(s) from "
f"channel {channel_id} client set"
)
return stale_ids

View file

@ -20,6 +20,10 @@ class ChannelState:
STOPPED = "stopped"
BUFFERING = "buffering"
# States before a channel is fully active. Used by the stream manager
# finally block to decide whether a failed stream can write ERROR.
PRE_ACTIVE = frozenset([INITIALIZING, CONNECTING, BUFFERING, WAITING_FOR_CLIENTS])
# Event types
class EventType:
STREAM_SWITCH = "stream_switch"
@ -71,6 +75,7 @@ class ChannelMetadataField:
FFMPEG_FPS = "ffmpeg_fps"
ACTUAL_FPS = "actual_fps"
FFMPEG_OUTPUT_BITRATE = "ffmpeg_output_bitrate"
FFMPEG_BITRATE = "ffmpeg_bitrate"
FFMPEG_STATS_UPDATED = "ffmpeg_stats_updated"
# Video stream info
@ -81,6 +86,7 @@ class ChannelMetadataField:
SOURCE_FPS = "source_fps"
PIXEL_FORMAT = "pixel_format"
VIDEO_BITRATE = "video_bitrate"
SOURCE_BITRATE = "source_bitrate"
# Audio stream info
AUDIO_CODEC = "audio_codec"

View file

@ -166,6 +166,7 @@ class ProxyServer:
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
pubsub_client = redis.Redis(
host=redis_host,
port=redis_port,
@ -175,7 +176,9 @@ class ProxyServer:
socket_timeout=60,
socket_connect_timeout=10,
socket_keepalive=True,
health_check_interval=30
health_check_interval=30,
decode_responses=True,
**ssl_params
)
logger.info("Created fallback Redis PubSub client for event listener")
@ -196,8 +199,8 @@ class ProxyServer:
continue
try:
channel = message["channel"].decode("utf-8")
data = json.loads(message["data"].decode("utf-8"))
channel = message["channel"]
data = json.loads(message["data"])
event_type = data.get("event")
channel_id = data.get("channel_id")
@ -224,26 +227,29 @@ class ProxyServer:
# Handle stream switch request
new_url = data.get("url")
user_agent = data.get("user_agent")
event_stream_id = data.get("stream_id")
event_m3u_profile_id = data.get("m3u_profile_id")
if new_url and channel_id in self.stream_managers:
# Update metadata in Redis
# Mark the switch as in-progress in Redis so other workers know to wait
if self.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
self.redis_client.hset(metadata_key, "url", new_url)
if user_agent:
self.redis_client.hset(metadata_key, "user_agent", user_agent)
# Set switch status
status_key = RedisKeys.switch_status(channel_id)
self.redis_client.set(status_key, "switching")
# Perform the stream switch
# Perform the stream switch, forwarding stream_id and m3u_profile_id
stream_manager = self.stream_managers[channel_id]
success = stream_manager.update_url(new_url)
success = stream_manager.update_url(new_url, event_stream_id, event_m3u_profile_id)
if success:
logger.info(f"Stream switch initiated for channel {channel_id}")
# Confirm the URL in metadata now that the switch happened
if self.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
self.redis_client.hset(metadata_key, "url", new_url)
if user_agent:
self.redis_client.hset(metadata_key, "user_agent", user_agent)
# Publish confirmation
switch_result = {
"event": EventType.STREAM_SWITCHED, # Use constant instead of string
@ -263,6 +269,14 @@ class ProxyServer:
else:
logger.error(f"Failed to switch stream for channel {channel_id}")
# Roll back the URL in metadata to what the manager will
# actually reconnect to. The non-owner may have pre-written
# the desired URL; use stream_manager.url (the ground truth)
# so Redis is consistent with the live stream.
if self.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
self.redis_client.hset(metadata_key, "url", stream_manager.url)
# Publish failure
switch_result = {
"event": EventType.STREAM_SWITCHED,
@ -373,7 +387,7 @@ class ProxyServer:
if result is None:
return None
try:
return result.decode('utf-8')
return result
except (AttributeError, UnicodeDecodeError) as e:
logger.error(f"Error decoding channel owner for {channel_id}: {e}, raw={result!r}")
return None
@ -412,7 +426,7 @@ class ProxyServer:
current_owner = self._execute_redis_command(
lambda: self.redis_client.get(lock_key)
)
if current_owner and current_owner.decode('utf-8') == self.worker_id:
if current_owner and current_owner == self.worker_id:
# Refresh TTL
self._execute_redis_command(
lambda: self.redis_client.expire(lock_key, ttl)
@ -437,7 +451,7 @@ class ProxyServer:
# Only delete if we're the current owner to prevent race conditions
current = self.redis_client.get(lock_key)
if current and current.decode('utf-8') == self.worker_id:
if current and current == self.worker_id:
self.redis_client.delete(lock_key)
logger.info(f"Released ownership of channel {channel_id}")
@ -471,7 +485,7 @@ class ProxyServer:
return False
return False
if current.decode('utf-8') == self.worker_id:
if current == self.worker_id:
self.redis_client.expire(lock_key, ttl)
return True
@ -488,15 +502,15 @@ class ProxyServer:
metadata_key = RedisKeys.channel_metadata(channel_id)
if self.redis_client.exists(metadata_key):
metadata = self.redis_client.hgetall(metadata_key)
if b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if 'state' in metadata:
state = metadata['state']
active_states = [ChannelState.INITIALIZING, ChannelState.CONNECTING,
ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE, ChannelState.BUFFERING]
if state in active_states:
logger.info(f"Channel {channel_id} already being initialized with state {state}")
# Create buffer and client manager only if we don't have them
if channel_id not in self.stream_buffers:
self.stream_buffers[channel_id] = StreamBuffer(channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = StreamBuffer(channel_id, redis_client=RedisClient.get_buffer())
if channel_id not in self.client_managers:
self.client_managers[channel_id] = ClientManager(
channel_id,
@ -507,7 +521,7 @@ class ProxyServer:
# Create buffer and client manager instances (or reuse if they exist)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id, redis_client=RedisClient.get_buffer())
self.stream_buffers[channel_id] = buffer
if channel_id not in self.client_managers:
@ -546,18 +560,18 @@ class ProxyServer:
# If no url was passed, try to get from Redis
if not url and existing_metadata:
url_bytes = existing_metadata.get(b'url')
url_bytes = existing_metadata.get('url')
if url_bytes:
channel_url = url_bytes.decode('utf-8')
channel_url = url_bytes
ua_bytes = existing_metadata.get(b'user_agent')
ua_bytes = existing_metadata.get('user_agent')
if ua_bytes:
channel_user_agent = ua_bytes.decode('utf-8')
channel_user_agent = ua_bytes
# Get stream ID from metadata if not provided
if not channel_stream_id and b'stream_id' in existing_metadata:
if not channel_stream_id and 'stream_id' in existing_metadata:
try:
channel_stream_id = int(existing_metadata[b'stream_id'].decode('utf-8'))
channel_stream_id = int(existing_metadata['stream_id'])
logger.debug(f"Found stream_id {channel_stream_id} in metadata for channel {channel_id}")
except (ValueError, TypeError) as e:
logger.debug(f"Could not parse stream_id from metadata: {e}")
@ -572,7 +586,7 @@ class ProxyServer:
# Create buffer but not stream manager (only if not already exists)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer())
self.stream_buffers[channel_id] = buffer
# Create client manager with channel_id and redis_client (only if not already exists)
@ -595,7 +609,7 @@ class ProxyServer:
# Create buffer but not stream manager (only if not already exists)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer())
self.stream_buffers[channel_id] = buffer
# Create client manager with channel_id and redis_client (only if not already exists)
@ -634,12 +648,12 @@ class ProxyServer:
# Verify the stream_id was set correctly in Redis
stream_id_value = self.redis_client.hget(metadata_key, "stream_id")
if stream_id_value:
logger.info(f"Verified stream_id {stream_id_value.decode('utf-8')} is set in Redis for channel {channel_id}")
logger.info(f"Verified stream_id {stream_id_value} is set in Redis for channel {channel_id}")
else:
logger.warning(f"Failed to set stream_id in Redis for channel {channel_id}")
# Create stream buffer
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer())
logger.debug(f"Created StreamBuffer for channel {channel_id}")
self.stream_buffers[channel_id] = buffer
@ -733,8 +747,8 @@ class ProxyServer:
metadata = self.redis_client.hgetall(metadata_key)
# Get channel state and owner
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'').decode('utf-8')
state = metadata.get('state', 'unknown')
owner = metadata.get('owner', '')
# States that indicate the channel is running properly or shutting down
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS,
@ -772,8 +786,8 @@ class ProxyServer:
return False
else:
# Unknown or initializing state, check how long it's been in this state
if b'state_changed_at' in metadata:
state_changed_at = float(metadata[b'state_changed_at'].decode('utf-8'))
if 'state_changed_at' in metadata:
state_changed_at = float(metadata['state_changed_at'])
state_age = time.time() - state_changed_at
# If in initializing state for too long, consider it stale
@ -811,8 +825,8 @@ class ProxyServer:
# If we have metadata, log details for debugging
if metadata:
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'unknown').decode('utf-8')
state = metadata.get('state', 'unknown')
owner = metadata.get('owner', 'unknown')
logger.info(f"Zombie channel details - state: {state}, owner: {owner}")
# Clean up Redis keys
@ -937,16 +951,16 @@ class ProxyServer:
metadata = self.redis_client.hgetall(metadata_key)
if metadata:
# Calculate runtime from init_time
if b'init_time' in metadata:
if 'init_time' in metadata:
try:
init_time = float(metadata[b'init_time'].decode('utf-8'))
init_time = float(metadata['init_time'])
runtime = round(time.time() - init_time, 2)
except Exception:
pass
# Get total bytes transferred
if b'total_bytes' in metadata:
if 'total_bytes' in metadata:
try:
total_bytes = int(metadata[b'total_bytes'].decode('utf-8'))
total_bytes = int(metadata['total_bytes'])
except Exception:
pass
@ -1057,8 +1071,8 @@ class ProxyServer:
if self.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = self.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
channel_state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
channel_state = metadata['state']
# Check if channel has any clients left
total_clients = 0
@ -1080,7 +1094,7 @@ class ProxyServer:
logger.info(f"Channel {channel_id} has {total_clients} clients, state: {channel_state}")
# If in connecting or waiting_for_clients state, check grace period
if channel_state in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
if channel_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
# Check if channel is already stopping
if self.redis_client:
stop_key = RedisKeys.channel_stopping(channel_id)
@ -1090,9 +1104,9 @@ class ProxyServer:
# Get connection_ready_time from metadata (indicates if channel reached ready state)
connection_ready_time = None
if metadata and b'connection_ready_time' in metadata:
if metadata and 'connection_ready_time' in metadata:
try:
connection_ready_time = float(metadata[b'connection_ready_time'].decode('utf-8'))
connection_ready_time = float(metadata['connection_ready_time'])
except (ValueError, TypeError):
pass
@ -1104,15 +1118,15 @@ class ProxyServer:
attempt_value = self.redis_client.get(attempt_key)
if attempt_value:
try:
connection_attempt_time = float(attempt_value.decode('utf-8'))
connection_attempt_time = float(attempt_value)
except (ValueError, TypeError):
pass
# Also get init time as a fallback
init_time = None
if metadata and b'init_time' in metadata:
if metadata and 'init_time' in metadata:
try:
init_time = float(metadata[b'init_time'].decode('utf-8'))
init_time = float(metadata['init_time'])
except (ValueError, TypeError):
pass
@ -1183,7 +1197,7 @@ class ProxyServer:
disconnect_value = self.redis_client.get(disconnect_key)
if disconnect_value:
try:
disconnect_time = float(disconnect_value.decode('utf-8'))
disconnect_time = float(disconnect_value)
except (ValueError, TypeError) as e:
logger.error(f"Invalid disconnect time for channel {channel_id}: {e}")
@ -1304,7 +1318,7 @@ class ProxyServer:
for key in channel_keys:
try:
channel_id = key.decode('utf-8').split(':')[2]
channel_id = key.split(':')[2]
# Check if this channel has an owner
owner = self.get_channel_owner(channel_id)
@ -1349,7 +1363,7 @@ class ProxyServer:
for key in channel_keys:
try:
channel_id = key.decode('utf-8').split(':')[2]
channel_id = key.split(':')[2]
# Get metadata first
metadata = self.redis_client.hgetall(key)
@ -1364,7 +1378,7 @@ class ProxyServer:
continue
# Get owner
owner = metadata.get(b'owner', b'').decode('utf-8') if b'owner' in metadata else ''
owner = metadata.get('owner', '') if 'owner' in metadata else ''
# Check if owner is still alive
owner_alive = False
@ -1378,7 +1392,7 @@ class ProxyServer:
# If no owner and no clients, clean it up
if not owner_alive and client_count == 0:
state = metadata.get(b'state', b'unknown').decode('utf-8') if b'state' in metadata else 'unknown'
state = metadata.get('state', 'unknown')
logger.warning(f"Found orphaned metadata for channel {channel_id} (state: {state}, owner: {owner}, clients: {client_count}) - cleaning up")
# If we have it locally, stop it properly to clean up transcode/proxy processes
@ -1389,8 +1403,30 @@ class ProxyServer:
# Just clean up Redis keys for remote channels
self._clean_redis_keys(channel_id)
elif not owner_alive and client_count > 0:
# Owner is gone but clients remain - just log for now
logger.warning(f"Found orphaned channel {channel_id} with {client_count} clients but no owner - may need ownership takeover")
# SCARD may include ghost entries from a dead worker's
# expired metadata hashes. Validate before deciding.
stale_ids = ClientManager.remove_ghost_clients(
self.redis_client, channel_id
)
real_count = max(0, client_count - len(stale_ids))
if real_count <= 0:
# No real clients remain — safe to clean up.
state = metadata.get('state', 'unknown')
logger.warning(
f"Orphaned channel {channel_id} (state: {state}, "
f"owner: {owner}) had {client_count} ghost client(s) "
f"- cleaning up"
)
if channel_id in self.stream_managers or channel_id in self.client_managers:
self.stop_channel(channel_id)
else:
self._clean_redis_keys(channel_id)
else:
logger.warning(
f"Orphaned channel {channel_id} still has "
f"{real_count} live client(s) after ghost removal "
f"- may need ownership takeover"
)
except Exception as e:
logger.error(f"Error processing metadata key {key}: {e}", exc_info=True)
@ -1470,8 +1506,8 @@ class ProxyServer:
# Get current state for logging
current_state = None
metadata = self.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
current_state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
current_state = metadata['state']
# Only update if state is actually changing
if current_state == new_state:

View file

@ -61,7 +61,7 @@ class ChannelService:
# Verify the stream_id was set
stream_id_value = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_value:
logger.debug(f"Verified stream_id {stream_id_value.decode('utf-8')} is now set in Redis")
logger.debug(f"Verified stream_id {stream_id_value} is now set in Redis")
else:
logger.error(f"Failed to set stream_id {stream_id} in Redis before initialization")
@ -131,7 +131,7 @@ class ChannelService:
try:
# This is inefficient but used for diagnostics - in production would use more targeted checks
redis_keys = proxy_server.redis_client.keys(f"ts_proxy:*:{channel_id}*")
redis_keys = [k.decode('utf-8') for k in redis_keys] if redis_keys else []
redis_keys = [k for k in redis_keys] if redis_keys else []
except Exception as e:
logger.error(f"Error checking Redis keys: {e}")
@ -168,15 +168,6 @@ class ChannelService:
else:
result = {'status': 'success'}
# Update metadata in Redis regardless of ownership
if proxy_server.redis_client:
try:
ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
result['metadata_updated'] = True
except Exception as e:
logger.error(f"Error updating Redis metadata: {e}", exc_info=True)
result['metadata_updated'] = False
# If we're the owner, update directly
if proxy_server.am_i_owner(channel_id) and channel_id in proxy_server.stream_managers:
logger.info(f"This worker is the owner, changing stream URL for channel {channel_id}")
@ -187,14 +178,33 @@ class ChannelService:
success = manager.update_url(new_url, stream_id, m3u_profile_id)
logger.info(f"Stream URL changed from {old_url} to {new_url}, result: {success}")
# Update Redis metadata based on the actual outcome.
# On success, write the new values. On failure, restore whatever URL
# the manager will actually reconnect to (may be old_url if the
# exception happened before self.url was reassigned, or new_url if it
# happened after) so Redis never describes a URL that isn't in use.
if proxy_server.redis_client:
try:
if success:
ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
else:
ChannelService._update_channel_metadata(channel_id, manager.url, user_agent)
result['metadata_updated'] = True
except Exception as e:
logger.error(f"Error updating Redis metadata: {e}", exc_info=True)
result['metadata_updated'] = False
result.update({
'direct_update': True,
'success': success,
'worker_id': proxy_server.worker_id
})
else:
# If we're not the owner, publish an event for the owner to pick up
logger.info(f"Not the owner, requesting URL change via Redis PubSub")
# Not the owner: publish the switch event. The owner will update metadata
# after the actual switch attempt succeeds (or roll back on failure).
# All needed info (url, user_agent, stream_id, m3u_profile_id) is carried
# in the pubsub message, so there is no reason to pre-write metadata here.
logger.debug(f"This worker is not the owner, publishing stream switch event for channel {channel_id}")
if proxy_server.redis_client:
ChannelService._publish_stream_switch_event(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
result.update({
@ -236,8 +246,8 @@ class ChannelService:
metadata_key = RedisKeys.channel_metadata(channel_id)
try:
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
state = metadata['state']
channel_info = {"state": state}
# Immediately mark as stopping in metadata so clients detect it faster
@ -382,8 +392,8 @@ class ChannelService:
metadata = proxy_server.redis_client.hgetall(metadata_key)
# Extract state and owner
state = metadata.get(ChannelMetadataField.STATE.encode(), b'unknown').decode('utf-8')
owner = metadata.get(ChannelMetadataField.OWNER.encode(), b'unknown').decode('utf-8')
state = metadata.get(ChannelMetadataField.STATE, 'unknown')
owner = metadata.get(ChannelMetadataField.OWNER, 'unknown')
# Valid states indicate channel is running properly
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING]
@ -409,7 +419,7 @@ class ChannelService:
}
if last_data:
last_data_time = float(last_data.decode('utf-8'))
last_data_time = float(last_data)
data_age = time.time() - last_data_time
details["last_data_age"] = data_age
@ -432,13 +442,13 @@ class ChannelService:
try:
# Use factory to parse the line based on stream type
parsed_data = LogParserFactory.parse(stream_type, stream_info_line)
if not parsed_data:
return
# Update Redis and database with parsed data
ChannelService._update_stream_info_in_redis(
channel_id,
channel_id,
parsed_data.get('video_codec'),
parsed_data.get('resolution'),
parsed_data.get('width'),
@ -579,7 +589,7 @@ class ChannelService:
metadata_key = RedisKeys.channel_metadata(channel_id)
# First check if the key exists and what type it is
key_type = proxy_server.redis_client.type(metadata_key).decode('utf-8')
key_type = proxy_server.redis_client.type(metadata_key)
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# Build metadata update dict

View file

@ -76,27 +76,28 @@ class StreamBuffer:
if not hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
# Combine with any previous partial packet
combined_data = bytearray(self._partial_packet) + bytearray(chunk)
# Calculate complete packets
complete_packets_size = (len(combined_data) // self.TS_PACKET_SIZE) * self.TS_PACKET_SIZE
if complete_packets_size == 0:
# Not enough data for a complete packet
self._partial_packet = combined_data
return True
# Split into complete packets and remainder
complete_packets = combined_data[:complete_packets_size]
self._partial_packet = combined_data[complete_packets_size:]
# Add completed packets to write buffer
self._write_buffer.extend(complete_packets)
# Only write to Redis when we have enough data for an optimized chunk
# Lock the full operation to prevent race with reset_buffer_position
writes_done = 0
with self.lock:
# Combine with any previous partial packet
combined_data = bytearray(self._partial_packet) + bytearray(chunk)
# Calculate complete packets
complete_packets_size = (len(combined_data) // self.TS_PACKET_SIZE) * self.TS_PACKET_SIZE
if complete_packets_size == 0:
# Not enough data for a complete packet
self._partial_packet = combined_data
return True
# Split into complete packets and remainder
complete_packets = combined_data[:complete_packets_size]
self._partial_packet = combined_data[complete_packets_size:]
# Add completed packets to write buffer
self._write_buffer.extend(complete_packets)
# Only write to Redis when we have enough data for an optimized chunk
while len(self._write_buffer) >= self.target_chunk_size:
# Extract a full chunk
chunk_data = self._write_buffer[:self.target_chunk_size]
@ -132,6 +133,40 @@ class StreamBuffer:
logger.error(f"Error adding chunk to buffer: {e}")
return False
def reset_buffer_position(self):
"""
Reset internal buffers for a clean stream transition (failover).
Called by stream_manager.update_url() when switching between FFmpeg
processes. Without this, _partial_packet from the old FFmpeg gets
concatenated with the first bytes from the new FFmpeg, creating
corrupted TS packets that break audio decoder sync in the client.
"""
try:
with self.lock:
old_write_size = len(self._write_buffer)
old_partial_size = len(getattr(self, '_partial_packet', b''))
self._write_buffer = bytearray()
if hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
if old_write_size > 0 or old_partial_size > 0:
logger.info(
f"Reset buffer position for channel {self.channel_id}: "
f"cleared {old_write_size} bytes from write buffer, "
f"{old_partial_size} bytes from partial packet"
)
else:
logger.debug(
f"Reset buffer position for channel {self.channel_id}: "
f"buffers were already clean"
)
except Exception as e:
logger.error(
f"Error resetting buffer position for channel {self.channel_id}: {e}"
)
def get_chunks(self, start_index=None):
"""Get chunks from the buffer with detailed logging"""
try:

View file

@ -25,7 +25,7 @@ class StreamGenerator:
data delivery, and cleanup.
"""
def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None):
"""
Initialize the stream generator with client and channel details.
@ -35,12 +35,14 @@ class StreamGenerator:
client_ip: Client's IP address
client_user_agent: User agent string from client
channel_initializing: Whether the channel is still initializing
user: Authenticated user making the request
"""
self.channel_id = channel_id
self.client_id = client_id
self.client_ip = client_ip
self.client_user_agent = client_user_agent
self.channel_initializing = channel_initializing
self.user = user
# Performance and state tracking
self.stream_start_time = time.time()
@ -112,7 +114,8 @@ class StreamGenerator:
channel_name=channel_obj.name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client connect event: {e}")
@ -141,13 +144,13 @@ class StreamGenerator:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
state = metadata['state']
if state in ['waiting_for_clients', 'active']:
logger.info(f"[{self.client_id}] Channel {self.channel_id} now ready (state={state})")
return True
elif state in ['error', 'stopped', 'stopping']: # Added 'stopping' to error states
error_message = metadata.get(b'error_message', b'Unknown error').decode('utf-8')
error_message = metadata.get('error_message', 'Unknown error')
logger.error(f"[{self.client_id}] Channel {self.channel_id} in error state: {state}, message: {error_message}")
# Send error packet before giving up
yield create_ts_packet('error', f"Error: {error_message}")
@ -155,9 +158,9 @@ class StreamGenerator:
else:
# Improved logging to track initialization progress
init_time = "unknown"
if b'init_time' in metadata:
if 'init_time' in metadata:
try:
init_time_float = float(metadata[b'init_time'].decode('utf-8'))
init_time_float = float(metadata['init_time'])
init_duration = time.time() - init_time_float
init_time = f"{init_duration:.1f}s ago"
except:
@ -255,6 +258,10 @@ class StreamGenerator:
def _stream_data_generator(self):
"""Generate stream data chunks based on buffer contents."""
# Keepalive packets refresh last_yield_time, so _is_timeout() never fires
# during sustained stream failure. This timer enforces a wall-clock cap.
keepalive_start_time = None
# Main streaming loop
while True:
# Check if resources still exist
@ -265,6 +272,7 @@ class StreamGenerator:
chunks, next_index = self.buffer.get_optimized_client_data(self.local_index)
if chunks:
keepalive_start_time = None # Each recovery restarts the cap independently.
yield from self._process_chunks(chunks, next_index)
self.local_index = next_index
self.last_yield_time = time.time()
@ -306,12 +314,28 @@ class StreamGenerator:
continue # Retry immediately with the new position
if self._should_send_keepalive(self.local_index):
if keepalive_start_time is None:
keepalive_start_time = time.time()
max_keepalive = getattr(Config, 'MAX_KEEPALIVE_DURATION', 300)
if time.time() - keepalive_start_time > max_keepalive:
logger.warning(
f"[{self.client_id}] Keepalive duration exceeded {max_keepalive}s "
f"with no stream recovery, disconnecting"
)
break
keepalive_packet = create_ts_packet('keepalive')
logger.debug(f"[{self.client_id}] Sending keepalive packet while waiting at buffer head")
yield keepalive_packet
self.bytes_sent += len(keepalive_packet)
self.last_yield_time = time.time()
self.consecutive_empty = 0 # Reset consecutive counter but keep total empty_reads
# Update last_active so clients waiting during failover aren't flagged as ghosts
proxy_server = ProxyServer.get_instance()
if proxy_server and proxy_server.redis_client:
client_key = RedisKeys.client_metadata(self.channel_id, self.client_id)
proxy_server.redis_client.hset(client_key, "last_active", str(time.time()))
gevent.sleep(Config.KEEPALIVE_INTERVAL) # Replace time.sleep
else:
# Standard wait with backoff
@ -369,8 +393,8 @@ class StreamGenerator:
# Channel state in metadata
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
state = metadata['state']
if state in ['error', 'stopped', 'stopping']:
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
return False
@ -428,7 +452,8 @@ class StreamGenerator:
ChannelMetadataField.BYTES_SENT: str(self.bytes_sent),
ChannelMetadataField.AVG_RATE_KBPS: str(round(avg_rate, 1)),
ChannelMetadataField.CURRENT_RATE_KBPS: str(round(self.current_rate, 1)),
ChannelMetadataField.STATS_UPDATED_AT: str(current_time)
ChannelMetadataField.STATS_UPDATED_AT: str(current_time),
"last_active": str(current_time)
}
proxy_server.redis_client.hset(client_key, mapping=stats)
@ -533,8 +558,6 @@ class StreamGenerator:
if metadata:
stream_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
stream_id = int(stream_id_bytes.decode('utf-8'))
# Check if we're the last client
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
@ -573,7 +596,8 @@ class StreamGenerator:
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
duration=round(elapsed, 2),
bytes_sent=self.bytes_sent
bytes_sent=self.bytes_sent,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
@ -608,10 +632,10 @@ class StreamGenerator:
gevent.spawn(delayed_shutdown)
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None):
"""
Factory function to create a new stream generator.
Returns a function that can be passed to StreamingHttpResponse.
"""
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing)
return generator.generate
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user)
return generator.generate

View file

@ -90,7 +90,7 @@ class StreamManager:
# Try to get stream_id specifically
stream_id_bytes = buffer.redis_client.hget(metadata_key, "stream_id")
if stream_id_bytes:
self.current_stream_id = int(stream_id_bytes.decode('utf-8'))
self.current_stream_id = int(stream_id_bytes)
self.tried_stream_ids.add(self.current_stream_id)
logger.info(f"Loaded stream ID {self.current_stream_id} from Redis for channel {buffer.channel_id}")
else:
@ -401,24 +401,44 @@ class StreamManager:
# Close all connections
self._close_all_connections()
# Update channel state in Redis to prevent clients from waiting indefinitely
# Transition to ERROR so clients stop waiting. Ownership may have
# expired during retries, so fall back to a state guard when no
# owner exists — but never clobber a new owner's active stream.
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
try:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
# Check if we're the owner before updating state
owner_key = RedisKeys.channel_owner(self.channel_id)
current_owner = self.buffer.redis_client.get(owner_key)
# Use the worker_id that was passed in during initialization
if current_owner and self.worker_id and current_owner.decode('utf-8') == self.worker_id:
# Determine the appropriate error message based on retry failures
is_owner = (
current_owner
and self.worker_id
and current_owner == self.worker_id
)
no_owner = current_owner is None
should_update = is_owner
if not should_update and no_owner:
current_state_bytes = self.buffer.redis_client.hget(
metadata_key, ChannelMetadataField.STATE
)
current_state = (
current_state_bytes
if current_state_bytes else None
)
should_update = current_state in ChannelState.PRE_ACTIVE
if not should_update and current_state:
logger.info(
f"Channel {self.channel_id} has no owner but "
f"state is {current_state} — skipping ERROR update"
)
if should_update:
if self.tried_stream_ids and len(self.tried_stream_ids) > 0:
error_message = f"All {len(self.tried_stream_ids)} stream options failed"
else:
error_message = f"Connection failed after {self.max_retries} attempts"
# Update metadata to indicate error state
update_data = {
ChannelMetadataField.STATE: ChannelState.ERROR,
ChannelMetadataField.STATE_CHANGED_AT: str(time.time()),
@ -426,9 +446,13 @@ class StreamManager:
ChannelMetadataField.ERROR_TIME: str(time.time())
}
self.buffer.redis_client.hset(metadata_key, mapping=update_data)
logger.info(f"Updated channel {self.channel_id} state to ERROR in Redis after stream failure")
logger.info(
f"Updated channel {self.channel_id} state to ERROR "
f"in Redis after stream failure "
f"(owner={'self' if is_owner else 'expired'})"
)
# Also set stopping key to ensure clients disconnect
# Signal clients to disconnect
stop_key = RedisKeys.channel_stopping(self.channel_id)
self.buffer.redis_client.setex(stop_key, 60, "true")
except Exception as e:
@ -1479,9 +1503,9 @@ class StreamManager:
current_state = None
try:
metadata = redis_client.hgetall(metadata_key)
state_field = ChannelMetadataField.STATE.encode('utf-8')
state_field = ChannelMetadataField.STATE
if metadata and state_field in metadata:
current_state = metadata[state_field].decode('utf-8')
current_state = metadata[state_field]
except Exception as e:
logger.error(f"Error checking current state: {e}")
@ -1686,4 +1710,4 @@ class StreamManager:
"""Safely reset the URL switching state if it gets stuck"""
self.url_switching = False
self.url_switch_start_time = 0
logger.info(f"Reset URL switching state for channel {self.channel_id}")
logger.info(f"Reset URL switching state for channel {self.channel_id}")

View file

@ -3,7 +3,7 @@ Utilities for handling stream URLs and transformations.
"""
import logging
import re
import regex
from typing import Optional, Tuple, List
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel, Stream
@ -146,13 +146,14 @@ def transform_url(input_url: str, search_pattern: str, replace_pattern: str) ->
logger.debug(f" base URL: {input_url}")
logger.debug(f" search: {search_pattern}")
# Handle backreferences in the replacement pattern
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern)
# Convert JS-style backreferences in replace pattern: $<name> -> \g<name>, $1 -> \1
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
logger.debug(f" replace: {replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
# Apply the transformation
stream_url = re.sub(search_pattern, safe_replace_pattern, input_url)
# Apply the transformation (regex module accepts JS-style (?<name>...) natively)
stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url)
logger.info(f"Generated stream url: {stream_url}")
return stream_url
@ -211,9 +212,9 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id.decode('utf-8')
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
if existing_profile_id and int(existing_profile_id.decode('utf-8')) == profile.id:
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")
@ -349,9 +350,9 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id.decode('utf-8')
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
if existing_profile_id and int(existing_profile_id.decode('utf-8')) == profile.id:
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")

View file

@ -83,7 +83,7 @@ def create_ts_packet(packet_type='null', message=None):
# Add message to payload if provided
if message:
msg_bytes = message.encode('utf-8')
msg_bytes = message
packet[4:4+min(len(msg_bytes), 180)] = msg_bytes[:180]
return bytes(packet)
@ -113,4 +113,4 @@ def get_logger(component_name=None):
# Default if detection fails
logger_name = "ts_proxy"
return logging.getLogger(logger_name)
return logging.getLogger(logger_name)

View file

@ -19,6 +19,7 @@ from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.accounts.models import User
from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.accounts.permissions import (
IsAdmin,
@ -40,16 +41,21 @@ from .utils import get_logger
from uuid import UUID
import gevent
from dispatcharr.utils import network_access_allowed
from apps.proxy.utils import check_user_stream_limits
logger = get_logger()
@api_view(["GET"])
def stream_ts(request, channel_id):
@permission_classes([AllowAny])
def stream_ts(request, channel_id, user=None):
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
if user is None and hasattr(request, 'user') and request.user.is_authenticated:
user = request.user
channel = get_stream_object(channel_id)
client_user_agent = None
@ -71,6 +77,13 @@ def stream_ts(request, channel_id):
)
break
if user:
if not check_user_stream_limits(user, client_id, media_id=channel_id):
return JsonResponse(
{"error": f"Stream limit exceeded ({user.stream_limit} concurrent streams allowed)"},
status=429
)
# Check if we need to reinitialize the channel
needs_initialization = True
channel_state = None
@ -81,9 +94,9 @@ def stream_ts(request, channel_id):
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
metadata = proxy_server.redis_client.hgetall(metadata_key)
state_field = ChannelMetadataField.STATE.encode("utf-8")
state_field = ChannelMetadataField.STATE
if state_field in metadata:
channel_state = metadata[state_field].decode("utf-8")
channel_state = metadata[state_field]
# Active/running states - channel is operational, don't reinitialize
if channel_state in [
@ -119,9 +132,9 @@ def stream_ts(request, channel_id):
)
# Unknown/empty state - check if owner is alive
else:
owner_field = ChannelMetadataField.OWNER.encode("utf-8")
owner_field = ChannelMetadataField.OWNER
if owner_field in metadata:
owner = metadata[owner_field].decode("utf-8")
owner = metadata[owner_field]
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
if proxy_server.redis_client.exists(owner_heartbeat_key):
# Owner is still active with unknown state - don't reinitialize
@ -399,7 +412,7 @@ def stream_ts(request, channel_id):
metadata_key, ChannelMetadataField.STATE
)
if state_bytes:
current_state = state_bytes.decode("utf-8")
current_state = state_bytes
logger.debug(
f"[{client_id}] Current state of channel {channel_id}: {current_state}"
)
@ -475,12 +488,12 @@ def stream_ts(request, channel_id):
)
if url_bytes:
url = url_bytes.decode("utf-8")
url = url_bytes
if ua_bytes:
stream_user_agent = ua_bytes.decode("utf-8")
stream_user_agent = ua_bytes
# Extract transcode setting from Redis
if profile_bytes:
profile_str = profile_bytes.decode("utf-8")
profile_str = profile_bytes
use_transcode = (
profile_str == PROXY_PROFILE_NAME or profile_str == "None"
)
@ -516,12 +529,12 @@ def stream_ts(request, channel_id):
# Register client
buffer = proxy_server.stream_buffers[channel_id]
client_manager = proxy_server.client_managers[channel_id]
client_manager.add_client(client_id, client_ip, client_user_agent)
client_manager.add_client(client_id, client_ip, client_user_agent, user)
logger.info(f"[{client_id}] Client registered with channel {channel_id}")
# Create a stream generator for this client
generate = create_stream_generator(
channel_id, client_id, client_ip, client_user_agent, channel_initializing
channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user
)
# Return the StreamingHttpResponse from the main function
@ -543,6 +556,7 @@ def stream_ts(request, channel_id):
@api_view(["GET"])
@permission_classes([AllowAny])
def stream_xc(request, username, password, channel_id):
user = get_object_or_404(User, username=username)
@ -557,7 +571,6 @@ def stream_xc(request, username, password, channel_id):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
print(f"Fetchin channel with ID: {channel_id}")
if user.user_level < 10:
user_profile_count = user.channel_profiles.count()
@ -585,7 +598,7 @@ def stream_xc(request, username, password, channel_id):
channel = get_object_or_404(Channel, id=channel_id)
# @TODO: we've got the file 'type' via extension, support this when we support multiple outputs
return stream_ts(request._request, str(channel.uuid))
return stream_ts(request._request, str(channel.uuid), user)
@csrf_exempt
@ -713,7 +726,7 @@ def channel_status(request, channel_id=None):
)
for key in keys:
channel_id_match = re.search(
r"ts_proxy:channel:(.*):metadata", key.decode("utf-8")
r"ts_proxy:channel:(.*):metadata", key
)
if channel_id_match:
ch_id = channel_id_match.group(1)
@ -834,7 +847,7 @@ def next_stream(request, channel_id):
metadata_key, ChannelMetadataField.STREAM_ID
)
if stream_id_bytes:
current_stream_id = int(stream_id_bytes.decode("utf-8"))
current_stream_id = int(stream_id_bytes)
logger.info(
f"Found current stream ID {current_stream_id} in Redis for channel {channel_id}"
)
@ -844,7 +857,7 @@ def next_stream(request, channel_id):
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if profile_id_bytes:
profile_id = int(profile_id_bytes.decode("utf-8"))
profile_id = int(profile_id_bytes)
logger.info(
f"Found M3U profile ID {profile_id} in Redis for channel {channel_id}"
)
@ -916,7 +929,8 @@ def next_stream(request, channel_id):
channel_id,
stream_info["url"],
stream_info["user_agent"],
next_stream_id, # Pass the stream_id to be stored in Redis
next_stream_id,
stream_info.get("m3u_profile_id"),
)
if result.get("status") == "error":

View file

@ -6,4 +6,4 @@ urlpatterns = [
path('ts/', include('apps.proxy.ts_proxy.urls')),
path('hls/', include('apps.proxy.hls_proxy.urls')),
path('vod/', include('apps.proxy.vod_proxy.urls')),
]
]

185
apps/proxy/utils.py Normal file
View file

@ -0,0 +1,185 @@
import logging
from core.utils import RedisClient
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, get_vod_client_stop_key
from core.models import CoreSettings
from apps.proxy.ts_proxy.services.channel_service import ChannelService
logger = logging.getLogger("proxy")
def attempt_stream_termination(user_id, requesting_client_id, active_connections):
try:
logger.info("[stream limits]" f"[{requesting_client_id}] User {user_id} has {len(active_connections)} active connections, checking termination candidates")
user_limit_settings = CoreSettings.get_user_limits_settings()
terminate_oldest = user_limit_settings.get("terminate_oldest", True)
prioritize_single = user_limit_settings.get("prioritize_single_client_channels", True)
ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False)
channel_counts = {}
for connection in active_connections:
media_id = connection['media_id']
channel_counts[media_id] = channel_counts.get(media_id, 0) + 1
def prioritize(connection):
is_multi = channel_counts[connection['media_id']] > 1
# if we're ignoring same-channel connections, put them at the end
same_ch_key = 1 if (ignore_same_channel and is_multi) else 0
# key for prioritizing single-client channels
single_key = 0 if (prioritize_single and not is_multi) else 1
# sort by age setting
time_key = connection['connected_at'] if terminate_oldest else -connection['connected_at']
return (same_ch_key, single_key, time_key)
termination_candidates = sorted(active_connections, key=prioritize)
if not termination_candidates:
logger.warning("[stream limits]" f"[{requesting_client_id}] No termination candidates found for user {user_id}")
return False
target = termination_candidates[0]
logger.info("[stream limits]"
f"[{requesting_client_id}] Terminating client {target['client_id']} "
f"on media {target['media_id']} (connected_at={target['connected_at']})"
)
# When counting by unique channel, freeing one connection from a multi-connection
# channel doesn't free a slot — terminate all connections to that channel so the
# unique-channel count actually drops by one.
targets = (
[c for c in active_connections if c['media_id'] == target['media_id']]
if ignore_same_channel
else [target]
)
for t in targets:
if t['type'] == 'live':
result = ChannelService.stop_client(t['media_id'], t['client_id'])
if result.get("status") == "error":
logger.warning(f"[stream limits][{requesting_client_id}] Failed to stop client {t['client_id']} on channel {t['media_id']}")
else:
connection_manager = MultiWorkerVODConnectionManager.get_instance()
redis_client = connection_manager.redis_client
if not redis_client:
return False
connection_key = f"vod_persistent_connection:{t['client_id']}"
connection_data = redis_client.hgetall(connection_key)
if not connection_data:
logger.warning(f"VOD connection not found: {t['client_id']}")
continue
stop_key = get_vod_client_stop_key(t['client_id'])
redis_client.setex(stop_key, 60, "true") # 60 second TTL
return True
except Exception as e:
logger.error("[stream limits]" f"[{requesting_client_id}] Error during stream termination for user {user_id}: {e}")
return False
def get_user_active_connections(user_id):
redis_client = RedisClient.get_client()
connections = []
try:
# Grab live streams
for key in redis_client.scan_iter(match="ts_proxy:channel:*:clients:*", count=1000):
parts = key.split(':')
if len(parts) >= 5:
channel_id = parts[2]
client_id = parts[4]
client_user_id = redis_client.hget(key, 'user_id')
connected_at = redis_client.hget(key, 'connected_at')
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] channel_id = {channel_id}")
logger.debug(f"[stream limits] client_id = {client_id}")
if client_user_id and int(client_user_id) == user_id:
try:
logger.debug(f"[stream limits] Found LIVE connection for user {user_id} on channel {channel_id} with client ID {client_id}")
connected_at = float(connected_at) if connected_at else 0
connections.append({
'media_id': channel_id,
'client_id': client_id,
'connected_at': connected_at,
'type': 'live',
})
except (ValueError, TypeError):
pass
# Grab VOD
for key in redis_client.scan_iter(match="vod_persistent_connection:*", count=1000):
parts = key.split(':')
if len(parts) >= 2:
client_id = parts[1]
client_user_id = redis_client.hget(key, 'user_id')
connected_at = redis_client.hget(key, 'created_at')
content_uuid = redis_client.hget(key, 'content_uuid')
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] client_id = {client_id}")
if client_user_id and int(client_user_id) == user_id:
try:
logger.debug(f"[stream limits] Found VOD connection for user {user_id} on content {content_uuid} with client ID {client_id}")
connected_at = float(connected_at) if connected_at else 0
connections.append({
'media_id': content_uuid or client_id,
'client_id': client_id,
'connected_at': connected_at,
'type': 'vod',
})
except (ValueError, TypeError):
pass
return connections
except Exception as e:
logger.warning(f"Error getting active channel details for user {user_id}: {e}")
return []
def check_user_stream_limits(user, client_id, media_id=None):
# Check user stream limits
if user and user.stream_limit > 0:
logger.debug("[stream limits]" f"[{client_id}] User {user.username} (ID: {user.id}) is requesting a stream (stream_limit: {user.stream_limit})")
user_limit_settings = CoreSettings.get_user_limits_settings()
ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False)
active_connections = get_user_active_connections(user.id)
unique_channel_count = set([conn['media_id'] for conn in active_connections])
user_stream_count = len(unique_channel_count) if ignore_same_channel else len(active_connections)
logger.debug(f"[stream limits]" f"[{client_id}] User {user.username} currently has {len(active_connections)} active connections across {len(unique_channel_count)} unique channels (counting method: {'unique channels' if ignore_same_channel else 'total connections'})")
# If ignore_same_channel is enabled and this request is for a live channel the user
# is already watching, allow it through without counting against the limit.
# VOD is excluded: connections aren't shared so multiple VOD connections to the
# same content would mean multiple upstream connections.
live_channel_ids = {str(conn['media_id']) for conn in active_connections if conn['type'] == 'live'}
if ignore_same_channel and media_id and str(media_id) in live_channel_ids:
logger.debug(f"[stream limits][{client_id}] Same-channel reconnect for {media_id} allowed (ignore_same_channel=True)")
return True
if user_stream_count >= user.stream_limit:
if user_limit_settings.get("terminate_on_limit_exceeded", True) == False:
return False
if user_stream_count >= user.stream_limit:
logger.warning("[stream limits]"
f"[{client_id}] User {user.username} (ID: {user.id}) has reached stream limit "
f"({user_stream_count}/{user.stream_limit} streams), attempting to free up slot"
)
if not attempt_stream_termination(user.id, client_id, active_connections):
return False
return True

File diff suppressed because it is too large Load diff

View file

@ -93,7 +93,7 @@ class SerializableConnectionState:
content_name: str = None, client_ip: str = None,
client_user_agent: str = None, utc_start: str = None,
utc_end: str = None, offset: str = None,
worker_id: str = None, connection_type: str = "redis_backed"):
worker_id: str = None, connection_type: str = "redis_backed", user_id: str = "unknown"):
self.session_id = session_id
self.stream_url = stream_url
self.headers = headers
@ -104,6 +104,7 @@ class SerializableConnectionState:
self.last_activity = time.time()
self.request_count = 0
self.active_streams = 0
self.user_id = user_id
# Session metadata (consolidated from vod_session key)
self.content_obj_type = content_obj_type
@ -160,7 +161,8 @@ class SerializableConnectionState:
'last_seek_byte': str(self.last_seek_byte),
'last_seek_percentage': str(self.last_seek_percentage),
'total_content_size': str(self.total_content_size),
'last_seek_timestamp': str(self.last_seek_timestamp)
'last_seek_timestamp': str(self.last_seek_timestamp),
'user_id': str(self.user_id),
}
@classmethod
@ -184,7 +186,8 @@ class SerializableConnectionState:
utc_end=data.get('utc_end') or '',
offset=data.get('offset') or '',
worker_id=data.get('worker_id') or None,
connection_type=data.get('connection_type', 'redis_backed')
connection_type=data.get('connection_type', 'redis_backed'),
user_id=data.get('user_id', 'unknown')
)
obj.last_activity = float(data.get('last_activity', time.time()))
obj.request_count = int(data.get('request_count', 0))
@ -224,7 +227,7 @@ class RedisBackedVODConnection:
# Convert bytes keys/values to strings if needed
if isinstance(list(data.keys())[0], bytes):
data = {k.decode('utf-8'): v.decode('utf-8') for k, v in data.items()}
data = {k: v for k, v in data.items()}
return SerializableConnectionState.from_dict(data)
except Exception as e:
@ -281,7 +284,7 @@ class RedisBackedVODConnection:
content_name: str = None, client_ip: str = None,
client_user_agent: str = None, utc_start: str = None,
utc_end: str = None, offset: str = None,
worker_id: str = None) -> bool:
worker_id: str = None, user=None) -> bool:
"""Create a new connection state in Redis with consolidated session metadata"""
if not self._acquire_lock():
logger.warning(f"[{self.session_id}] Could not acquire lock for connection creation")
@ -309,7 +312,8 @@ class RedisBackedVODConnection:
utc_start=utc_start,
utc_end=utc_end,
offset=offset,
worker_id=worker_id
worker_id=worker_id,
user_id=user.id if user else "unknown"
)
success = self._save_connection_state(state)
@ -365,6 +369,24 @@ class RedisBackedVODConnection:
timeout=(10, 10),
allow_redirects=allow_redirects
)
# If the cached final_url returned an error (e.g. an ephemeral dispatcharr session
# that has since expired), clear it and retry from the original stream_url.
if response.status_code >= 400 and state.final_url:
logger.warning(
f"[{self.session_id}] Cached final_url returned {response.status_code}, "
f"clearing and retrying from stream_url"
)
response.close()
state.final_url = None
response = self.local_session.get(
state.stream_url,
headers=headers,
stream=True,
timeout=(10, 10),
allow_redirects=True
)
response.raise_for_status()
# Update state with response info on first request
@ -798,7 +820,7 @@ class MultiWorkerVODConnectionManager:
def stream_content_with_session(self, session_id, content_obj, stream_url, m3u_profile,
client_ip, client_user_agent, request,
utc_start=None, utc_end=None, offset=None, range_header=None):
utc_start=None, utc_end=None, offset=None, range_header=None, user=None):
"""Stream content with Redis-backed persistent connection"""
# Generate client ID
@ -895,7 +917,8 @@ class MultiWorkerVODConnectionManager:
utc_start=utc_start,
utc_end=utc_end,
offset=str(offset) if offset else None,
worker_id=self.worker_id
worker_id=self.worker_id,
user=user
):
logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection")
# Roll back the profile slot reservation since connection failed
@ -1316,14 +1339,14 @@ class MultiWorkerVODConnectionManager:
# Convert bytes to strings if needed
if isinstance(list(data.keys())[0], bytes):
data = {k.decode('utf-8'): v.decode('utf-8') for k, v in data.items()}
data = {k: v for k, v in data.items()}
last_activity = float(data.get('last_activity', 0))
active_streams = int(data.get('active_streams', 0))
# Clean up if stale and no active streams
if (current_time - last_activity > max_age_seconds) and active_streams == 0:
session_id = key.decode('utf-8').replace('vod_persistent_connection:', '')
session_id = key.replace('vod_persistent_connection:', '')
logger.info(f"Cleaning up stale connection: {session_id}")
# Clean up connection and related keys
@ -1420,7 +1443,7 @@ class MultiWorkerVODConnectionManager:
if connection_data:
# Convert bytes to strings if needed
if isinstance(list(connection_data.keys())[0], bytes):
connection_data = {k.decode('utf-8'): v.decode('utf-8') for k, v in connection_data.items()}
connection_data = {k: v for k, v in connection_data.items()}
profile_id = connection_data.get('m3u_profile_id')
if profile_id:
@ -1482,7 +1505,7 @@ class MultiWorkerVODConnectionManager:
# Convert bytes keys/values to strings if needed
if isinstance(list(connection_data.keys())[0], bytes):
connection_data = {k.decode('utf-8'): v.decode('utf-8') for k, v in connection_data.items()}
connection_data = {k: v for k, v in connection_data.items()}
# Check if content matches (using consolidated data)
stored_content_type = connection_data.get('content_obj_type', '')
@ -1492,7 +1515,7 @@ class MultiWorkerVODConnectionManager:
continue
# Extract session ID
session_id = key.decode('utf-8').replace('vod_persistent_connection:', '')
session_id = key.replace('vod_persistent_connection:', '')
# Check if Redis-backed connection exists and has no active streams
redis_connection = RedisBackedVODConnection(session_id, self.redis_client)
@ -1570,4 +1593,4 @@ class MultiWorkerVODConnectionManager:
return redis_connection.get_session_metadata()
except Exception as e:
logger.error(f"Error getting session info for {session_id}: {e}")
return None
return None

View file

@ -1,26 +1,20 @@
from django.urls import path
from . import views
from .views import stream_vod
app_name = 'vod_proxy'
urlpatterns = [
# Generic VOD streaming with session ID in path (for compatibility)
path('<str:content_type>/<uuid:content_id>/<str:session_id>', views.VODStreamView.as_view(), name='vod_stream_with_session'),
path('<str:content_type>/<uuid:content_id>/<str:session_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_session_and_profile'),
path('<str:content_type>/<uuid:content_id>/<str:session_id>', stream_vod, name='vod_stream_with_session'),
path('<str:content_type>/<uuid:content_id>/<str:session_id>/<int:profile_id>/', stream_vod, name='vod_stream_with_session_and_profile'),
# Generic VOD streaming (supports movies, episodes, series) - legacy patterns
path('<str:content_type>/<uuid:content_id>', views.VODStreamView.as_view(), name='vod_stream'),
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_profile'),
# VOD playlist generation
path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'),
path('playlist/<int:profile_id>/', views.VODPlaylistView.as_view(), name='vod_playlist_with_profile'),
# Position tracking
path('position/<uuid:content_id>/', views.VODPositionView.as_view(), name='vod_position'),
path('<str:content_type>/<uuid:content_id>', stream_vod, name='vod_stream'),
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', stream_vod, name='vod_stream_with_profile'),
# VOD Stats
path('stats/', views.VODStatsView.as_view(), name='vod_stats'),
path('stats/', views.vod_stats, name='vod_stats'),
# Stop VOD client connection
path('stop_client/', views.stop_vod_client, name='stop_vod_client'),

File diff suppressed because it is too large Load diff

View file

@ -578,13 +578,15 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)"
]
params = []
movie_params = []
series_params = []
if search:
where_conditions[0] += " AND LOWER(movies.name) LIKE %s"
where_conditions[1] += " AND LOWER(series.name) LIKE %s"
search_param = f"%{search.lower()}%"
params.extend([search_param, search_param])
movie_params.append(search_param)
series_params.append(search_param)
if category:
if '|' in category:
@ -592,15 +594,20 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
if cat_type == 'movie':
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] = "1=0" # Exclude series
params.append(cat_name)
movie_params.append(cat_name)
series_params = [] # no params needed for "1=0"
elif cat_type == 'series':
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
where_conditions[0] = "1=0" # Exclude movies
params.append(cat_name)
series_params.append(cat_name)
movie_params = [] # no params needed for "1=0"
else:
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
params.extend([category, category])
movie_params.append(category)
series_params.append(category)
params = movie_params + series_params
# Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination
# This is much more efficient than Python sorting
@ -896,4 +903,3 @@ class VODLogoViewSet(viewsets.ModelViewSet):
{"error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)