mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/CodeBormen/943
This commit is contained in:
commit
39865d3732
214 changed files with 25679 additions and 5073 deletions
|
|
@ -7,7 +7,7 @@ from .models import User
|
|||
class CustomUserAdmin(UserAdmin):
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password', 'avatar_config', 'groups')}),
|
||||
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}),
|
||||
('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from .api_views import (
|
|||
AuthViewSet,
|
||||
UserViewSet,
|
||||
GroupViewSet,
|
||||
APIKeyViewSet,
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
list_permissions,
|
||||
|
|
@ -17,6 +18,7 @@ app_name = "accounts"
|
|||
router = DefaultRouter()
|
||||
router.register(r"users", UserViewSet, basename="user")
|
||||
router.register(r"groups", GroupViewSet, basename="group")
|
||||
router.register(r"api-keys", APIKeyViewSet, basename="api-key")
|
||||
|
||||
# 🔹 Custom Authentication Endpoints
|
||||
auth_view = AuthViewSet.as_view({"post": "login"})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from django.contrib.auth import authenticate, login, logout
|
||||
import logging
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -8,6 +9,7 @@ from rest_framework import viewsets, status, serializers
|
|||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
import json
|
||||
import secrets
|
||||
from .permissions import IsAdmin, Authenticated
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
|
|
@ -15,6 +17,8 @@ from .models import User
|
|||
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenObtainPairView(TokenObtainPairView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -25,6 +29,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
username = request.data.get("username", 'unknown')
|
||||
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}")
|
||||
log_system_event(
|
||||
event_type='login_failed',
|
||||
user=username,
|
||||
|
|
@ -43,6 +48,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
|
||||
try:
|
||||
logger.debug(f"Attempting JWT login for user={username}")
|
||||
response = super().post(request, *args, **kwargs)
|
||||
|
||||
# If login was successful, update last_login and log success
|
||||
|
|
@ -61,6 +67,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Login success: user={username} ip={client_ip}")
|
||||
except User.DoesNotExist:
|
||||
pass # User doesn't exist, but login somehow succeeded
|
||||
else:
|
||||
|
|
@ -72,6 +79,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
user_agent=user_agent,
|
||||
reason='Invalid credentials',
|
||||
)
|
||||
logger.info(f"Login failed: user={username} ip={client_ip}")
|
||||
|
||||
return response
|
||||
|
||||
|
|
@ -84,6 +92,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
user_agent=user_agent,
|
||||
reason=f'Authentication error: {str(e)[:100]}',
|
||||
)
|
||||
logger.error(f"Login error for user={username}: {e}")
|
||||
raise # Re-raise the exception to maintain normal error flow
|
||||
|
||||
|
||||
|
|
@ -95,6 +104,7 @@ class TokenRefreshView(TokenRefreshView):
|
|||
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.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}")
|
||||
log_system_event(
|
||||
event_type='login_failed',
|
||||
user='token_refresh',
|
||||
|
|
@ -109,8 +119,8 @@ class TokenRefreshView(TokenRefreshView):
|
|||
|
||||
@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists.
|
||||
def initialize_superuser(request):
|
||||
# If a superuser already exists, always indicate that
|
||||
if User.objects.filter(is_superuser=True).exists():
|
||||
# If an admin-level user already exists, the system is configured
|
||||
if User.objects.filter(user_level__gte=10).exists():
|
||||
return JsonResponse({"superuser_exists": True})
|
||||
|
||||
if request.method == "POST":
|
||||
|
|
@ -167,6 +177,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
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)
|
||||
|
|
@ -182,6 +193,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Login success via session: user={username} ip={client_ip}")
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -203,6 +215,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
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)
|
||||
|
||||
@extend_schema(
|
||||
|
|
@ -222,6 +235,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Logout: user={username} ip={client_ip}")
|
||||
|
||||
logout(request)
|
||||
return Response({"message": "Logout successful"})
|
||||
|
|
@ -264,11 +278,24 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@extend_schema(
|
||||
description="Get active user information",
|
||||
description="Get or update active user information. PATCH updates custom_properties with merge semantics.",
|
||||
methods=["GET", "PATCH"],
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_path="me")
|
||||
@action(detail=False, methods=["get", "patch"], url_path="me")
|
||||
def me(self, request):
|
||||
user = request.user
|
||||
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,
|
||||
)
|
||||
serializer = UserSerializer(user, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
serializer = UserSerializer(user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
|
@ -305,6 +332,59 @@ class GroupViewSet(viewsets.ModelViewSet):
|
|||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# API Key management
|
||||
class APIKeyViewSet(viewsets.ViewSet):
|
||||
permission_classes = [Authenticated]
|
||||
|
||||
def list(self, request):
|
||||
user = request.user
|
||||
return Response({"key": user.api_key})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="generate")
|
||||
def generate(self, request):
|
||||
target_user = request.user
|
||||
user_id = request.data.get("user_id")
|
||||
|
||||
if user_id:
|
||||
from .permissions import IsAdmin
|
||||
|
||||
if not IsAdmin().has_permission(request, self):
|
||||
return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
target_user = User.objects.get(id=int(user_id))
|
||||
except Exception:
|
||||
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
raw = secrets.token_urlsafe(40)
|
||||
target_user.api_key = raw
|
||||
target_user.save(update_fields=["api_key"])
|
||||
|
||||
user_data = UserSerializer(target_user).data
|
||||
return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="revoke")
|
||||
def revoke(self, request):
|
||||
target_user = request.user
|
||||
user_id = request.data.get("user_id")
|
||||
|
||||
if user_id:
|
||||
from .permissions import IsAdmin
|
||||
|
||||
if not IsAdmin().has_permission(request, self):
|
||||
return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
target_user = User.objects.get(id=int(user_id))
|
||||
except Exception:
|
||||
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
target_user.api_key = None
|
||||
target_user.save(update_fields=["api_key"])
|
||||
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
# 🔹 4) Permissions List API
|
||||
@extend_schema(
|
||||
description="Retrieve a list of all permissions",
|
||||
|
|
|
|||
49
apps/accounts/authentication.py
Normal file
49
apps/accounts/authentication.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from django.conf import settings
|
||||
from .models import User
|
||||
|
||||
|
||||
class ApiKeyAuthentication(authentication.BaseAuthentication):
|
||||
"""
|
||||
Accepts header `Authorization: ApiKey <key>` or `X-API-Key: <key>`.
|
||||
"""
|
||||
|
||||
keyword = "ApiKey"
|
||||
|
||||
def authenticate(self, request):
|
||||
# Check X-API-Key header first
|
||||
raw_key = request.META.get("HTTP_X_API_KEY")
|
||||
|
||||
if not raw_key:
|
||||
auth = authentication.get_authorization_header(request).split()
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
if len(auth) != 2:
|
||||
return None
|
||||
|
||||
scheme = auth[0].decode().lower()
|
||||
if scheme != self.keyword.lower():
|
||||
return None
|
||||
|
||||
raw_key = auth[1].decode()
|
||||
|
||||
if not raw_key:
|
||||
return None
|
||||
|
||||
if not raw_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = User.objects.get(api_key=raw_key)
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed("Invalid API key")
|
||||
|
||||
if not user.is_active:
|
||||
raise exceptions.AuthenticationFailed("User inactive")
|
||||
|
||||
return (user, None)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
18
apps/accounts/migrations/0004_user_api_key.py
Normal file
18
apps/accounts/migrations/0004_user_api_key.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.11 on 2026-02-21 18:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_alter_user_custom_properties'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=200, null=True),
|
||||
),
|
||||
]
|
||||
20
apps/accounts/migrations/0005_alter_user_managers.py
Normal file
20
apps/accounts/migrations/0005_alter_user_managers.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 5.2.11 on 2026-02-26 19:24
|
||||
|
||||
import apps.accounts.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_user_api_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', apps.accounts.models.CustomUserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
# apps/accounts/models.py
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, Permission
|
||||
from django.contrib.auth.models import AbstractUser, Permission, UserManager
|
||||
|
||||
|
||||
class CustomUserManager(UserManager):
|
||||
def create_superuser(self, username, email=None, password=None, **extra_fields):
|
||||
extra_fields.setdefault('user_level', 10)
|
||||
return super().create_superuser(username, email, password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
objects = CustomUserManager()
|
||||
"""
|
||||
Custom user model for Dispatcharr.
|
||||
Inherits from Django's AbstractUser to add additional fields if needed.
|
||||
|
|
@ -22,6 +29,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)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
|
|
|||
|
|
@ -1,9 +1,32 @@
|
|||
import json
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from .models import User
|
||||
from apps.channels.models import ChannelProfile
|
||||
|
||||
|
||||
# Valid navigation item IDs for validation
|
||||
VALID_NAV_ITEM_IDS = {
|
||||
'channels', 'vods', 'sources', 'guide', 'dvr',
|
||||
'stats', 'plugins', 'integrations', 'system', 'settings'
|
||||
}
|
||||
MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit
|
||||
|
||||
|
||||
def validate_nav_array(value, field_name):
|
||||
"""Validate that a value is an array of valid nav item ID strings."""
|
||||
if not isinstance(value, list):
|
||||
raise serializers.ValidationError(f"{field_name} must be an array")
|
||||
if len(value) > 50:
|
||||
raise serializers.ValidationError(f"{field_name} exceeds maximum length of 50 items")
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise serializers.ValidationError(f"{field_name} items must be strings")
|
||||
if item not in VALID_NAV_ITEM_IDS:
|
||||
raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID")
|
||||
|
||||
|
||||
# 🔹 Fix for Permission serialization
|
||||
class PermissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
|
@ -24,23 +47,24 @@ class GroupSerializer(serializers.ModelSerializer):
|
|||
|
||||
# 🔹 Fix for User serialization
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True)
|
||||
password = serializers.CharField(write_only=True, required=False)
|
||||
channel_profiles = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChannelProfile.objects.all(), many=True, required=False
|
||||
)
|
||||
api_key = serializers.CharField(read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"api_key",
|
||||
"email",
|
||||
"user_level",
|
||||
"password",
|
||||
"channel_profiles",
|
||||
"custom_properties",
|
||||
"avatar_config",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"last_login",
|
||||
|
|
@ -49,12 +73,37 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
"last_name",
|
||||
]
|
||||
|
||||
def validate_custom_properties(self, value):
|
||||
"""Validate custom_properties structure and size."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("custom_properties must be a dictionary")
|
||||
|
||||
# Size limit check
|
||||
try:
|
||||
if len(json.dumps(value)) > MAX_CUSTOM_PROPS_SIZE:
|
||||
raise serializers.ValidationError(
|
||||
f"custom_properties exceeds maximum size of {MAX_CUSTOM_PROPS_SIZE} bytes"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError("custom_properties contains non-serializable data")
|
||||
|
||||
# Validate navOrder if present
|
||||
if 'navOrder' in value:
|
||||
validate_nav_array(value['navOrder'], 'navOrder')
|
||||
|
||||
# Validate hiddenNav if present
|
||||
if 'hiddenNav' in value:
|
||||
validate_nav_array(value['hiddenNav'], 'hiddenNav')
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
channel_profiles = validated_data.pop("channel_profiles", [])
|
||||
|
||||
user = User(**validated_data)
|
||||
user.set_password(validated_data["password"])
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
user.channel_profiles.set(channel_profiles)
|
||||
|
|
@ -65,6 +114,22 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
password = validated_data.pop("password", None)
|
||||
channel_profiles = validated_data.pop("channel_profiles", None)
|
||||
|
||||
# Merge custom_properties instead of replacing (prevents data loss)
|
||||
# Strip null values — sending null for a key omits it rather than overwriting with null
|
||||
custom_properties = validated_data.pop("custom_properties", None)
|
||||
if custom_properties is not None:
|
||||
existing = instance.custom_properties or {}
|
||||
cleaned = {k: v for k, v in custom_properties.items() if v is not None}
|
||||
merged = {**existing, **cleaned}
|
||||
# Scrub stale nav IDs so the DB self-heals on next save
|
||||
for nav_field in ('navOrder', 'hiddenNav'):
|
||||
if nav_field in merged and isinstance(merged[nav_field], list):
|
||||
merged[nav_field] = [
|
||||
item for item in merged[nav_field]
|
||||
if item in VALID_NAV_ITEM_IDS
|
||||
]
|
||||
instance.custom_properties = merged
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
|
|
|
|||
72
apps/accounts/tests.py
Normal file
72
apps/accounts/tests.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class InitializeSuperuserTests(TestCase):
|
||||
"""Tests for the initialize_superuser endpoint"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = "/api/accounts/initialize-superuser/"
|
||||
|
||||
def test_returns_true_when_superuser_exists(self):
|
||||
"""Superuser with is_superuser=True should be detected"""
|
||||
User.objects.create_superuser(
|
||||
username="admin", password="testpass123", user_level=10
|
||||
)
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
|
||||
def test_returns_true_when_admin_level_user_exists(self):
|
||||
"""User with user_level=10 but is_superuser=False should be detected"""
|
||||
user = User.objects.create_user(username="admin", password="testpass123")
|
||||
user.user_level = 10
|
||||
user.is_superuser = False
|
||||
user.save()
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
|
||||
def test_returns_false_when_no_admin_exists(self):
|
||||
"""No admin or superuser should return false"""
|
||||
# Create a non-admin user
|
||||
User.objects.create_user(username="regular", password="testpass123")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.json()["superuser_exists"])
|
||||
|
||||
def test_returns_false_when_no_users_exist(self):
|
||||
"""Empty database should return false"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.json()["superuser_exists"])
|
||||
|
||||
def test_create_superuser_when_none_exists(self):
|
||||
"""POST should create superuser when none exists"""
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"username": "newadmin", "password": "testpass123", "email": "admin@test.com"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists())
|
||||
|
||||
def test_cannot_create_superuser_when_admin_exists(self):
|
||||
"""POST should fail when an admin-level user already exists"""
|
||||
user = User.objects.create_user(username="existing", password="testpass123")
|
||||
user.user_level = 10
|
||||
user.save()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"username": "newadmin", "password": "testpass123"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
# Should NOT have created a new user
|
||||
self.assertFalse(User.objects.filter(username="newadmin").exists())
|
||||
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
|||
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
|
||||
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
|
||||
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
|
||||
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
|
||||
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
|
||||
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
|
||||
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ from django.conf import settings
|
|||
from django.http import HttpResponse, StreamingHttpResponse, Http404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes, parser_classes
|
||||
from rest_framework.permissions import IsAdminUser, AllowAny
|
||||
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
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ def _verify_task_token(task_id: str, token: str) -> bool:
|
|||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def list_backups(request):
|
||||
"""List all available backup files."""
|
||||
try:
|
||||
|
|
@ -47,7 +48,7 @@ def list_backups(request):
|
|||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def create_backup(request):
|
||||
"""Create a new backup (async via Celery)."""
|
||||
try:
|
||||
|
|
@ -86,7 +87,7 @@ def backup_status(request, task_id):
|
|||
)
|
||||
else:
|
||||
# Fall back to admin auth check
|
||||
if not request.user.is_authenticated or not request.user.is_staff:
|
||||
if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10:
|
||||
return Response(
|
||||
{"detail": "Authentication required"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -124,7 +125,7 @@ def backup_status(request, task_id):
|
|||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def get_download_token(request, filename):
|
||||
"""Get a signed token for downloading a backup file."""
|
||||
try:
|
||||
|
|
@ -168,7 +169,7 @@ def download_backup(request, filename):
|
|||
)
|
||||
else:
|
||||
# Fall back to admin auth check
|
||||
if not request.user.is_authenticated or not request.user.is_staff:
|
||||
if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10:
|
||||
return Response(
|
||||
{"detail": "Authentication required"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -230,7 +231,7 @@ def download_backup(request, filename):
|
|||
|
||||
|
||||
@api_view(["DELETE"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def delete_backup(request, filename):
|
||||
"""Delete a backup file."""
|
||||
try:
|
||||
|
|
@ -253,7 +254,7 @@ def delete_backup(request, filename):
|
|||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
@parser_classes([MultiPartParser, FormParser])
|
||||
def upload_backup(request):
|
||||
"""Upload a backup file for restoration."""
|
||||
|
|
@ -299,7 +300,7 @@ def upload_backup(request):
|
|||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def restore_backup(request, filename):
|
||||
"""Restore from a backup file (async via Celery). WARNING: This will flush the database!"""
|
||||
try:
|
||||
|
|
@ -332,7 +333,7 @@ def restore_backup(request, filename):
|
|||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def get_schedule(request):
|
||||
"""Get backup schedule settings."""
|
||||
try:
|
||||
|
|
@ -346,7 +347,7 @@ def get_schedule(request):
|
|||
|
||||
|
||||
@api_view(["PUT"])
|
||||
@permission_classes([IsAdminUser])
|
||||
@permission_classes([IsAdmin])
|
||||
def update_schedule(request):
|
||||
"""Update backup schedule settings."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django_celery_beat.models import PeriodicTask, CrontabSchedule
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
from core.models import CoreSettings
|
||||
from core.scheduling import (
|
||||
create_or_update_periodic_task,
|
||||
delete_periodic_task,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -105,98 +109,25 @@ def _sync_periodic_task() -> None:
|
|||
settings = get_schedule_settings()
|
||||
|
||||
if not settings["enabled"]:
|
||||
# Delete the task if it exists
|
||||
task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first()
|
||||
if task:
|
||||
old_crontab = task.crontab
|
||||
task.delete()
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME)
|
||||
logger.info("Backup schedule disabled, removed periodic task")
|
||||
return
|
||||
|
||||
# Get old crontab before creating new one
|
||||
old_crontab = None
|
||||
try:
|
||||
old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME)
|
||||
old_crontab = old_task.crontab
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Check if using cron expression (advanced mode)
|
||||
if settings["cron_expression"]:
|
||||
# Parse cron expression: "minute hour day month weekday"
|
||||
try:
|
||||
parts = settings["cron_expression"].split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
|
||||
|
||||
minute, hour, day_of_month, month_of_year, day_of_week = parts
|
||||
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=day_of_week,
|
||||
day_of_month=day_of_month,
|
||||
month_of_year=month_of_year,
|
||||
timezone=CoreSettings.get_system_time_zone(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
|
||||
raise ValueError(f"Invalid cron expression: {e}")
|
||||
cron_expr = settings["cron_expression"]
|
||||
else:
|
||||
# Use simple frequency-based scheduling
|
||||
# Parse time
|
||||
# Build a cron expression from simple frequency settings
|
||||
hour, minute = settings["time"].split(":")
|
||||
|
||||
# Build crontab based on frequency
|
||||
system_tz = CoreSettings.get_system_time_zone()
|
||||
if settings["frequency"] == "daily":
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week="*",
|
||||
day_of_month="*",
|
||||
month_of_year="*",
|
||||
timezone=system_tz,
|
||||
)
|
||||
cron_expr = f"{minute} {hour} * * *"
|
||||
else: # weekly
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=str(settings["day_of_week"]),
|
||||
day_of_month="*",
|
||||
month_of_year="*",
|
||||
timezone=system_tz,
|
||||
)
|
||||
cron_expr = f"{minute} {hour} * * {settings['day_of_week']}"
|
||||
|
||||
# Create or update the periodic task
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=BACKUP_SCHEDULE_TASK_NAME,
|
||||
defaults={
|
||||
"task": "apps.backups.tasks.scheduled_backup_task",
|
||||
"crontab": crontab,
|
||||
"enabled": True,
|
||||
"kwargs": json.dumps({"retention_count": settings["retention_count"]}),
|
||||
},
|
||||
create_or_update_periodic_task(
|
||||
task_name=BACKUP_SCHEDULE_TASK_NAME,
|
||||
celery_task_path="apps.backups.tasks.scheduled_backup_task",
|
||||
kwargs={"retention_count": settings["retention_count"]},
|
||||
cron_expression=cron_expr,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Clean up old crontab if it changed and is orphaned
|
||||
if old_crontab and old_crontab.id != crontab.id:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}")
|
||||
|
||||
|
||||
def _cleanup_orphaned_crontab(crontab_schedule):
|
||||
"""Delete old CrontabSchedule if no other tasks are using it."""
|
||||
if crontab_schedule is None:
|
||||
return
|
||||
|
||||
# Check if any other tasks are using this crontab
|
||||
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
|
||||
logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting")
|
||||
return
|
||||
|
||||
logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}")
|
||||
crontab_schedule.delete()
|
||||
|
|
|
|||
|
|
@ -735,6 +735,94 @@ class BackupAPITestCase(TestCase):
|
|||
self.assertIn('frequency', data['detail'])
|
||||
|
||||
|
||||
class BackupAdminPermissionTestCase(TestCase):
|
||||
"""Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks.
|
||||
|
||||
This validates the IsAdminUser -> IsAdmin permission change.
|
||||
API-created admins have user_level=10 but is_staff=False and is_superuser=False.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
# API-created admin: user_level=10 but NOT is_staff or is_superuser
|
||||
self.api_admin = User.objects.create_user(
|
||||
username='api_admin',
|
||||
email='apiadmin@example.com',
|
||||
password='testpass123'
|
||||
)
|
||||
self.api_admin.user_level = 10
|
||||
self.api_admin.is_staff = False
|
||||
self.api_admin.is_superuser = False
|
||||
self.api_admin.save()
|
||||
|
||||
# User with is_staff=True but low user_level (should NOT have access)
|
||||
self.staff_user = User.objects.create_user(
|
||||
username='staffuser',
|
||||
email='staff@example.com',
|
||||
password='testpass123'
|
||||
)
|
||||
self.staff_user.is_staff = True
|
||||
self.staff_user.user_level = 1
|
||||
self.staff_user.save()
|
||||
|
||||
self.temp_backup_dir = tempfile.mkdtemp()
|
||||
|
||||
def get_auth_header(self, user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return f'Bearer {str(refresh.access_token)}'
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
if Path(self.temp_backup_dir).exists():
|
||||
shutil.rmtree(self.temp_backup_dir)
|
||||
|
||||
@patch('apps.backups.services.list_backups')
|
||||
def test_api_created_admin_can_list_backups(self, mock_list_backups):
|
||||
"""API-created admin (user_level=10, is_staff=False) should access backup endpoints"""
|
||||
mock_list_backups.return_value = []
|
||||
|
||||
auth_header = self.get_auth_header(self.api_admin)
|
||||
response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_staff_user_without_user_level_cannot_list_backups(self):
|
||||
"""User with is_staff=True but user_level < 10 should NOT access backup endpoints"""
|
||||
auth_header = self.get_auth_header(self.staff_user)
|
||||
response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header)
|
||||
|
||||
self.assertIn(response.status_code, [401, 403])
|
||||
|
||||
@patch('apps.backups.tasks.create_backup_task.delay')
|
||||
def test_api_created_admin_can_create_backup(self, mock_create_task):
|
||||
"""API-created admin should be able to create backups"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-id'
|
||||
mock_create_task.return_value = mock_task
|
||||
|
||||
auth_header = self.get_auth_header(self.api_admin)
|
||||
response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
|
||||
@patch('apps.backups.services.get_backup_dir')
|
||||
def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir):
|
||||
"""API-created admin should be able to delete backups"""
|
||||
backup_dir = Path(self.temp_backup_dir)
|
||||
mock_get_backup_dir.return_value = backup_dir
|
||||
|
||||
backup_file = backup_dir / "test-backup.zip"
|
||||
backup_file.write_text("test content")
|
||||
|
||||
auth_header = self.get_auth_header(self.api_admin)
|
||||
response = self.client.delete(
|
||||
'/api/backups/test-backup.zip/delete/',
|
||||
HTTP_AUTHORIZATION=auth_header
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
|
||||
class BackupSchedulerTestCase(TestCase):
|
||||
"""Test cases for backup scheduler"""
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404, get_list_or_404
|
|||
from django.db import transaction
|
||||
from django.db.models import Count, F
|
||||
from django.db.models import Q
|
||||
import os, json, requests, logging, mimetypes
|
||||
import os, json, requests, logging, mimetypes, threading, time
|
||||
from datetime import timedelta
|
||||
from django.utils.http import http_date
|
||||
from urllib.parse import unquote
|
||||
from apps.accounts.permissions import (
|
||||
|
|
@ -48,7 +49,6 @@ from .serializers import (
|
|||
)
|
||||
from .tasks import (
|
||||
match_epg_channels,
|
||||
evaluate_series_rules,
|
||||
evaluate_series_rules_impl,
|
||||
match_single_channel_epg,
|
||||
match_selected_channels_epg,
|
||||
|
|
@ -72,6 +72,12 @@ from rest_framework.pagination import PageNumberPagination
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Negative cache for remote logo URLs that failed to fetch.
|
||||
# Prevents repeated blocking requests to unreachable hosts (e.g., dead CDNs)
|
||||
# from exhausting Daphne workers. Keyed by URL, value is expiry timestamp.
|
||||
_logo_fetch_failures = {}
|
||||
_LOGO_FAIL_TTL = 300 # seconds
|
||||
|
||||
|
||||
class OrInFilter(django_filters.Filter):
|
||||
"""
|
||||
|
|
@ -413,6 +419,13 @@ class ChannelPagination(PageNumberPagination):
|
|||
|
||||
return super().paginate_queryset(queryset, request, view)
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
from django.db.models import Exists, OuterRef
|
||||
has_unassigned = Channel.objects.filter(epg_data__isnull=True).exists()
|
||||
response = super().get_paginated_response(data)
|
||||
response.data['has_unassigned_epg_channels'] = has_unassigned
|
||||
return response
|
||||
|
||||
|
||||
class EPGFilter(django_filters.Filter):
|
||||
"""
|
||||
|
|
@ -571,6 +584,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
channel_profile_id = self.request.query_params.get("channel_profile_id")
|
||||
show_disabled_param = self.request.query_params.get("show_disabled", None)
|
||||
only_streamless = self.request.query_params.get("only_streamless", None)
|
||||
only_stale = self.request.query_params.get("only_stale", None)
|
||||
|
||||
if channel_profile_id:
|
||||
try:
|
||||
|
|
@ -590,6 +604,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
|
||||
if only_streamless:
|
||||
q_filters &= Q(streams__isnull=True)
|
||||
if only_stale:
|
||||
# Filter channels that have at least one related stream marked as stale
|
||||
q_filters &= Q(streams__is_stale=True)
|
||||
|
||||
if self.request.user.user_level < 10:
|
||||
filters["user_level__lte"] = self.request.user.user_level
|
||||
|
|
@ -724,6 +741,104 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"channels": serialized_channels
|
||||
})
|
||||
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description=(
|
||||
"Bulk rename channel names using a regex find/replace executed server-side. "
|
||||
"Accepts JavaScript-style named groups (e.g., (?<name>...)) and converts them to Python syntax. "
|
||||
"Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $<name> are translated to Python."
|
||||
),
|
||||
request=inline_serializer(
|
||||
name="BulkRegexRenameRequest",
|
||||
fields={
|
||||
"channel_ids": serializers.ListField(child=serializers.IntegerField()),
|
||||
"find": serializers.CharField(),
|
||||
"replace": serializers.CharField(required=False, allow_blank=True),
|
||||
"flags": serializers.CharField(required=False, allow_blank=True),
|
||||
},
|
||||
),
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="edit/bulk-regex")
|
||||
def bulk_regex_rename(self, request):
|
||||
"""
|
||||
Efficiently apply a regex find/replace to the `name` field of multiple channels.
|
||||
"""
|
||||
import regex as re
|
||||
|
||||
channel_ids = request.data.get("channel_ids", [])
|
||||
pattern = request.data.get("find", "")
|
||||
replace = request.data.get("replace", "")
|
||||
flags_str = request.data.get("flags", "") or ""
|
||||
|
||||
if not isinstance(channel_ids, list) or len(channel_ids) == 0:
|
||||
return Response({"error": "channel_ids must be a non-empty list"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not isinstance(pattern, str) or pattern.strip() == "":
|
||||
return Response({"error": "find (regex pattern) is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not isinstance(replace, str):
|
||||
return Response({"error": "replace must be a string"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Convert JS-style named groups to Python (?<name>...) -> (?P<name>...)
|
||||
try:
|
||||
converted_pattern = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", pattern)
|
||||
except Exception as e:
|
||||
return Response({"error": f"Failed to normalize pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Compile flags
|
||||
re_flags = 0
|
||||
if "i" in flags_str:
|
||||
re_flags |= re.IGNORECASE
|
||||
# Note: 'g' (global) is the default behavior of re.sub; no action needed.
|
||||
|
||||
# Translate common JS replacement tokens to Python
|
||||
def translate_js_replacement(rep: str) -> str:
|
||||
# $$ -> $
|
||||
rep = rep.replace("$$", "$")
|
||||
# $& -> \g<0>
|
||||
rep = rep.replace("$&", r"\g<0>")
|
||||
# $<name> -> \g<name>
|
||||
rep = re.sub(r"\$<([A-Za-z_][A-Za-z0-9_]*)>", r"\\g<\1>", rep)
|
||||
# $1 -> \g<1>, $2 -> \g<2>, etc.
|
||||
rep = re.sub(r"\$(\d+)", r"\\g<\1>", rep)
|
||||
return rep
|
||||
|
||||
try:
|
||||
replacement_py = translate_js_replacement(replace)
|
||||
compiled = re.compile(converted_pattern, flags=re_flags)
|
||||
except Exception as e:
|
||||
return Response({"error": f"Invalid regex pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Fetch channels in one query
|
||||
channels = list(Channel.objects.filter(id__in=channel_ids))
|
||||
if not channels:
|
||||
return Response({"error": "No matching channels found for provided IDs"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
changed = []
|
||||
for ch in channels:
|
||||
current = ch.name or ""
|
||||
try:
|
||||
new_name = compiled.sub(replacement_py, current)
|
||||
except Exception as e:
|
||||
# Skip problematic replacements but continue processing others
|
||||
logger.warning(f"Regex replacement failed for channel {ch.id}: {e}")
|
||||
continue
|
||||
|
||||
# Only update if name actually changes and remains non-empty
|
||||
if new_name != current and new_name.strip():
|
||||
ch.name = new_name
|
||||
changed.append(ch)
|
||||
|
||||
# Apply updates in bulk
|
||||
updated_count = 0
|
||||
if changed:
|
||||
with transaction.atomic():
|
||||
Channel.objects.bulk_update(changed, fields=["name"], batch_size=100)
|
||||
updated_count = len(changed)
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"updated_count": updated_count,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="set-names-from-epg")
|
||||
def set_names_from_epg(self, request):
|
||||
"""
|
||||
|
|
@ -831,6 +946,50 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
# Return the response with the list of IDs
|
||||
return Response(list(channel_ids))
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="summary")
|
||||
def summary(self, request, *args, **kwargs):
|
||||
"""Return a lightweight list of channels with only the fields needed by the TV Guide."""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
data = list(
|
||||
queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"logo_id",
|
||||
"channel_number",
|
||||
"uuid",
|
||||
"epg_data_id",
|
||||
"channel_group_id",
|
||||
)
|
||||
)
|
||||
return Response(data)
|
||||
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Retrieve channels by a list of UUIDs using POST to avoid URL length limitations",
|
||||
request=inline_serializer(
|
||||
name="ChannelByUUIDsRequest",
|
||||
fields={
|
||||
"uuids": serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
help_text="List of channel UUIDs to retrieve",
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={200: ChannelSerializer(many=True)},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="by-uuids")
|
||||
def get_by_uuids(self, request, *args, **kwargs):
|
||||
uuids = request.data.get("uuids", [])
|
||||
if not isinstance(uuids, list):
|
||||
return Response(
|
||||
{"error": "uuids must be a list of strings"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
channels = Channel.objects.filter(uuid__in=uuids)
|
||||
serializer = self.get_serializer(channels, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
|
||||
|
|
@ -1803,6 +1962,12 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
return response
|
||||
|
||||
else: # Remote image
|
||||
# Skip URLs that recently failed to avoid blocking Daphne workers
|
||||
# on unreachable hosts (e.g., dead CDNs referenced by old recordings).
|
||||
fail_expiry = _logo_fetch_failures.get(logo_url)
|
||||
if fail_expiry and time.monotonic() < fail_expiry:
|
||||
raise Http404("Remote image temporarily unavailable")
|
||||
|
||||
try:
|
||||
# Get the default user agent
|
||||
try:
|
||||
|
|
@ -1821,6 +1986,9 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
headers={'User-Agent': user_agent}
|
||||
)
|
||||
if remote_response.status_code == 200:
|
||||
# Success — clear any previous failure entry
|
||||
_logo_fetch_failures.pop(logo_url, None)
|
||||
|
||||
# Try to get content type from response headers first
|
||||
content_type = remote_response.headers.get("Content-Type")
|
||||
|
||||
|
|
@ -1844,14 +2012,19 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
os.path.basename(logo_url)
|
||||
)
|
||||
return response
|
||||
# Non-200 response — cache the failure and evict stale entries
|
||||
now = time.monotonic()
|
||||
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
|
||||
if len(_logo_fetch_failures) > 256:
|
||||
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
|
||||
_logo_fetch_failures.pop(k, None)
|
||||
raise Http404("Remote image not found")
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"Timeout fetching logo from {logo_url}")
|
||||
raise Http404("Logo request timed out")
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(f"Connection error fetching logo from {logo_url}")
|
||||
raise Http404("Unable to connect to logo server")
|
||||
except requests.RequestException as e:
|
||||
now = time.monotonic()
|
||||
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
|
||||
if len(_logo_fetch_failures) > 256:
|
||||
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
|
||||
_logo_fetch_failures.pop(k, None)
|
||||
logger.warning(f"Error fetching logo from {logo_url}: {e}")
|
||||
raise Http404("Error fetching remote image")
|
||||
|
||||
|
|
@ -2095,6 +2268,54 @@ class RecurringRecordingRuleViewSet(viewsets.ModelViewSet):
|
|||
logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}")
|
||||
|
||||
|
||||
def _stop_dvr_clients(channel_uuid, recording_id=None):
|
||||
"""Stop DVR recording clients for a channel.
|
||||
|
||||
If recording_id is provided, only the client whose User-Agent contains that
|
||||
recording ID is stopped (safe for simultaneous recordings on the same channel).
|
||||
If recording_id is None, all Dispatcharr-DVR clients for the channel are stopped
|
||||
(used by destroy() when deleting a recording whose task_id is unknown).
|
||||
|
||||
Returns the number of DVR clients stopped.
|
||||
"""
|
||||
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 not r:
|
||||
return 0
|
||||
client_set_key = RedisKeys.clients(channel_uuid)
|
||||
client_ids = r.smembers(client_set_key) or []
|
||||
stopped = 0
|
||||
for raw_id in client_ids:
|
||||
try:
|
||||
cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
|
||||
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
|
||||
ua = r.hget(meta_key, "user_agent")
|
||||
ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "")
|
||||
if not (ua_s and "Dispatcharr-DVR" in ua_s):
|
||||
continue
|
||||
# When a recording_id is specified, only stop the client for that recording.
|
||||
# Each run_recording task connects with User-Agent "Dispatcharr-DVR/recording-{id}",
|
||||
# so we can safely target just this recording without affecting others on the channel.
|
||||
if recording_id is not None and f"recording-{recording_id}" not in ua_s:
|
||||
continue
|
||||
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}")
|
||||
# Do not call ChannelService.stop_channel() here.
|
||||
# Stopping the channel proxy would terminate the source connection which may
|
||||
# be shared with other recordings on the same channel. The TS proxy server
|
||||
# already detects when client count reaches zero and tears down the channel
|
||||
# cleanly on its own (with the configured shutdown delay).
|
||||
return stopped
|
||||
|
||||
|
||||
class RecordingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recording.objects.all()
|
||||
serializer_class = RecordingSerializer
|
||||
|
|
@ -2189,72 +2410,290 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="stop")
|
||||
def stop(self, request, pk=None):
|
||||
"""Stop a recording early while retaining the partial content for playback."""
|
||||
instance = self.get_object()
|
||||
|
||||
cp = instance.custom_properties or {}
|
||||
current_status = cp.get("status", "")
|
||||
|
||||
# Reject stop on recordings that are already in a terminal state.
|
||||
# Without this guard, stop() would overwrite "completed" or
|
||||
# "interrupted" with "stopped", losing the original outcome.
|
||||
terminal = {"completed", "interrupted", "failed"}
|
||||
if current_status in terminal:
|
||||
return Response(
|
||||
{"success": False, "error": f"Recording is already {current_status}"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# Mark as stopped in the DB first so run_recording detects it.
|
||||
# This is the only operation that MUST be synchronous — run_recording reads
|
||||
# the status field to decide whether the stream disconnection was deliberate.
|
||||
cp["status"] = "stopped"
|
||||
cp["stopped_at"] = str(timezone.now())
|
||||
instance.custom_properties = cp
|
||||
instance.save(update_fields=["custom_properties"])
|
||||
|
||||
# Send the WebSocket notification before returning the response.
|
||||
# send_websocket_update is gevent-safe (offloads async_to_sync to a
|
||||
# real OS thread when monkey-patching is active).
|
||||
channel_uuid = str(instance.channel.uuid)
|
||||
recording_id = instance.id
|
||||
task_id = instance.task_id
|
||||
channel_name = instance.channel.name
|
||||
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_stopped",
|
||||
"channel": channel_name,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DVR client teardown and task revocation are deferred to a daemon thread
|
||||
# because they have occasional slow paths (Redis timeouts, Celery control
|
||||
# broadcasts) that would otherwise add 5-15 s to the HTTP response time.
|
||||
def _background_stop():
|
||||
try:
|
||||
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
|
||||
if stopped:
|
||||
logger.info(
|
||||
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to stop DVR clients for stopped recording: {e}")
|
||||
|
||||
try:
|
||||
from apps.channels.signals import revoke_task
|
||||
revoke_task(task_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to revoke task for stopped recording: {e}")
|
||||
|
||||
try:
|
||||
from django.db import connection as _conn
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_background_stop, daemon=True).start()
|
||||
|
||||
return Response({"success": True, "status": "stopped"})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="extend")
|
||||
def extend(self, request, pk=None):
|
||||
"""Extend an in-progress recording's end_time without interrupting the stream.
|
||||
|
||||
The running task re-reads end_time every ~2 s and adjusts its deadline
|
||||
dynamically. The pre_save signal skips task revocation while the
|
||||
recording status is 'recording'.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
cp = instance.custom_properties or {}
|
||||
|
||||
if cp.get("status") in ("completed", "stopped", "interrupted"):
|
||||
return Response(
|
||||
{"success": False, "error": "Recording has already finished"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
extra_minutes = int(request.data.get("extra_minutes", 0))
|
||||
except (TypeError, ValueError):
|
||||
extra_minutes = 0
|
||||
|
||||
if extra_minutes <= 0:
|
||||
return Response(
|
||||
{"success": False, "error": "extra_minutes must be a positive integer"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_end_time = instance.end_time + timedelta(minutes=extra_minutes)
|
||||
# Use queryset .update() to bypass pre_save/post_save signals.
|
||||
# This avoids the pre_save signal revoking the scheduled/running
|
||||
# Celery task. The running task's 2-second polling loop re-reads
|
||||
# end_time from the DB and extends its deadline dynamically.
|
||||
# If the task hasn't started yet (still in Beat's queue), it will
|
||||
# read the updated end_time from the DB on its first poll cycle.
|
||||
Recording.objects.filter(pk=instance.pk).update(end_time=new_end_time)
|
||||
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_extended",
|
||||
"recording_id": instance.id,
|
||||
"new_end_time": new_end_time.isoformat(),
|
||||
"extra_minutes": extra_minutes,
|
||||
"channel": instance.channel.name,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"success": True, "new_end_time": new_end_time.isoformat()})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="refresh-artwork")
|
||||
def refresh_artwork(self, request, pk=None):
|
||||
"""Re-run the poster resolution pipeline for this recording.
|
||||
|
||||
Useful when a recording fell back to a channel logo or default logo
|
||||
because external sources were temporarily unavailable.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
|
||||
def _background_refresh(rec_id):
|
||||
try:
|
||||
from .tasks import _resolve_poster_for_program
|
||||
from .models import Recording
|
||||
from core.utils import send_websocket_update
|
||||
from django.db import close_old_connections
|
||||
|
||||
rec = Recording.objects.select_related("channel").get(id=rec_id)
|
||||
cp = rec.custom_properties or {}
|
||||
program = cp.get("program") or {}
|
||||
|
||||
poster_logo_id, poster_url = _resolve_poster_for_program(
|
||||
rec.channel.name, program, channel_logo_id=rec.channel.logo_id,
|
||||
)
|
||||
|
||||
# Refresh and merge to avoid overwriting concurrent changes.
|
||||
# Only upgrade — never replace a real poster with a channel logo fallback.
|
||||
rec.refresh_from_db()
|
||||
fresh_cp = rec.custom_properties or {}
|
||||
updated = False
|
||||
is_channel_logo_fallback = (
|
||||
poster_logo_id == rec.channel.logo_id
|
||||
and not poster_url
|
||||
)
|
||||
if program and program.get("id"):
|
||||
fresh_cp["program"] = program
|
||||
updated = True
|
||||
if not is_channel_logo_fallback:
|
||||
if poster_logo_id and fresh_cp.get("poster_logo_id") != poster_logo_id:
|
||||
fresh_cp["poster_logo_id"] = poster_logo_id
|
||||
updated = True
|
||||
if poster_url and fresh_cp.get("poster_url") != poster_url:
|
||||
fresh_cp["poster_url"] = poster_url
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
rec.custom_properties = fresh_cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_updated",
|
||||
"recording_id": rec_id,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"refresh-artwork background failed for {rec_id}: {e}")
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
t = threading.Thread(target=_background_refresh, args=(instance.id,), daemon=True)
|
||||
t.start()
|
||||
|
||||
return Response({"success": True, "message": "Artwork refresh started"})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="update-metadata")
|
||||
def update_metadata(self, request, pk=None):
|
||||
"""Update user-editable recording metadata (title, description).
|
||||
|
||||
Sets user_edited flag to prevent EPG auto-enrichment from overwriting
|
||||
the user's changes on subsequent task runs.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
title = request.data.get("title")
|
||||
description = request.data.get("description")
|
||||
|
||||
if title is None and description is None:
|
||||
return Response(
|
||||
{"success": False, "error": "No fields to update"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Strip whitespace; treat blank strings as "no change"
|
||||
clean_title = str(title).strip() if title is not None else None
|
||||
clean_desc = str(description).strip() if description is not None else None
|
||||
|
||||
if not clean_title and not clean_desc:
|
||||
return Response(
|
||||
{"success": False, "error": "Title and description cannot be blank"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cp = instance.custom_properties or {}
|
||||
program = cp.get("program") or {}
|
||||
|
||||
if clean_title:
|
||||
program["title"] = clean_title
|
||||
if clean_desc:
|
||||
program["description"] = clean_desc
|
||||
program["user_edited"] = True
|
||||
|
||||
cp["program"] = program
|
||||
instance.custom_properties = cp
|
||||
instance.save(update_fields=["custom_properties"])
|
||||
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_updated",
|
||||
"recording_id": instance.id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"success": True})
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete the Recording and ensure any active DVR client connection is closed.
|
||||
|
||||
Also removes the associated file(s) from disk if present.
|
||||
|
||||
Operation order matters for correctness:
|
||||
1. Delete the DB record first — run_recording's cancellation guard
|
||||
(Recording.objects.filter(id=...).exists()) will now return False,
|
||||
preventing it from saving 'interrupted' status or sending
|
||||
recording_ended after the stream is torn down.
|
||||
2. Send recording_cancelled WebSocket immediately so the frontend
|
||||
removes the card without waiting for the slow DVR client teardown.
|
||||
3. Spawn a background thread to stop the DVR client and delete files.
|
||||
This mirrors the stop() endpoint's approach and avoids the 5-15 s
|
||||
delay that _stop_dvr_clients() can introduce.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
recording_id = instance.pk
|
||||
channel_name = instance.channel.name
|
||||
|
||||
# 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 raw_id in client_ids:
|
||||
try:
|
||||
cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
|
||||
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
|
||||
ua = r.hget(meta_key, "user_agent")
|
||||
ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "")
|
||||
# Identify DVR recording client by its user agent
|
||||
if ua_s and "Dispatcharr-DVR" in ua_s:
|
||||
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
|
||||
# Capture state before the DB row is deleted
|
||||
cp = instance.custom_properties or {}
|
||||
rec_status = cp.get("status", "")
|
||||
file_path = cp.get("file_path")
|
||||
temp_ts_path = cp.get("_temp_file_path")
|
||||
channel_uuid = str(instance.channel.uuid)
|
||||
|
||||
# Perform DB delete first, then try to remove files
|
||||
# 1. Delete the DB record (also fires post_delete → revoke_task_on_delete)
|
||||
response = super().destroy(request, *args, **kwargs)
|
||||
|
||||
# Notify frontends to refresh recordings
|
||||
# 2. Notify frontends immediately
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"})
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True,
|
||||
"type": "recording_cancelled",
|
||||
"recording_id": recording_id,
|
||||
"channel": channel_name,
|
||||
"was_in_progress": rec_status == "recording",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Defer slow teardown to a background thread
|
||||
library_dir = '/data'
|
||||
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
|
||||
|
||||
|
|
@ -2268,8 +2707,32 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
except Exception as ex:
|
||||
logger.warning(f"Failed to delete recording artifact {path}: {ex}")
|
||||
|
||||
_safe_remove(file_path)
|
||||
_safe_remove(temp_ts_path)
|
||||
def _background_cancel():
|
||||
# Only stop the DVR client if the recording was actively streaming.
|
||||
# Stopping for completed/upcoming recordings would kill an unrelated
|
||||
# in-progress recording on the same channel.
|
||||
if rec_status == "recording":
|
||||
try:
|
||||
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
|
||||
if stopped:
|
||||
logger.info(
|
||||
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
|
||||
|
||||
# Best-effort file cleanup in case run_recording already exited
|
||||
# before the DB delete.
|
||||
_safe_remove(file_path)
|
||||
_safe_remove(temp_ts_path)
|
||||
|
||||
try:
|
||||
from django.db import connection as _conn
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_background_cancel, daemon=True).start()
|
||||
|
||||
return response
|
||||
|
||||
|
|
@ -2382,11 +2845,9 @@ class SeriesRulesAPIView(APIView):
|
|||
else:
|
||||
rules.append({"tvg_id": tvg_id, "mode": mode, "title": title})
|
||||
CoreSettings.set_dvr_series_rules(rules)
|
||||
# Evaluate immediately for this tvg_id (async)
|
||||
try:
|
||||
evaluate_series_rules.delay(tvg_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Note: frontend calls the evaluate endpoint explicitly after creating
|
||||
# the rule, so do NOT fire evaluate_series_rules.delay() here to
|
||||
# avoid a race that creates duplicate recordings.
|
||||
return Response({"success": True, "rules": rules})
|
||||
|
||||
|
||||
|
|
@ -2399,16 +2860,44 @@ class DeleteSeriesRuleAPIView(APIView):
|
|||
|
||||
@extend_schema(
|
||||
summary="Delete a series rule",
|
||||
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
|
||||
description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.",
|
||||
parameters=[
|
||||
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
|
||||
],
|
||||
)
|
||||
def delete(self, request, tvg_id):
|
||||
tvg_id = unquote(str(tvg_id or ""))
|
||||
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
|
||||
CoreSettings.set_dvr_series_rules(rules)
|
||||
return Response({"success": True, "rules": rules})
|
||||
|
||||
# Find the rule before removing to retain the title for cleanup
|
||||
rules = CoreSettings.get_dvr_series_rules()
|
||||
deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None)
|
||||
remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id]
|
||||
CoreSettings.set_dvr_series_rules(remaining)
|
||||
|
||||
# Delete only FUTURE recordings — preserve previously recorded episodes
|
||||
removed = 0
|
||||
if deleted_rule:
|
||||
from .models import Recording
|
||||
qs = Recording.objects.filter(
|
||||
start_time__gte=timezone.now(),
|
||||
custom_properties__program__tvg_id=tvg_id,
|
||||
)
|
||||
title = deleted_rule.get("title")
|
||||
if title:
|
||||
qs = qs.filter(custom_properties__program__title=title)
|
||||
removed = qs.count()
|
||||
qs.delete()
|
||||
|
||||
# Notify frontend to refresh recordings list
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
"success": True, "type": "recordings_refreshed", "removed": removed,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"success": True, "rules": remaining, "removed": removed})
|
||||
|
||||
|
||||
class EvaluateSeriesRulesAPIView(APIView):
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from django.core.exceptions import ValidationError
|
|||
from django.conf import settings
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
from core.utils import RedisClient
|
||||
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
|
||||
|
|
@ -204,7 +206,10 @@ class Stream(models.Model):
|
|||
|
||||
def get_stream(self):
|
||||
"""
|
||||
Finds an available stream for the requested channel and returns the selected stream and profile.
|
||||
Finds an available profile for this stream and reserves a connection slot.
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason)
|
||||
"""
|
||||
redis_client = RedisClient.get_client()
|
||||
profile_id = redis_client.get(f"stream_profile:{self.id}")
|
||||
|
|
@ -226,33 +231,32 @@ class Stream(models.Model):
|
|||
if profile.is_active == False:
|
||||
continue
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
current_connections = int(redis_client.get(profile_connections_key) or 0)
|
||||
# Atomic slot reservation: INCR first, check, rollback if over capacity
|
||||
if profile.max_streams == 0:
|
||||
reserved = True
|
||||
else:
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
new_count = redis_client.incr(profile_connections_key)
|
||||
if new_count <= profile.max_streams:
|
||||
reserved = True
|
||||
else:
|
||||
redis_client.decr(profile_connections_key)
|
||||
reserved = False
|
||||
|
||||
# Check if profile has available slots (or unlimited connections)
|
||||
if profile.max_streams == 0 or current_connections < profile.max_streams:
|
||||
# Start a new stream
|
||||
if reserved:
|
||||
redis_client.set(f"channel_stream:{self.id}", self.id)
|
||||
redis_client.set(
|
||||
f"stream_profile:{self.id}", profile.id
|
||||
) # Store only the matched profile
|
||||
redis_client.set(f"stream_profile:{self.id}", profile.id)
|
||||
return self.id, profile.id, None
|
||||
|
||||
# Increment connection count for profiles with limits
|
||||
if profile.max_streams > 0:
|
||||
redis_client.incr(profile_connections_key)
|
||||
|
||||
return (
|
||||
self.id,
|
||||
profile.id,
|
||||
None,
|
||||
) # Return newly assigned stream and matched profile
|
||||
|
||||
# 4. No available streams
|
||||
return None, None, None
|
||||
return None, None, "All active M3U profiles have reached maximum connection limits"
|
||||
|
||||
def release_stream(self):
|
||||
"""
|
||||
Called when a stream is finished to release the lock.
|
||||
|
||||
Returns:
|
||||
bool: True if stream was successfully released, False if
|
||||
no profile info could be found for cleanup.
|
||||
"""
|
||||
redis_client = RedisClient.get_client()
|
||||
|
||||
|
|
@ -260,14 +264,17 @@ class Stream(models.Model):
|
|||
# Get the matched profile for cleanup
|
||||
profile_id = redis_client.get(f"stream_profile:{stream_id}")
|
||||
if not profile_id:
|
||||
logger.debug("Invalid profile ID pulled from stream index")
|
||||
return
|
||||
logger.debug(
|
||||
f"Stream {stream_id}: no profile found in "
|
||||
f"stream_profile:{stream_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
|
||||
|
||||
profile_id = int(profile_id)
|
||||
logger.debug(
|
||||
f"Found profile ID {profile_id} associated with stream {stream_id}"
|
||||
f"Stream {stream_id}: found profile_id={profile_id}"
|
||||
)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
|
|
@ -277,6 +284,8 @@ class Stream(models.Model):
|
|||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ChannelManager(models.Manager):
|
||||
def active(self):
|
||||
|
|
@ -391,6 +400,38 @@ class Channel(models.Model):
|
|||
|
||||
return stream_profile
|
||||
|
||||
def _check_and_reserve_profile_slot(self, profile, redis_client):
|
||||
"""
|
||||
Atomically check and reserve a connection slot for the given profile.
|
||||
|
||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
||||
condition where separate GET + check + INCR operations could allow
|
||||
concurrent requests to both pass the capacity check.
|
||||
|
||||
For profiles with max_streams=0 (unlimited), no reservation is needed.
|
||||
|
||||
Args:
|
||||
profile: M3UAccountProfile instance
|
||||
redis_client: Redis client instance
|
||||
|
||||
Returns:
|
||||
tuple: (reserved: bool, current_count: int)
|
||||
"""
|
||||
if profile.max_streams == 0:
|
||||
return (True, 0)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
|
||||
# Atomically increment first — this is a single Redis command
|
||||
new_count = redis_client.incr(profile_connections_key)
|
||||
|
||||
if new_count <= profile.max_streams:
|
||||
return (True, new_count)
|
||||
|
||||
# Over capacity — roll back the increment
|
||||
redis_client.decr(profile_connections_key)
|
||||
return (False, new_count - 1)
|
||||
|
||||
def get_stream(self):
|
||||
"""
|
||||
Finds an available stream for the requested channel and returns the selected stream and profile.
|
||||
|
|
@ -456,24 +497,16 @@ class Channel(models.Model):
|
|||
for profile in profiles:
|
||||
has_active_profiles = True
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
current_connections = int(
|
||||
redis_client.get(profile_connections_key) or 0
|
||||
# Atomically check and reserve a slot (INCR-first pattern)
|
||||
reserved, current_count = self._check_and_reserve_profile_slot(
|
||||
profile, redis_client
|
||||
)
|
||||
|
||||
# Check if profile has available slots (or unlimited connections)
|
||||
if (
|
||||
profile.max_streams == 0
|
||||
or current_connections < profile.max_streams
|
||||
):
|
||||
# Start a new stream
|
||||
if reserved:
|
||||
# Slot reserved — assign stream to this channel
|
||||
redis_client.set(f"channel_stream:{self.id}", stream.id)
|
||||
redis_client.set(f"stream_profile:{stream.id}", profile.id)
|
||||
|
||||
# Increment connection count for profiles with limits
|
||||
if profile.max_streams > 0:
|
||||
redis_client.incr(profile_connections_key)
|
||||
|
||||
return (
|
||||
stream.id,
|
||||
profile.id,
|
||||
|
|
@ -483,7 +516,8 @@ class Channel(models.Model):
|
|||
# This profile is at max connections
|
||||
has_streams_but_maxed_out = True
|
||||
logger.debug(
|
||||
f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}"
|
||||
f"Profile {profile.id} at max connections: "
|
||||
f"{current_count}/{profile.max_streams}"
|
||||
)
|
||||
|
||||
# No available streams - determine specific reason
|
||||
|
|
@ -499,32 +533,93 @@ class Channel(models.Model):
|
|||
def release_stream(self):
|
||||
"""
|
||||
Called when a stream is finished to release the lock.
|
||||
|
||||
Returns:
|
||||
bool: True if stream was successfully released, False if
|
||||
no stream/profile info could be found for cleanup.
|
||||
"""
|
||||
redis_client = RedisClient.get_client()
|
||||
|
||||
stream_id = redis_client.get(f"channel_stream:{self.id}")
|
||||
if not stream_id:
|
||||
logger.debug("Invalid stream ID pulled from channel index")
|
||||
return
|
||||
# Primary key missing — try metadata hash fallback.
|
||||
# The proxy may have already cleaned up channel_stream/stream_profile
|
||||
# keys, but the metadata hash can still have the stream_id and profile.
|
||||
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||
meta_stream_id = redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.STREAM_ID
|
||||
)
|
||||
meta_profile_id = redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.M3U_PROFILE
|
||||
)
|
||||
|
||||
if meta_stream_id and meta_profile_id:
|
||||
stream_id = int(meta_stream_id)
|
||||
profile_id = int(meta_profile_id)
|
||||
logger.debug(
|
||||
f"Channel {self.uuid}: recovered stream_id={stream_id}, "
|
||||
f"profile_id={profile_id} from metadata fallback"
|
||||
)
|
||||
# Clean up any remaining keys
|
||||
redis_client.delete(f"channel_stream:{self.id}")
|
||||
redis_client.delete(f"stream_profile:{stream_id}")
|
||||
|
||||
# Clear metadata fields so duplicate release_stream() calls
|
||||
# won't find them and DECR again
|
||||
redis_client.hdel(
|
||||
metadata_key,
|
||||
ChannelMetadataField.STREAM_ID,
|
||||
ChannelMetadataField.M3U_PROFILE,
|
||||
)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
current_count = int(
|
||||
redis_client.get(profile_connections_key) or 0
|
||||
)
|
||||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
f"Channel {self.uuid}: no stream info found in primary keys "
|
||||
f"or metadata fallback"
|
||||
)
|
||||
return False
|
||||
|
||||
redis_client.delete(f"channel_stream:{self.id}") # Remove active stream
|
||||
|
||||
stream_id = int(stream_id)
|
||||
logger.debug(
|
||||
f"Found stream ID {stream_id} associated with channel stream {self.id}"
|
||||
f"Channel {self.uuid}: found stream_id={stream_id} for "
|
||||
f"channel_stream:{self.id}"
|
||||
)
|
||||
|
||||
# Get the matched profile for cleanup
|
||||
profile_id = redis_client.get(f"stream_profile:{stream_id}")
|
||||
if not profile_id:
|
||||
logger.debug("Invalid profile ID pulled from stream index")
|
||||
return
|
||||
|
||||
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
|
||||
|
||||
profile_id = int(profile_id)
|
||||
if profile_id:
|
||||
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
|
||||
profile_id = int(profile_id)
|
||||
else:
|
||||
# stream_profile key missing — try metadata hash fallback
|
||||
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||
meta_profile_id = redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.M3U_PROFILE
|
||||
)
|
||||
if meta_profile_id:
|
||||
profile_id = int(meta_profile_id)
|
||||
logger.debug(
|
||||
f"Channel {self.uuid}: recovered profile_id={profile_id} "
|
||||
f"from metadata fallback (stream_profile:{stream_id} was missing)"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Channel {self.uuid}: no profile found for "
|
||||
f"stream_profile:{stream_id} or in metadata fallback"
|
||||
)
|
||||
return False
|
||||
logger.debug(
|
||||
f"Found profile ID {profile_id} associated with stream {stream_id}"
|
||||
f"Channel {self.uuid}: found profile_id={profile_id} for "
|
||||
f"stream {stream_id}"
|
||||
)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
|
|
@ -534,6 +629,18 @@ class Channel(models.Model):
|
|||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
|
||||
# Clear metadata fields so duplicate release_stream() calls
|
||||
# (e.g. from _clean_redis_keys or ChannelService.stop_channel)
|
||||
# won't find them via fallback and DECR again
|
||||
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||
redis_client.hdel(
|
||||
metadata_key,
|
||||
ChannelMetadataField.STREAM_ID,
|
||||
ChannelMetadataField.M3U_PROFILE,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def update_stream_profile(self, new_profile_id):
|
||||
"""
|
||||
Updates the profile for the current stream and adjusts connection counts.
|
||||
|
|
@ -566,18 +673,18 @@ class Channel(models.Model):
|
|||
if current_profile_id == new_profile_id:
|
||||
return True
|
||||
|
||||
# Decrement connection count for old profile
|
||||
# Use pipeline for atomic profile switch to prevent counter drift
|
||||
# if an exception occurs between DECR and INCR
|
||||
old_profile_connections_key = f"profile_connections:{current_profile_id}"
|
||||
old_count = int(redis_client.get(old_profile_connections_key) or 0)
|
||||
if old_count > 0:
|
||||
redis_client.decr(old_profile_connections_key)
|
||||
|
||||
# Update the profile mapping
|
||||
redis_client.set(f"stream_profile:{stream_id}", new_profile_id)
|
||||
|
||||
# Increment connection count for new profile
|
||||
new_profile_connections_key = f"profile_connections:{new_profile_id}"
|
||||
redis_client.incr(new_profile_connections_key)
|
||||
old_count = int(redis_client.get(old_profile_connections_key) or 0)
|
||||
|
||||
pipe = redis_client.pipeline()
|
||||
if old_count > 0:
|
||||
pipe.decr(old_profile_connections_key)
|
||||
pipe.set(f"stream_profile:{stream_id}", new_profile_id)
|
||||
pipe.incr(new_profile_connections_key)
|
||||
pipe.execute()
|
||||
logger.info(
|
||||
f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import now, is_aware, make_aware
|
||||
from celery.result import AsyncResult
|
||||
from django_celery_beat.models import ClockedSchedule, PeriodicTask
|
||||
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
||||
import logging, requests, time
|
||||
import json
|
||||
import logging
|
||||
from .tasks import run_recording, prefetch_recording_artwork
|
||||
from django.utils.timezone import now, is_aware, make_aware
|
||||
from datetime import timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -85,18 +86,76 @@ def create_profile_memberships(sender, instance, created, **kwargs):
|
|||
for channel in channels
|
||||
])
|
||||
|
||||
def schedule_recording_task(instance):
|
||||
eta = instance.start_time
|
||||
# Pass recording_id first so task can persist metadata to the correct row
|
||||
task = run_recording.apply_async(
|
||||
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
|
||||
eta=eta
|
||||
def _dvr_task_name(recording_id):
|
||||
"""Predictable PeriodicTask name for a DVR recording."""
|
||||
return f"dvr-recording-{recording_id}"
|
||||
|
||||
|
||||
def schedule_recording_task(instance, eta=None):
|
||||
"""Schedule a recording task via ClockedSchedule + one-off PeriodicTask.
|
||||
|
||||
The task is stored in the database and dispatched by Celery Beat at the
|
||||
scheduled time with no countdown. This avoids the Redis visibility_timeout
|
||||
redelivery bug that caused duplicate recordings when using apply_async
|
||||
with long countdowns.
|
||||
"""
|
||||
if eta is None:
|
||||
eta = instance.start_time
|
||||
if eta is not None and not is_aware(eta):
|
||||
eta = make_aware(eta)
|
||||
# Clamp to now so Beat dispatches immediately for past/current start times
|
||||
if eta <= now():
|
||||
eta = now()
|
||||
|
||||
task_args = [
|
||||
instance.id,
|
||||
instance.channel_id,
|
||||
str(instance.start_time),
|
||||
str(instance.end_time),
|
||||
]
|
||||
|
||||
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta)
|
||||
task_name = _dvr_task_name(instance.id)
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=task_name,
|
||||
defaults={
|
||||
"task": "apps.channels.tasks.run_recording",
|
||||
"clocked": clocked,
|
||||
"args": json.dumps(task_args),
|
||||
"one_off": True,
|
||||
"enabled": True,
|
||||
"interval": None,
|
||||
"crontab": None,
|
||||
"solar": None,
|
||||
},
|
||||
)
|
||||
return task.id
|
||||
return task_name
|
||||
|
||||
|
||||
def revoke_task(task_id):
|
||||
if task_id:
|
||||
"""Cancel a pending recording task.
|
||||
|
||||
task_id is normally a PeriodicTask name (e.g. "dvr-recording-42").
|
||||
For backwards compatibility with legacy Celery async-result UUIDs,
|
||||
falls back to AsyncResult.revoke().
|
||||
"""
|
||||
if not task_id:
|
||||
return
|
||||
# Primary path: delete the PeriodicTask and clean up its ClockedSchedule
|
||||
try:
|
||||
pt = PeriodicTask.objects.get(name=task_id)
|
||||
old_clocked = pt.clocked
|
||||
pt.delete()
|
||||
if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists():
|
||||
old_clocked.delete()
|
||||
return
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
# Fallback for legacy Celery task UUIDs
|
||||
try:
|
||||
AsyncResult(task_id).revoke()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@receiver(pre_save, sender=Recording)
|
||||
def revoke_old_task_on_update(sender, instance, **kwargs):
|
||||
|
|
@ -109,6 +168,12 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
|
|||
old.end_time != instance.end_time or
|
||||
old.channel_id != instance.channel_id
|
||||
):
|
||||
# Do NOT revoke while the recording is actively streaming.
|
||||
# run_recording re-reads end_time from the DB every ~2 s and extends
|
||||
# its internal deadline dynamically — revoking here would kill the task.
|
||||
old_status = (old.custom_properties or {}).get("status", "")
|
||||
if old_status == "recording":
|
||||
return
|
||||
revoke_task(old.task_id)
|
||||
instance.task_id = None
|
||||
except Recording.DoesNotExist:
|
||||
|
|
@ -117,32 +182,50 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
|
|||
@receiver(post_save, sender=Recording)
|
||||
def schedule_task_on_save(sender, instance, created, **kwargs):
|
||||
try:
|
||||
# Skip processing for internal field-only saves (metadata updates,
|
||||
# task_id assignment, end_time extensions) to prevent re-entrant
|
||||
# artwork dispatch and redundant recording_updated WS events.
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}:
|
||||
return
|
||||
|
||||
if not instance.task_id:
|
||||
start_time = instance.start_time
|
||||
end_time = instance.end_time
|
||||
|
||||
# Make both datetimes aware (in UTC)
|
||||
# Make datetimes aware (in UTC)
|
||||
if not is_aware(start_time):
|
||||
print("Start time was not aware, making aware")
|
||||
start_time = make_aware(start_time)
|
||||
if end_time and not is_aware(end_time):
|
||||
end_time = make_aware(end_time)
|
||||
|
||||
current_time = now()
|
||||
|
||||
# Debug log
|
||||
print(f"Start time: {start_time}, Now: {current_time}")
|
||||
|
||||
# Optionally allow slight fudge factor (1 second) to ensure scheduling happens
|
||||
if start_time > current_time - timedelta(seconds=1):
|
||||
print("Scheduling recording task!")
|
||||
task_id = schedule_recording_task(instance)
|
||||
# Future recording — schedule at start_time
|
||||
logger.info(f"Recording {instance.id}: scheduling task at {start_time}")
|
||||
task_id = schedule_recording_task(instance, eta=start_time)
|
||||
instance.task_id = task_id
|
||||
instance.save(update_fields=['task_id'])
|
||||
elif end_time and end_time > current_time:
|
||||
# Currently-playing — start immediately (e.g. series rule for in-progress program)
|
||||
logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately")
|
||||
task_id = schedule_recording_task(instance, eta=current_time)
|
||||
instance.task_id = task_id
|
||||
instance.save(update_fields=['task_id'])
|
||||
else:
|
||||
print("Start time is in the past. Not scheduling.")
|
||||
# Kick off poster/artwork prefetch to enrich Upcoming cards
|
||||
try:
|
||||
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
|
||||
except Exception as e:
|
||||
print("Error scheduling artwork prefetch:", e)
|
||||
logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling")
|
||||
# Kick off poster/artwork prefetch to enrich Upcoming cards.
|
||||
# Skip when the recording is already active or finished — run_recording
|
||||
# handles its own poster resolution, and scheduling artwork prefetch
|
||||
# while the task is running causes a race that can overwrite status.
|
||||
cp = instance.custom_properties or {}
|
||||
rec_status = cp.get("status", "")
|
||||
if rec_status not in ("recording", "completed", "stopped", "interrupted"):
|
||||
try:
|
||||
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
|
||||
except Exception as e:
|
||||
print("Error scheduling artwork prefetch:", e)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print("Error in post_save signal:", e)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
278
apps/channels/tests/test_db_retry.py
Normal file
278
apps/channels/tests/test_db_retry.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""Tests for DVR retry logic.
|
||||
|
||||
Covers:
|
||||
- _db_retry(): exponential backoff, max retries, connection reset
|
||||
- Final metadata save retry in run_recording post-processing
|
||||
- Initial TS proxy connection retry (per-base retry on retriable errors)
|
||||
- recover_recordings_on_startup DB retry wrappers
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
from django.db import OperationalError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.tasks import _db_retry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _db_retry unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DbRetryTests(TestCase):
|
||||
"""Tests for the _db_retry() exponential backoff helper."""
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_succeeds_on_first_attempt(self, _close, _sleep):
|
||||
"""No retry needed when fn succeeds immediately."""
|
||||
result = _db_retry(lambda: "ok", max_retries=3)
|
||||
self.assertEqual(result, "ok")
|
||||
_sleep.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_retries_on_operational_error_then_succeeds(self, mock_close, mock_sleep):
|
||||
"""Retry succeeds on second attempt after OperationalError."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def flaky():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("connection reset")
|
||||
return "recovered"
|
||||
|
||||
result = _db_retry(flaky, max_retries=3, base_interval=1)
|
||||
self.assertEqual(result, "recovered")
|
||||
self.assertEqual(call_count["n"], 2)
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_raises_after_max_retries_exhausted(self, mock_close, mock_sleep):
|
||||
"""Raises OperationalError after all retries fail."""
|
||||
def always_fail():
|
||||
raise OperationalError("db gone")
|
||||
|
||||
with self.assertRaises(OperationalError):
|
||||
_db_retry(always_fail, max_retries=3, base_interval=1)
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_exponential_backoff_timing(self, mock_close, mock_sleep):
|
||||
"""Sleep durations follow exponential backoff: 1s, 2s, 4s."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fail_twice():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] <= 2:
|
||||
raise OperationalError("retry me")
|
||||
return "done"
|
||||
|
||||
_db_retry(fail_twice, max_retries=3, base_interval=1)
|
||||
mock_sleep.assert_has_calls([call(1), call(2)])
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_close_old_connections_called_between_retries(self, mock_close, mock_sleep):
|
||||
"""Stale DB connections are reset before each retry attempt."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fail_once():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("stale conn")
|
||||
return "ok"
|
||||
|
||||
_db_retry(fail_once, max_retries=3)
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_non_operational_error_not_retried(self, mock_close, mock_sleep):
|
||||
"""Non-OperationalError exceptions propagate immediately."""
|
||||
def raise_value_error():
|
||||
raise ValueError("not a DB error")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_db_retry(raise_value_error, max_retries=3)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_returns_fn_return_value(self, mock_close, mock_sleep):
|
||||
"""Return value of fn() is passed through."""
|
||||
result = _db_retry(lambda: {"key": "value"}, max_retries=3)
|
||||
self.assertEqual(result, {"key": "value"})
|
||||
|
||||
@patch("apps.channels.tasks.time.sleep")
|
||||
@patch("apps.channels.tasks.close_old_connections")
|
||||
def test_single_retry_allowed(self, mock_close, mock_sleep):
|
||||
"""max_retries=1 means no retry — fail immediately."""
|
||||
with self.assertRaises(OperationalError):
|
||||
_db_retry(
|
||||
lambda: (_ for _ in ()).throw(OperationalError("fail")),
|
||||
max_retries=1,
|
||||
)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Final metadata save retry integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FinalMetadataSaveRetryTests(TestCase):
|
||||
"""The final recording metadata save must retry on transient DB errors."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=95, name="Retry Test Channel"
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_metadata_save_uses_db_retry(self, _ws):
|
||||
"""Verify recording metadata is saved via _db_retry (retries on OperationalError)."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
# Directly call _db_retry to save metadata as run_recording does
|
||||
cp = rec.custom_properties.copy()
|
||||
cp["status"] = "completed"
|
||||
cp["ended_at"] = str(now)
|
||||
cp["bytes_written"] = 1024
|
||||
|
||||
def _save():
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
_db_retry(_save, max_retries=3, base_interval=1, label="test save")
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], "completed")
|
||||
self.assertEqual(rec.custom_properties["bytes_written"], 1024)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_metadata_survives_transient_save_failure(self, _ws):
|
||||
"""Simulate OperationalError on first save, success on retry."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
cp = {"status": "completed", "bytes_written": 2048}
|
||||
call_count = {"n": 0}
|
||||
_real_save = rec.save
|
||||
|
||||
def patched_save(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("connection reset by peer")
|
||||
return _real_save(**kwargs)
|
||||
|
||||
with patch.object(rec, "save", side_effect=patched_save):
|
||||
with patch("apps.channels.tasks.time.sleep"):
|
||||
with patch("apps.channels.tasks.close_old_connections"):
|
||||
def _save():
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_db_retry(_save, max_retries=3, base_interval=1, label="test")
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], "completed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial connection retry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InitialConnectionRetryTests(TestCase):
|
||||
"""Verify that the DVR task's reconnection logic retries the same
|
||||
base URL before falling back to the next candidate."""
|
||||
|
||||
def test_reconnect_max_constant_exists_in_run_recording(self):
|
||||
"""run_recording must define a max-reconnect limit to prevent
|
||||
infinite retries on the same broken base URL."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# The reconnection counter pattern must be present
|
||||
self.assertIn("reconnect", source.lower(),
|
||||
"run_recording must contain reconnection logic")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# recover_recordings_on_startup retry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RecoveryRetryTests(TestCase):
|
||||
"""DB operations in recover_recordings_on_startup must use _db_retry."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=97, name="Recovery Retry Channel"
|
||||
)
|
||||
|
||||
@patch("apps.channels.tasks.run_recording.apply_async")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_recovery_save_retries_on_operational_error(self, _ws, mock_async):
|
||||
"""Recovery status update uses _db_retry — survives one OperationalError."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=30),
|
||||
end_time=now + timedelta(minutes=30),
|
||||
custom_properties={},
|
||||
)
|
||||
# Simulate what recovery does: mark interrupted, then save with retry
|
||||
cp = rec.custom_properties or {}
|
||||
cp["status"] = "interrupted"
|
||||
cp["interrupted_reason"] = "server_restarted"
|
||||
rec.custom_properties = cp
|
||||
|
||||
call_count = {"n": 0}
|
||||
_real_save = Recording.save
|
||||
|
||||
def patched_save(self_rec, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise OperationalError("db temporarily unavailable")
|
||||
return _real_save(self_rec, **kwargs)
|
||||
|
||||
with patch.object(Recording, "save", patched_save):
|
||||
with patch("apps.channels.tasks.time.sleep"):
|
||||
with patch("apps.channels.tasks.close_old_connections"):
|
||||
_db_retry(
|
||||
lambda: rec.save(update_fields=["custom_properties"]),
|
||||
max_retries=3,
|
||||
label="test recovery",
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("status"), "interrupted")
|
||||
self.assertEqual(rec.custom_properties.get("interrupted_reason"), "server_restarted")
|
||||
|
||||
def test_db_retry_fetches_recording_list(self):
|
||||
"""_db_retry correctly returns query results for recording list fetch."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=30),
|
||||
end_time=now + timedelta(minutes=30),
|
||||
custom_properties={},
|
||||
)
|
||||
result = _db_retry(
|
||||
lambda: list(Recording.objects.filter(
|
||||
start_time__lte=now, end_time__gt=now
|
||||
)),
|
||||
label="test query",
|
||||
)
|
||||
self.assertGreaterEqual(len(result), 1)
|
||||
ids = [r.id for r in result]
|
||||
self.assertIn(rec.id, ids)
|
||||
59
apps/channels/tests/test_dvr_port_resolution.py
Normal file
59
apps/channels/tests/test_dvr_port_resolution.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import os
|
||||
from django.test import SimpleTestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.channels.tasks import build_dvr_candidates
|
||||
|
||||
|
||||
class DVRPortResolutionTests(SimpleTestCase):
|
||||
"""
|
||||
Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT
|
||||
environment variable instead of hardcoding port 9191.
|
||||
"""
|
||||
|
||||
@patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True)
|
||||
def test_default_port_uses_9191(self):
|
||||
"""Without DISPATCHARR_PORT set, candidates default to 9191."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://web:9191', candidates)
|
||||
self.assertIn('http://localhost:9191', candidates)
|
||||
|
||||
@patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True)
|
||||
def test_custom_port_reflected_in_candidates(self):
|
||||
"""DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://web:8080', candidates)
|
||||
self.assertIn('http://localhost:8080', candidates)
|
||||
self.assertNotIn('http://web:9191', candidates)
|
||||
self.assertNotIn('http://localhost:9191', candidates)
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'DISPATCHARR_PORT': '7777',
|
||||
'DISPATCHARR_ENV': 'dev',
|
||||
'REDIS_HOST': 'redis',
|
||||
}, clear=True)
|
||||
def test_dev_mode_includes_5656_and_custom_port(self):
|
||||
"""Dev mode includes both uwsgi internal port (5656) and custom port."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://127.0.0.1:5656', candidates)
|
||||
self.assertIn('http://127.0.0.1:7777', candidates)
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234',
|
||||
'REDIS_HOST': 'redis',
|
||||
}, clear=True)
|
||||
def test_explicit_override_is_first(self):
|
||||
"""DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertEqual(candidates[0], 'http://custom:1234')
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'DISPATCHARR_PORT': '3000',
|
||||
'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000',
|
||||
'REDIS_HOST': 'redis',
|
||||
}, clear=True)
|
||||
def test_internal_api_base_overrides_web_fallback(self):
|
||||
"""DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://myhost:4000', candidates)
|
||||
self.assertNotIn('http://web:3000', candidates)
|
||||
180
apps/channels/tests/test_epg_matching.py
Normal file
180
apps/channels/tests/test_epg_matching.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Tests for the _match_epg_program_by_timeslot() helper in tasks.py.
|
||||
|
||||
Covers:
|
||||
- Exact time-slot match returns program dict
|
||||
- 80% overlap threshold: at boundary, above, and below
|
||||
- Multiple overlapping programs: dominant vs. evenly split
|
||||
- Edge cases: None inputs, zero-duration recording, no EPG data
|
||||
- Returned dict structure (id, title, sub_title, description)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import Channel
|
||||
from apps.epg.models import EPGSource, EPGData, ProgramData
|
||||
from apps.channels.tasks import _match_epg_program_by_timeslot
|
||||
|
||||
|
||||
class EpgMatchingSetupMixin:
|
||||
"""Shared setup for EPG matching tests."""
|
||||
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(name="Test Source")
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="test.channel", name="Test Channel EPG", epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=50, name="EPG Match Channel", epg_data=self.epg,
|
||||
)
|
||||
self.base = timezone.now().replace(second=0, microsecond=0)
|
||||
|
||||
def _prog(self, offset_min, duration_min, title="Test Show", **kwargs):
|
||||
"""Create a ProgramData starting offset_min from self.base."""
|
||||
start = self.base + timedelta(minutes=offset_min)
|
||||
end = start + timedelta(minutes=duration_min)
|
||||
return ProgramData.objects.create(
|
||||
epg=self.epg, start_time=start, end_time=end, title=title, **kwargs,
|
||||
)
|
||||
|
||||
|
||||
class ExactMatchTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""Recording window exactly matches an EPG program."""
|
||||
|
||||
def test_exact_match_returns_program_dict(self):
|
||||
prog = self._prog(0, 60, title="News at 9", sub_title="Top Stories",
|
||||
description="Evening news broadcast")
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, prog.start_time, prog.end_time,
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], prog.id)
|
||||
self.assertEqual(result["title"], "News at 9")
|
||||
self.assertEqual(result["sub_title"], "Top Stories")
|
||||
self.assertEqual(result["description"], "Evening news broadcast")
|
||||
|
||||
def test_missing_optional_fields_returned_as_empty_strings(self):
|
||||
prog = self._prog(0, 30, title="Minimal Show")
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, prog.start_time, prog.end_time,
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["sub_title"], "")
|
||||
self.assertEqual(result["description"], "")
|
||||
|
||||
|
||||
class OverlapThresholdTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""80% overlap threshold boundary tests."""
|
||||
|
||||
def test_exactly_80_percent_overlap_returns_match(self):
|
||||
"""Program covers exactly 80% of the recording window."""
|
||||
# Program: 0-60min, Recording: 0-75min → overlap = 60/75 = 80%
|
||||
prog = self._prog(0, 60, title="Borderline Show")
|
||||
rec_start = self.base
|
||||
rec_end = self.base + timedelta(minutes=75)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Borderline Show")
|
||||
|
||||
def test_below_80_percent_returns_none(self):
|
||||
"""Program covers 79% of the recording — below threshold."""
|
||||
# Program: 0-60min, Recording: 0-76min → overlap = 60/76 ≈ 78.9%
|
||||
prog = self._prog(0, 60, title="Too Short")
|
||||
rec_start = self.base
|
||||
rec_end = self.base + timedelta(minutes=76)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_above_80_percent_returns_match(self):
|
||||
"""Program covers 90% of the recording."""
|
||||
# Program: 0-60min, Recording: 0-66min → overlap = 60/66 ≈ 90.9%
|
||||
prog = self._prog(0, 60, title="Good Match")
|
||||
rec_start = self.base
|
||||
rec_end = self.base + timedelta(minutes=66)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Good Match")
|
||||
|
||||
|
||||
class MultipleProgramTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""Recording spans multiple EPG programs."""
|
||||
|
||||
def test_dominant_program_returned(self):
|
||||
"""Recording spans 2 programs; one covers 85%, the other 15%."""
|
||||
# Show A: 0-60min, Show B: 60-120min
|
||||
# Recording: 9-69min → A overlap=51/60=85%, B overlap=9/60=15%
|
||||
self._prog(0, 60, title="Show A")
|
||||
self._prog(60, 60, title="Show B")
|
||||
rec_start = self.base + timedelta(minutes=9)
|
||||
rec_end = self.base + timedelta(minutes=69)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Show A")
|
||||
|
||||
def test_evenly_split_returns_none(self):
|
||||
"""Recording spans 2 equal programs — neither reaches 80%."""
|
||||
# Show A: 0-60min, Show B: 60-120min
|
||||
# Recording: 30-90min → each covers 50%
|
||||
self._prog(0, 60, title="Show A")
|
||||
self._prog(60, 60, title="Show B")
|
||||
rec_start = self.base + timedelta(minutes=30)
|
||||
rec_end = self.base + timedelta(minutes=90)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_three_programs_one_dominant(self):
|
||||
"""Recording spans 3 programs; middle one is dominant."""
|
||||
# A: 0-30min, B: 30-90min, C: 90-120min
|
||||
# Recording: 25-95min (70min window) → B overlap=60/70≈85.7%
|
||||
self._prog(0, 30, title="Show A")
|
||||
self._prog(30, 60, title="Show B")
|
||||
self._prog(90, 30, title="Show C")
|
||||
rec_start = self.base + timedelta(minutes=25)
|
||||
rec_end = self.base + timedelta(minutes=95)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Show B")
|
||||
|
||||
|
||||
class EdgeCaseTests(EpgMatchingSetupMixin, TestCase):
|
||||
"""Edge cases and error handling."""
|
||||
|
||||
def test_none_epg_data_returns_none(self):
|
||||
result = _match_epg_program_by_timeslot(None, self.base, self.base + timedelta(hours=1))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_none_start_time_returns_none(self):
|
||||
result = _match_epg_program_by_timeslot(self.epg, None, self.base + timedelta(hours=1))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_none_end_time_returns_none(self):
|
||||
result = _match_epg_program_by_timeslot(self.epg, self.base, None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_zero_duration_returns_none(self):
|
||||
"""Recording with start == end should return None."""
|
||||
result = _match_epg_program_by_timeslot(self.epg, self.base, self.base)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_negative_duration_returns_none(self):
|
||||
"""Recording with end before start should return None."""
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, self.base + timedelta(hours=1), self.base,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_no_overlapping_programs_returns_none(self):
|
||||
"""No EPG programs in the recording window."""
|
||||
self._prog(0, 60, title="Earlier Show")
|
||||
rec_start = self.base + timedelta(hours=5)
|
||||
rec_end = rec_start + timedelta(hours=1)
|
||||
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_empty_epg_no_programs_returns_none(self):
|
||||
"""EPGData exists but has no programs."""
|
||||
result = _match_epg_program_by_timeslot(
|
||||
self.epg, self.base, self.base + timedelta(hours=1),
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
235
apps/channels/tests/test_recording_extend.py
Normal file
235
apps/channels/tests/test_recording_extend.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Tests for the Extend In-Progress Recording feature.
|
||||
|
||||
Covers:
|
||||
- extend() API endpoint (happy path and validation)
|
||||
- pre_save signal guard: end_time change must NOT revoke a live recording
|
||||
- pre_save signal guard: end_time change MUST still revoke an upcoming recording
|
||||
- TOCTOU edge cases (extend on a completed/stopped/nonexistent recording)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="extend_test_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extend endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExtendEndpointTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/extend/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=88, name="Extend Test Channel"
|
||||
)
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _extend(self, rec, extra_minutes):
|
||||
request = self.factory.post(
|
||||
f"/api/channels/recordings/{rec.id}/extend/",
|
||||
{"extra_minutes": extra_minutes},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "extend"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, status="recording"):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": status},
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_updates_end_time_in_db(self, _ws):
|
||||
rec = self._make_rec()
|
||||
original_end = rec.end_time
|
||||
response = self._extend(rec, 30)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data.get("success"))
|
||||
rec.refresh_from_db()
|
||||
expected = original_end + timedelta(minutes=30)
|
||||
delta = abs((rec.end_time - expected).total_seconds())
|
||||
self.assertLess(delta, 1, "end_time was not extended by the correct amount")
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_stacks_multiple_extensions(self, _ws):
|
||||
"""Calling extend() twice adds both increments."""
|
||||
rec = self._make_rec()
|
||||
original_end = rec.end_time
|
||||
self._extend(rec, 15)
|
||||
self._extend(rec, 30)
|
||||
rec.refresh_from_db()
|
||||
expected = original_end + timedelta(minutes=45)
|
||||
delta = abs((rec.end_time - expected).total_seconds())
|
||||
self.assertLess(delta, 1)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_does_not_clear_task_id(self, _ws):
|
||||
"""The running Celery task must survive the DB save."""
|
||||
rec = self._make_rec()
|
||||
rec.task_id = "dvr-recording-999"
|
||||
rec.save(update_fields=["task_id"])
|
||||
self._extend(rec, 30)
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.task_id, "dvr-recording-999")
|
||||
|
||||
def test_extend_returns_400_if_finished(self):
|
||||
"""Cannot extend a completed, stopped, or interrupted recording."""
|
||||
for bad_status in ("completed", "stopped", "interrupted"):
|
||||
with self.subTest(status=bad_status):
|
||||
rec = self._make_rec(status=bad_status)
|
||||
response = self._extend(rec, 30)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.data.get("success"))
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_succeeds_before_task_sets_status(self, _ws):
|
||||
"""Extend must work when status is empty (task hasn't started yet)."""
|
||||
rec = self._make_rec(status="")
|
||||
response = self._extend(rec, 15)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
expected = rec.end_time # already extended
|
||||
self.assertTrue(response.data.get("success"))
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke):
|
||||
"""Extend uses .update() to bypass pre_save — revoke_task must never fire."""
|
||||
rec = self._make_rec(status="")
|
||||
rec.task_id = "dvr-recording-500"
|
||||
rec.save(update_fields=["task_id"])
|
||||
self._extend(rec, 15)
|
||||
self._extend(rec, 30)
|
||||
mock_revoke.assert_not_called()
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.task_id, "dvr-recording-500")
|
||||
|
||||
def test_extend_returns_400_for_zero_minutes(self):
|
||||
response = self._extend(self._make_rec(), 0)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_extend_returns_400_for_negative_minutes(self):
|
||||
response = self._extend(self._make_rec(), -15)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_extend_returns_400_for_non_numeric_minutes(self):
|
||||
rec = self._make_rec()
|
||||
request = self.factory.post(
|
||||
f"/api/channels/recordings/{rec.id}/extend/",
|
||||
{"extra_minutes": "lots"},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "extend"})
|
||||
response = view(request, pk=rec.id)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_extend_returns_404_for_nonexistent_recording(self):
|
||||
request = self.factory.post(
|
||||
"/api/channels/recordings/999999/extend/",
|
||||
{"extra_minutes": 30},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "extend"})
|
||||
response = view(request, pk=999999)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_save signal guard tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PreSaveExtendGuardTests(TestCase):
|
||||
"""The pre_save signal must NOT revoke a live recording when end_time changes,
|
||||
but MUST still revoke a scheduled (upcoming) recording as before."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=77, name="Signal Guard Channel"
|
||||
)
|
||||
|
||||
def _make_rec(self, status="", task_id="dvr-recording-42"):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=2),
|
||||
task_id=task_id,
|
||||
custom_properties={"status": status} if status else {},
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke):
|
||||
"""When status='recording', extending end_time must not call revoke_task."""
|
||||
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
mock_revoke.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke):
|
||||
"""task_id must not be cleared for a live recording's end_time change."""
|
||||
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
|
||||
original_task_id = rec.task_id
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.task_id, original_task_id)
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke):
|
||||
"""The guard must NOT apply to upcoming recordings — existing behavior preserved."""
|
||||
rec = self._make_rec(status="", task_id="dvr-recording-77")
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
mock_revoke.assert_called_once_with("dvr-recording-77")
|
||||
|
||||
@patch("apps.channels.signals.revoke_task")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke):
|
||||
"""pre_save reads status from DB (old object), not from the instance being saved."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
task_id="dvr-recording-66",
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
# Simulate: DB status changes to 'completed' behind the instance's back
|
||||
Recording.objects.filter(pk=rec.pk).update(
|
||||
custom_properties={"status": "completed"}
|
||||
)
|
||||
rec.end_time = rec.end_time + timedelta(minutes=30)
|
||||
rec.save(update_fields=["end_time"])
|
||||
# revoke_task should be called because DB status is "completed", not "recording"
|
||||
mock_revoke.assert_called_once_with("dvr-recording-66")
|
||||
379
apps/channels/tests/test_recording_metadata.py
Normal file
379
apps/channels/tests/test_recording_metadata.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
"""Tests for recording metadata endpoints and logo proxy negative cache.
|
||||
|
||||
Covers:
|
||||
- update_metadata endpoint: title/description, user_edited flag, validation
|
||||
- refresh_artwork endpoint: returns immediately, background thread behavior
|
||||
- Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears
|
||||
"""
|
||||
import time as time_mod
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording, Logo
|
||||
from apps.channels.api_views import RecordingViewSet, LogoViewSet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="metadata_test_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_metadata endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UpdateMetadataTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/update-metadata/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _update(self, rec, data):
|
||||
request = self.factory.post(
|
||||
f"/api/channels/recordings/{rec.id}/update-metadata/",
|
||||
data, format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "update_metadata"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, custom_properties=None):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties=custom_properties or {},
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_update_title_only(self, _ws):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": "My Show"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
program = rec.custom_properties["program"]
|
||||
self.assertEqual(program["title"], "My Show")
|
||||
self.assertTrue(program["user_edited"])
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_update_description_only(self, _ws):
|
||||
rec = self._make_rec({"program": {"title": "Existing Title"}})
|
||||
response = self._update(rec, {"description": "A great episode"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
program = rec.custom_properties["program"]
|
||||
self.assertEqual(program["description"], "A great episode")
|
||||
self.assertEqual(program["title"], "Existing Title")
|
||||
self.assertTrue(program["user_edited"])
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_update_both_fields(self, _ws):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": "New Title", "description": "New Desc"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
program = rec.custom_properties["program"]
|
||||
self.assertEqual(program["title"], "New Title")
|
||||
self.assertEqual(program["description"], "New Desc")
|
||||
self.assertTrue(program["user_edited"])
|
||||
|
||||
def test_no_fields_returns_400(self):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.data.get("success"))
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_whitespace_trimmed(self, _ws):
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": " Padded Title "})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title")
|
||||
|
||||
def test_whitespace_only_title_returns_400(self):
|
||||
"""Whitespace-only title and description should be rejected."""
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": " ", "description": " "})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.data.get("success"))
|
||||
|
||||
def test_whitespace_only_title_with_valid_description(self):
|
||||
"""Whitespace-only title is ignored; valid description is accepted."""
|
||||
rec = self._make_rec({"program": {"title": "Original"}})
|
||||
with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None):
|
||||
response = self._update(rec, {"title": " ", "description": "Valid desc"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
# Title should remain unchanged since the whitespace-only value is not applied
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Original")
|
||||
self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc")
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_creates_program_dict_when_absent(self, _ws):
|
||||
"""Recording with no program dict gets one created."""
|
||||
rec = self._make_rec({"status": "completed"})
|
||||
response = self._update(rec, {"title": "Brand New"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
self.assertIn("program", rec.custom_properties)
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Brand New")
|
||||
|
||||
def test_returns_404_for_nonexistent(self):
|
||||
request = self.factory.post(
|
||||
"/api/channels/recordings/99999/update-metadata/",
|
||||
{"title": "Ghost"}, format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "update_metadata"})
|
||||
self.assertEqual(view(request, pk=99999).status_code, 404)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_sends_websocket_event(self, mock_ws):
|
||||
rec = self._make_rec()
|
||||
self._update(rec, {"title": "WS Test"})
|
||||
mock_ws.assert_called_once()
|
||||
payload = mock_ws.call_args[0][2]
|
||||
self.assertEqual(payload["type"], "recording_updated")
|
||||
self.assertEqual(payload["recording_id"], rec.id)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=Exception("WS down"))
|
||||
def test_ws_failure_does_not_fail_request(self, _ws):
|
||||
"""WebSocket errors are silenced — the save still succeeds."""
|
||||
rec = self._make_rec()
|
||||
response = self._update(rec, {"title": "Resilient"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["program"]["title"], "Resilient")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_artwork endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RefreshArtworkTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/refresh-artwork/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _refresh(self, rec):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, custom_properties=None):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties=custom_properties or {},
|
||||
)
|
||||
|
||||
@patch("threading.Thread")
|
||||
def test_returns_200_immediately(self, mock_thread):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
response = self._refresh(rec)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data.get("success"))
|
||||
|
||||
@patch("threading.Thread")
|
||||
def test_spawns_background_thread(self, mock_thread):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
self._refresh(rec)
|
||||
mock_thread.assert_called_once()
|
||||
self.assertTrue(mock_thread.call_args[1].get("daemon", False))
|
||||
mock_thread.return_value.start.assert_called_once()
|
||||
|
||||
def test_returns_404_for_nonexistent(self):
|
||||
request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
self.assertEqual(view(request, pk=99999).status_code, 404)
|
||||
|
||||
@patch("django.db.close_old_connections")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_no_downgrade_to_channel_logo(self, _ws, _close):
|
||||
"""When the pipeline returns the channel's own logo, existing poster is preserved."""
|
||||
logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png")
|
||||
self.channel.logo = logo
|
||||
self.channel.save()
|
||||
rec = self._make_rec({
|
||||
"poster_logo_id": 999, # existing real poster
|
||||
"poster_url": "https://tmdb.com/real-poster.jpg",
|
||||
})
|
||||
|
||||
with patch("apps.channels.tasks._resolve_poster_for_program",
|
||||
return_value=(logo.id, None)):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
# Run synchronously by intercepting the thread
|
||||
captured_fn = None
|
||||
def capture_thread(*args, **kwargs):
|
||||
nonlocal captured_fn
|
||||
captured_fn = kwargs.get("target") or args[0]
|
||||
mock = MagicMock()
|
||||
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
|
||||
return mock
|
||||
|
||||
with patch("threading.Thread", side_effect=capture_thread):
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
view(request, pk=rec.id)
|
||||
|
||||
rec.refresh_from_db()
|
||||
# Existing poster should be preserved — not downgraded to channel logo
|
||||
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999)
|
||||
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg")
|
||||
|
||||
@patch("django.db.close_old_connections")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_upgrade_from_no_poster(self, _ws, _close):
|
||||
"""When a recording has no poster and the pipeline finds one, it gets updated."""
|
||||
rec = self._make_rec({"program": {"title": "Some Show", "id": 42}})
|
||||
|
||||
with patch("apps.channels.tasks._resolve_poster_for_program",
|
||||
return_value=(555, "https://tmdb.com/new-poster.jpg")):
|
||||
captured_fn = None
|
||||
def capture_thread(*args, **kwargs):
|
||||
nonlocal captured_fn
|
||||
captured_fn = kwargs.get("target") or args[0]
|
||||
mock = MagicMock()
|
||||
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
|
||||
return mock
|
||||
|
||||
with patch("threading.Thread", side_effect=capture_thread):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
|
||||
view(request, pk=rec.id)
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555)
|
||||
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logo proxy negative cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogoNegativeCacheTests(TestCase):
|
||||
"""Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache()."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.channels import api_views
|
||||
self._failures = api_views._logo_fetch_failures
|
||||
self._failures.clear()
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = _make_admin()
|
||||
|
||||
def _fetch_logo(self, logo):
|
||||
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = LogoViewSet.as_view({"get": "cache"})
|
||||
return view(request, pk=logo.id)
|
||||
|
||||
def test_failed_url_cached_on_non_200(self):
|
||||
"""Non-200 response adds URL to negative cache."""
|
||||
logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png")
|
||||
mock_resp = MagicMock(status_code=404)
|
||||
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn("https://dead-cdn.com/logo.png", self._failures)
|
||||
|
||||
def test_cached_failure_returns_404_immediately(self):
|
||||
"""Subsequent request for a cached-failed URL returns 404 without making a request."""
|
||||
logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png")
|
||||
self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300
|
||||
|
||||
with patch("apps.channels.api_views.requests.get") as mock_get:
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_expired_cache_entry_allows_retry(self):
|
||||
"""After TTL expires, a new request is made."""
|
||||
logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png")
|
||||
self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired
|
||||
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.headers = {"Content-Type": "image/png"}
|
||||
mock_resp.iter_content = MagicMock(return_value=[b"img"])
|
||||
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_success_clears_previous_failure(self):
|
||||
"""A successful fetch removes the URL from the failure cache."""
|
||||
url = "https://recovered.com/logo.png"
|
||||
logo = Logo.objects.create(name="Recovered", url=url)
|
||||
self._failures[url] = time_mod.monotonic() - 1 # expired
|
||||
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_resp.headers = {"Content-Type": "image/png"}
|
||||
mock_resp.iter_content = MagicMock(return_value=[b"img"])
|
||||
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
self._fetch_logo(logo)
|
||||
self.assertNotIn(url, self._failures)
|
||||
|
||||
def test_request_exception_cached(self):
|
||||
"""Network errors are cached the same as non-200 responses."""
|
||||
import requests
|
||||
logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png")
|
||||
with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
response = self._fetch_logo(logo)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn("https://timeout.com/logo.png", self._failures)
|
||||
|
||||
def test_eviction_when_cache_exceeds_256(self):
|
||||
"""Stale entries are evicted when the cache grows past 256."""
|
||||
now = time_mod.monotonic()
|
||||
# Fill with 257 expired entries
|
||||
for i in range(257):
|
||||
self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired
|
||||
|
||||
logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png")
|
||||
import requests
|
||||
with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \
|
||||
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
|
||||
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
|
||||
self._fetch_logo(logo)
|
||||
|
||||
# Expired entries should be evicted
|
||||
old_entries = [k for k in self._failures if k.startswith("https://old-")]
|
||||
self.assertEqual(len(old_entries), 0)
|
||||
# New failure entry should exist
|
||||
self.assertIn("https://trigger-evict.com/logo.png", self._failures)
|
||||
524
apps/channels/tests/test_recording_pipeline.py
Normal file
524
apps/channels/tests/test_recording_pipeline.py
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
"""Tests for recent DVR fixes.
|
||||
|
||||
Covers:
|
||||
1. Collision avoidance: _build_output_paths checks both .mkv and .ts files
|
||||
2. Logo guard: _resolve_poster_for_program skips external APIs when title ≈ channel name
|
||||
3. Recording status lifecycle: status transitions visible via API
|
||||
4. Concat flags: error-tolerant ffmpeg flags used for segment concatenation
|
||||
5. Recovery skip-list: "recording" status NOT in terminal skip list
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="dvr_fixes_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
def _make_channel(name="Test Channel", number=100):
|
||||
return Channel.objects.create(channel_number=number, name=name)
|
||||
|
||||
|
||||
def _make_recording(channel, **overrides):
|
||||
now = timezone.now()
|
||||
defaults = {
|
||||
"channel": channel,
|
||||
"start_time": now - timedelta(hours=1),
|
||||
"end_time": now + timedelta(hours=1),
|
||||
"custom_properties": {},
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Recording.objects.create(**defaults)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Collision avoidance — _build_output_paths
|
||||
# =========================================================================
|
||||
|
||||
class CollisionAvoidanceTests(TestCase):
|
||||
"""_build_output_paths must increment the filename counter when
|
||||
EITHER the .mkv OR the .ts file already exists with size > 0."""
|
||||
|
||||
def _call(self, channel, program, start, end):
|
||||
from apps.channels.tasks import _build_output_paths
|
||||
return _build_output_paths(channel, program, start, end)
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_no_collision_when_nothing_exists(self, _tv, _fb):
|
||||
"""Fresh path — no files exist, counter stays at 1."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
raise OSError("No such file")
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
# Should NOT have a _2 suffix
|
||||
self.assertNotIn("_2", final)
|
||||
self.assertTrue(final.endswith(".mkv"))
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb):
|
||||
"""Pre-restart scenario: MKV is 0-byte placeholder, TS has real data.
|
||||
The old code only checked MKV size, so it would reuse the path.
|
||||
The fix also checks TS, so it must increment."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
if "_2" in path:
|
||||
raise OSError("No such file")
|
||||
result = MagicMock()
|
||||
if path.endswith('.mkv'):
|
||||
result.st_size = 0 # MKV is 0-byte placeholder
|
||||
elif path.endswith('.ts'):
|
||||
result.st_size = 5000000 # TS has real data from pre-restart
|
||||
else:
|
||||
result.st_size = 0
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
# Must have incremented to _2
|
||||
self.assertIn("_2", final, "Should increment counter when TS file has data")
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_collision_when_mkv_has_data(self, _tv, _fb):
|
||||
"""Standard collision: MKV file has data, should increment."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
if "_2" in path:
|
||||
raise OSError("No such file")
|
||||
result = MagicMock()
|
||||
if path.endswith('.mkv'):
|
||||
result.st_size = 1000000 # MKV has data
|
||||
else:
|
||||
result.st_size = 0
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
self.assertIn("_2", final, "Should increment counter when MKV file has data")
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_no_collision_when_both_zero_bytes(self, _tv, _fb):
|
||||
"""Both MKV and TS exist but are 0 bytes — no collision."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
result = MagicMock()
|
||||
result.st_size = 0 # All files empty
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
self.assertNotIn("_2", final, "Should NOT increment when all files are empty")
|
||||
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||
return_value="TV/{show}/{start}.mkv")
|
||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
def test_collision_increments_to_3_when_2_also_occupied(self, _tv, _fb):
|
||||
"""When both base and _2 are occupied, should go to _3."""
|
||||
ch = MagicMock(name="TestCh")
|
||||
ch.name = "TestCh"
|
||||
program = {"title": "My Show"}
|
||||
now = timezone.now()
|
||||
|
||||
def mock_stat(path):
|
||||
if "_3" in path:
|
||||
raise OSError("No such file")
|
||||
result = MagicMock()
|
||||
if path.endswith('.ts'):
|
||||
result.st_size = 5000000
|
||||
else:
|
||||
result.st_size = 0
|
||||
return result
|
||||
|
||||
with patch("os.stat", side_effect=mock_stat), \
|
||||
patch("os.makedirs"):
|
||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||
|
||||
self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Logo guard — _resolve_poster_for_program
|
||||
# =========================================================================
|
||||
|
||||
class LogoGuardTests(TestCase):
|
||||
"""When the program title matches the channel name, external API
|
||||
searches (VOD, TMDB, OMDb, TVMaze, iTunes) must be skipped."""
|
||||
|
||||
def _call(self, channel_name, program, channel_logo_id=None):
|
||||
from apps.channels.tasks import _resolve_poster_for_program
|
||||
return _resolve_poster_for_program(channel_name, program, channel_logo_id)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_channel_name_as_title_skips_external_apis(self, mock_get):
|
||||
"""Title = 'USA A&E SD*', channel = 'USA A&E SD*' → no external calls."""
|
||||
program = {"title": "USA A&E SD*"}
|
||||
logo_id, url = self._call("USA A&E SD*", program, channel_logo_id=42)
|
||||
|
||||
# Should NOT have called any external APIs
|
||||
mock_get.assert_not_called()
|
||||
# Should fall back to channel logo
|
||||
self.assertEqual(logo_id, 42)
|
||||
self.assertIsNone(url)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_channel_name_normalized_match(self, mock_get):
|
||||
"""Title = 'fox news', channel = 'FOX-News*' → normalized match, skip APIs."""
|
||||
program = {"title": "fox news"}
|
||||
logo_id, url = self._call("FOX-News*", program, channel_logo_id=99)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
self.assertEqual(logo_id, 99)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_real_title_still_searched(self, mock_get):
|
||||
"""Title = 'Breaking Bad' on channel 'AMC' → should try external APIs."""
|
||||
# Mock TVMaze returning a result
|
||||
mock_resp = MagicMock(ok=True, status_code=200)
|
||||
mock_resp.json.return_value = {
|
||||
"image": {"original": "https://tvmaze.com/breaking-bad.jpg"}
|
||||
}
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
program = {"title": "Breaking Bad"}
|
||||
logo_id, url = self._call("AMC", program)
|
||||
|
||||
# Should have made at least one external API call
|
||||
self.assertTrue(mock_get.called, "Should search external APIs for real titles")
|
||||
self.assertIsNotNone(url)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_no_title_skips_to_channel_logo(self, mock_get):
|
||||
"""No title at all → falls through to channel logo, no API calls."""
|
||||
program = {}
|
||||
logo_id, url = self._call("SomeChannel", program, channel_logo_id=55)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
self.assertEqual(logo_id, 55)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
def test_epg_image_still_used_even_when_title_is_channel_name(self, mock_get):
|
||||
"""Even when title = channel name, Stage 1 (EPG images) should still work."""
|
||||
from apps.epg.models import ProgramData, EPGSource, EPGData
|
||||
|
||||
# Create an EPG source + EPGData entry + program with an icon URL
|
||||
epg_source = EPGSource.objects.create(source_type="xmltv", name="Test EPG")
|
||||
epg_data = EPGData.objects.create(tvg_id="test.ch", epg_source=epg_source)
|
||||
prog = ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
title="Test Channel HD",
|
||||
start_time=timezone.now() - timedelta(hours=1),
|
||||
end_time=timezone.now() + timedelta(hours=1),
|
||||
custom_properties={"icon": "https://epg-cdn.com/test-icon.png"},
|
||||
)
|
||||
|
||||
program = {"title": "Test Channel HD", "id": prog.id}
|
||||
|
||||
# Mock _validate_url to return True for the icon URL
|
||||
with patch("apps.channels.tasks._validate_url", return_value=True):
|
||||
logo_id, url = self._call("Test Channel HD", program, channel_logo_id=10)
|
||||
|
||||
# EPG icon should still be used (Stage 1 doesn't depend on title guard)
|
||||
self.assertEqual(url, "https://epg-cdn.com/test-icon.png")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Recording status lifecycle via API
|
||||
# =========================================================================
|
||||
|
||||
class RecordingStatusLifecycleTests(TestCase):
|
||||
"""Verify recording status transitions and that terminal recordings
|
||||
are properly filterable (supports the red-dot fix in guideUtils)."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = _make_channel("Status Test Channel", 200)
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _list_recordings(self):
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
request = self.factory.get("/api/channels/recordings/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"get": "list"})
|
||||
return view(request)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_stopped_recording_has_terminal_status(self, _ws):
|
||||
"""After stop, custom_properties.status = 'stopped'."""
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
|
||||
rec = _make_recording(self.channel, custom_properties={
|
||||
"status": "recording",
|
||||
"program": {"id": 1, "title": "Live Show"},
|
||||
})
|
||||
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "stop"})
|
||||
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
response = view(request, pk=rec.id)
|
||||
|
||||
self.assertIn(response.status_code, [200, 204])
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("status"), "stopped")
|
||||
|
||||
def test_listing_includes_status_in_custom_properties(self):
|
||||
"""API listing returns custom_properties with status field."""
|
||||
_make_recording(self.channel, custom_properties={
|
||||
"status": "recording",
|
||||
"program": {"id": 1, "title": "Recording Show"},
|
||||
})
|
||||
_make_recording(self.channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"id": 2, "title": "Stopped Show"},
|
||||
})
|
||||
|
||||
response = self._list_recordings()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
statuses = [r["custom_properties"].get("status") for r in response.data]
|
||||
self.assertIn("recording", statuses)
|
||||
self.assertIn("stopped", statuses)
|
||||
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_delete_recording_removes_from_listing(self, _ws):
|
||||
"""Deleting a recording removes it from the listing entirely."""
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
|
||||
rec = _make_recording(self.channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"id": 3, "title": "To Delete"},
|
||||
})
|
||||
rec_id = rec.id
|
||||
|
||||
request = self.factory.delete(f"/api/channels/recordings/{rec_id}/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"delete": "destroy"})
|
||||
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
response = view(request, pk=rec_id)
|
||||
|
||||
self.assertIn(response.status_code, [200, 204])
|
||||
self.assertFalse(Recording.objects.filter(id=rec_id).exists())
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Concat flags — error-tolerant ffmpeg
|
||||
# =========================================================================
|
||||
|
||||
class ConcatFlagsTests(TestCase):
|
||||
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
|
||||
when concatenating pre-restart segments."""
|
||||
|
||||
def test_concat_command_includes_error_tolerant_flags(self):
|
||||
"""Inspect the source code to confirm error-tolerant flags are present.
|
||||
This is a static analysis test — no ffmpeg execution needed."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# The concat subprocess.run call must include these flags
|
||||
self.assertIn("+genpts+igndts+discardcorrupt", source,
|
||||
"Concat must use +genpts+igndts+discardcorrupt fflags")
|
||||
self.assertIn("ignore_err", source,
|
||||
"Concat must use -err_detect ignore_err")
|
||||
self.assertIn("-f", source)
|
||||
self.assertIn("concat", source)
|
||||
|
||||
def test_concat_goes_directly_to_mkv(self):
|
||||
"""Concat must produce MKV directly (not intermediate .ts) to
|
||||
preserve timestamp boundaries and avoid playback freeze at splice."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# Must contain reset_timestamps for proper segment boundary handling
|
||||
self.assertIn("reset_timestamps", source,
|
||||
"Concat must use -reset_timestamps 1 for seamless seeking")
|
||||
# Must write directly to final_path (MKV), not an intermediate .ts
|
||||
self.assertIn("_concat_did_remux", source,
|
||||
"Concat path must set flag to skip separate remux step")
|
||||
|
||||
def test_segment_time_metadata_present(self):
|
||||
"""Verify concat uses -segment_time_metadata for boundary awareness."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
self.assertIn("segment_time_metadata", source,
|
||||
"Concat must use -segment_time_metadata 1 for segment boundary handling")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 5. Recovery skip-list
|
||||
# =========================================================================
|
||||
|
||||
class RecoverySkipListTests(TestCase):
|
||||
"""Verify that the recovery function does NOT skip 'recording' status,
|
||||
since that's the exact status recordings have when the server crashes."""
|
||||
|
||||
def test_recording_status_not_in_skip_list(self):
|
||||
"""Inspect recover_recordings_on_startup to ensure 'recording' is
|
||||
NOT treated as a terminal/skip state."""
|
||||
import inspect
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
source = inspect.getsource(recover_recordings_on_startup)
|
||||
|
||||
# Find the skip condition line
|
||||
# It should be: if current_status in ("completed", "stopped"):
|
||||
# NOT: if current_status in ("completed", "stopped", "recording"):
|
||||
lines = source.split('\n')
|
||||
skip_line = None
|
||||
for line in lines:
|
||||
if 'current_status in' in line and ('completed' in line or 'stopped' in line):
|
||||
skip_line = line.strip()
|
||||
break
|
||||
|
||||
self.assertIsNotNone(skip_line, "Should find the skip-list condition")
|
||||
self.assertNotIn('"recording"', skip_line,
|
||||
"Skip list must NOT contain 'recording' — "
|
||||
"that's the status of crashed mid-stream recordings that need recovery")
|
||||
|
||||
@patch("core.utils.RedisClient")
|
||||
@patch("apps.channels.tasks.run_recording")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_recovery_processes_recording_status(self, _ws, mock_run, mock_redis_cls):
|
||||
"""A recording with status='recording' should be recovered, not skipped."""
|
||||
mock_redis_conn = MagicMock()
|
||||
mock_redis_conn.set.return_value = True # Acquire lock
|
||||
mock_redis_cls.get_client.return_value = mock_redis_conn
|
||||
|
||||
channel = _make_channel("Recovery Test", 300)
|
||||
now = timezone.now()
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": "recording",
|
||||
"program": {"title": "Crashed Show"},
|
||||
}, end_time=now + timedelta(hours=2))
|
||||
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
result = recover_recordings_on_startup()
|
||||
|
||||
# The recording should have been dispatched for recovery
|
||||
self.assertTrue(mock_run.apply_async.called,
|
||||
"Recording with status='recording' should be dispatched for recovery")
|
||||
|
||||
@patch("core.utils.RedisClient")
|
||||
@patch("apps.channels.tasks.run_recording")
|
||||
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
||||
def test_recovery_skips_stopped_recordings(self, _ws, mock_run, mock_redis_cls):
|
||||
"""A recording with status='stopped' should be skipped by recovery."""
|
||||
mock_redis_conn = MagicMock()
|
||||
mock_redis_conn.set.return_value = True
|
||||
mock_redis_cls.get_client.return_value = mock_redis_conn
|
||||
|
||||
channel = _make_channel("Recovery Skip Test", 301)
|
||||
now = timezone.now()
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"title": "Finished Show"},
|
||||
}, end_time=now + timedelta(hours=2))
|
||||
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
with patch("apps.channels.signals.revoke_task"):
|
||||
recover_recordings_on_startup()
|
||||
|
||||
# Should NOT have dispatched a recovery task
|
||||
mock_run.apply_async.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
|
||||
# =========================================================================
|
||||
|
||||
class MapRecordingsByProgramIdTests(TestCase):
|
||||
"""These test the BACKEND side — confirming that recording status
|
||||
is preserved in the API response so the frontend can filter on it.
|
||||
|
||||
The actual frontend filtering is covered by frontend/src/pages/__tests__/DVR.test.jsx
|
||||
and the guideUtils code, but we verify the data contract here."""
|
||||
|
||||
def test_recording_custom_properties_status_persisted(self):
|
||||
"""Recording status in custom_properties survives save/load cycle."""
|
||||
channel = _make_channel("Red Dot Test", 400)
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": "stopped",
|
||||
"program": {"id": 42, "title": "A Show"},
|
||||
})
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], "stopped")
|
||||
|
||||
def test_terminal_statuses_are_well_defined(self):
|
||||
"""Verify the terminal status set matches what the frontend uses."""
|
||||
# These are the statuses that should NOT show a red dot in the Guide
|
||||
terminal = {"stopped", "completed", "interrupted", "failed"}
|
||||
channel = _make_channel("Terminal Status Test", 410)
|
||||
|
||||
# Verify each status is a valid recording status
|
||||
for status in terminal:
|
||||
rec = _make_recording(channel, custom_properties={
|
||||
"status": status,
|
||||
"program": {"id": 100, "title": "Test"},
|
||||
})
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties["status"], status)
|
||||
585
apps/channels/tests/test_recording_scheduling.py
Normal file
585
apps/channels/tests/test_recording_scheduling.py
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
"""Tests for DVR recording scheduling with ClockedSchedule.
|
||||
|
||||
Uses ClockedSchedule instead of apply_async with countdown because Redis
|
||||
visibility_timeout (default 3600s) causes task redelivery for long countdowns,
|
||||
leading to duplicate recordings.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from django_celery_beat.models import ClockedSchedule, PeriodicTask
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.signals import (
|
||||
schedule_recording_task,
|
||||
revoke_task,
|
||||
_dvr_task_name,
|
||||
)
|
||||
|
||||
|
||||
class ScheduleRecordingTaskTests(TestCase):
|
||||
"""Tests for schedule_recording_task()."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_future_recording_creates_periodic_task(self, mock_run_recording):
|
||||
"""Recordings in the future create a ClockedSchedule + PeriodicTask."""
|
||||
future_time = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future_time,
|
||||
end_time=future_time + timedelta(hours=1),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=future_time)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
|
||||
pt = PeriodicTask.objects.get(name=expected_name)
|
||||
self.assertTrue(pt.one_off)
|
||||
self.assertTrue(pt.enabled)
|
||||
self.assertEqual(pt.task, "apps.channels.tasks.run_recording")
|
||||
self.assertIsNotNone(pt.clocked)
|
||||
|
||||
# apply_async should not have been called
|
||||
mock_run_recording.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_immediate_recording_creates_periodic_task(self, mock_run_recording):
|
||||
"""Recordings starting now also use ClockedSchedule for consistency."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=now)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=expected_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_past_start_time_clamps_to_now(self, mock_run_recording):
|
||||
"""Recordings with past start_time get clamped to now."""
|
||||
past_time = timezone.now() - timedelta(minutes=5)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=past_time,
|
||||
end_time=timezone.now() + timedelta(hours=1),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=past_time)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
pt = PeriodicTask.objects.get(name=expected_name)
|
||||
# Clocked time should be >= now
|
||||
self.assertGreaterEqual(pt.clocked.clocked_time, past_time)
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_reschedule_updates_existing_periodic_task(self, mock_run_recording):
|
||||
"""Calling schedule_recording_task twice updates the existing PeriodicTask."""
|
||||
future_time = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future_time,
|
||||
end_time=future_time + timedelta(hours=1),
|
||||
)
|
||||
|
||||
schedule_recording_task(rec, eta=future_time)
|
||||
|
||||
# Reschedule with a different time
|
||||
new_eta = future_time + timedelta(hours=1)
|
||||
schedule_recording_task(rec, eta=new_eta)
|
||||
|
||||
# Should still be exactly one PeriodicTask
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(PeriodicTask.objects.filter(name=task_name).count(), 1)
|
||||
|
||||
@patch("apps.channels.signals.run_recording")
|
||||
def test_naive_eta_is_made_aware(self, mock_run_recording):
|
||||
"""A naive (timezone-unaware) eta is made timezone-aware."""
|
||||
from datetime import datetime
|
||||
naive_eta = datetime(2030, 6, 15, 14, 0, 0)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=timezone.now() + timedelta(hours=1),
|
||||
end_time=timezone.now() + timedelta(hours=2),
|
||||
)
|
||||
|
||||
task_id = schedule_recording_task(rec, eta=naive_eta)
|
||||
|
||||
expected_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(task_id, expected_name)
|
||||
pt = PeriodicTask.objects.get(name=expected_name)
|
||||
self.assertTrue(timezone.is_aware(pt.clocked.clocked_time))
|
||||
|
||||
|
||||
class RevokeTaskTests(TestCase):
|
||||
"""Tests for revoke_task()."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
def test_revoke_deletes_periodic_task_and_clocked_schedule(self):
|
||||
"""revoke_task deletes the PeriodicTask and orphaned ClockedSchedule."""
|
||||
eta = timezone.now() + timedelta(hours=5)
|
||||
clocked = ClockedSchedule.objects.create(clocked_time=eta)
|
||||
PeriodicTask.objects.create(
|
||||
name="dvr-recording-10",
|
||||
task="apps.channels.tasks.run_recording",
|
||||
clocked=clocked,
|
||||
one_off=True,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
revoke_task("dvr-recording-10")
|
||||
|
||||
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
|
||||
self.assertFalse(ClockedSchedule.objects.filter(id=clocked.id).exists())
|
||||
|
||||
def test_revoke_keeps_shared_clocked_schedule(self):
|
||||
"""ClockedSchedule is kept if another PeriodicTask still references it."""
|
||||
eta = timezone.now() + timedelta(hours=5)
|
||||
clocked = ClockedSchedule.objects.create(clocked_time=eta)
|
||||
PeriodicTask.objects.create(
|
||||
name="dvr-recording-10",
|
||||
task="apps.channels.tasks.run_recording",
|
||||
clocked=clocked,
|
||||
one_off=True,
|
||||
)
|
||||
PeriodicTask.objects.create(
|
||||
name="dvr-recording-11",
|
||||
task="apps.channels.tasks.run_recording",
|
||||
clocked=clocked,
|
||||
one_off=True,
|
||||
)
|
||||
|
||||
revoke_task("dvr-recording-10")
|
||||
|
||||
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
|
||||
self.assertTrue(ClockedSchedule.objects.filter(id=clocked.id).exists())
|
||||
|
||||
@patch("apps.channels.signals.AsyncResult")
|
||||
def test_revoke_falls_back_to_async_result_for_legacy_ids(self, mock_async_result):
|
||||
"""revoke_task falls back to AsyncResult.revoke() for old-style UUIDs."""
|
||||
revoke_task("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
mock_async_result.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000")
|
||||
mock_async_result.return_value.revoke.assert_called_once()
|
||||
|
||||
def test_revoke_none_is_noop(self):
|
||||
"""revoke_task(None) does nothing."""
|
||||
revoke_task(None) # Should not raise
|
||||
|
||||
def test_revoke_empty_string_is_noop(self):
|
||||
"""revoke_task('') does nothing."""
|
||||
revoke_task("") # Should not raise
|
||||
|
||||
|
||||
class DvrTaskNameTests(TestCase):
|
||||
"""Tests for the naming convention helper."""
|
||||
|
||||
def test_task_name_format(self):
|
||||
self.assertEqual(_dvr_task_name(42), "dvr-recording-42")
|
||||
|
||||
def test_task_name_fits_in_charfield(self):
|
||||
name = _dvr_task_name(999999999)
|
||||
self.assertLessEqual(len(name), 255)
|
||||
|
||||
|
||||
class SignalIntegrationTests(TestCase):
|
||||
"""Integration tests for the post_save / post_delete signal handlers."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_save_creates_periodic_task_for_future_recording(self, mock_artwork):
|
||||
"""Saving a future Recording creates a PeriodicTask via post_save signal."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(rec.task_id, task_name)
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_delete_removes_periodic_task(self, mock_artwork):
|
||||
"""Deleting a Recording removes its PeriodicTask."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
task_name = rec.task_id
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
rec.delete()
|
||||
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_bulk_delete_cleans_up_all_periodic_tasks(self, mock_artwork):
|
||||
"""Bulk deleting recordings cleans up all their PeriodicTasks."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec_ids = []
|
||||
for i in range(5):
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future + timedelta(hours=i),
|
||||
end_time=future + timedelta(hours=i + 1),
|
||||
)
|
||||
rec_ids.append(rec.id)
|
||||
|
||||
for rid in rec_ids:
|
||||
self.assertTrue(
|
||||
PeriodicTask.objects.filter(name=f"dvr-recording-{rid}").exists()
|
||||
)
|
||||
|
||||
Recording.objects.filter(channel=self.channel).delete()
|
||||
|
||||
self.assertEqual(
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").count(), 0
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_save_schedules_currently_playing_recording(self, mock_artwork):
|
||||
"""A recording with past start_time but future end_time schedules immediately."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
past_start = timezone.now() - timedelta(minutes=30)
|
||||
future_end = timezone.now() + timedelta(minutes=30)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=past_start,
|
||||
end_time=future_end,
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertEqual(rec.task_id, task_name)
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_post_save_skips_fully_past_recording(self, mock_artwork):
|
||||
"""A recording with both start_time and end_time in the past is not scheduled."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
past_start = timezone.now() - timedelta(hours=2)
|
||||
past_end = timezone.now() - timedelta(hours=1)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=past_start,
|
||||
end_time=past_end,
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
self.assertIsNone(rec.task_id)
|
||||
self.assertFalse(
|
||||
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_pre_save_revokes_on_time_change(self, mock_artwork):
|
||||
"""Changing a recording's start_time revokes the old task and creates a new one."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
)
|
||||
|
||||
rec.refresh_from_db()
|
||||
old_task_name = rec.task_id
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=old_task_name).exists())
|
||||
|
||||
# Change the start time — pre_save clears task_id, post_save reschedules
|
||||
new_future = future + timedelta(hours=3)
|
||||
rec.start_time = new_future
|
||||
rec.end_time = new_future + timedelta(hours=1)
|
||||
rec.save()
|
||||
|
||||
rec.refresh_from_db()
|
||||
# Old PeriodicTask should be deleted; new one should exist
|
||||
self.assertIsNotNone(rec.task_id)
|
||||
self.assertTrue(
|
||||
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
|
||||
)
|
||||
|
||||
|
||||
class IdempotencyGuardTests(TestCase):
|
||||
"""Tests for the idempotency guard in run_recording()."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_skips_if_already_recording(self, mock_layer):
|
||||
"""run_recording returns early if status is already 'recording'."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording", "started_at": str(now)},
|
||||
)
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
result = run_rec_task(rec.id, self.channel.id, str(now), str(now + timedelta(hours=1)))
|
||||
|
||||
self.assertIsNone(result)
|
||||
# get_channel_layer should not have been called (returned before)
|
||||
mock_layer.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_skips_if_already_completed(self, mock_layer):
|
||||
"""run_recording returns early if status is already 'completed'."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=2),
|
||||
end_time=now - timedelta(hours=1),
|
||||
custom_properties={"status": "completed"},
|
||||
)
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
|
||||
|
||||
self.assertIsNone(result)
|
||||
mock_layer.assert_not_called()
|
||||
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_skips_if_already_stopped(self, mock_layer):
|
||||
"""run_recording returns early if status is already 'stopped' (user stopped it early)."""
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "stopped", "stopped_at": str(now)},
|
||||
)
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
|
||||
|
||||
self.assertIsNone(result)
|
||||
mock_layer.assert_not_called()
|
||||
|
||||
|
||||
class ArtworkPrefetchSignalGuardTests(TestCase):
|
||||
"""Tests that the post_save signal does not schedule artwork prefetch when
|
||||
the recording is in an active or terminal state."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_not_scheduled_when_status_recording(self, mock_artwork):
|
||||
"""post_save must NOT schedule artwork prefetch when status='recording'
|
||||
to prevent a race that overwrites the running task's status updates."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
|
||||
# Simulate a save that run_recording itself might do mid-recording
|
||||
rec.custom_properties = {"status": "recording", "file_path": "/data/recordings/test.mkv"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
# apply_async was not called for the "recording" save
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_not_scheduled_when_status_completed(self, mock_artwork):
|
||||
"""post_save must NOT schedule artwork prefetch when status='completed'."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={"status": "completed"},
|
||||
)
|
||||
|
||||
rec.custom_properties = {"status": "completed"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_not_scheduled_when_status_stopped(self, mock_artwork):
|
||||
"""post_save must NOT schedule artwork prefetch when status='stopped'."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={"status": "stopped"},
|
||||
)
|
||||
|
||||
rec.custom_properties = {"status": "stopped"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_artwork_prefetch_scheduled_for_new_upcoming_recording(self, mock_artwork):
|
||||
"""post_save SHOULD schedule artwork prefetch for a newly created upcoming recording."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={}, # no status yet — should trigger prefetch
|
||||
)
|
||||
|
||||
self.assertTrue(mock_artwork.apply_async.called)
|
||||
|
||||
|
||||
class DestroyDvrClientIsolationTests(TestCase):
|
||||
"""Tests that deleting a recording only stops DVR clients when the
|
||||
recording is actively streaming — never for completed/upcoming recordings
|
||||
that could share a channel with an unrelated in-progress recording."""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="dvr_test_admin", password="pass",
|
||||
user_level=User.UserLevel.ADMIN,
|
||||
)
|
||||
self.factory = APIRequestFactory()
|
||||
self.force_authenticate = force_authenticate
|
||||
|
||||
def _delete_recording(self, rec):
|
||||
from apps.channels.api_views import RecordingViewSet
|
||||
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
|
||||
self.force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"delete": "destroy"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients")
|
||||
def test_destroy_completed_recording_does_not_stop_dvr_clients(self, mock_stop):
|
||||
"""Deleting a completed recording must NOT call _stop_dvr_clients."""
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=timezone.now() - timedelta(hours=2),
|
||||
end_time=timezone.now() - timedelta(hours=1),
|
||||
custom_properties={"status": "completed", "file_path": "/data/recordings/test.mkv"},
|
||||
)
|
||||
self._delete_recording(rec)
|
||||
mock_stop.assert_not_called()
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients")
|
||||
def test_destroy_upcoming_recording_does_not_stop_dvr_clients(self, mock_stop):
|
||||
"""Deleting an upcoming (scheduled) recording must NOT call _stop_dvr_clients."""
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={},
|
||||
)
|
||||
self._delete_recording(rec)
|
||||
mock_stop.assert_not_called()
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients")
|
||||
def test_destroy_active_recording_does_stop_dvr_clients(self, mock_stop):
|
||||
"""Deleting an in-progress recording MUST call _stop_dvr_clients."""
|
||||
mock_stop.return_value = 1
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=5),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
self._delete_recording(rec)
|
||||
mock_stop.assert_called_once_with(str(self.channel.uuid), recording_id=rec.id)
|
||||
|
||||
|
||||
class PeriodicTaskCleanupOnExecutionTests(TestCase):
|
||||
"""Tests for PeriodicTask cleanup when run_recording starts."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
|
||||
|
||||
def tearDown(self):
|
||||
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
|
||||
ClockedSchedule.objects.all().delete()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
@patch("apps.channels.tasks.get_channel_layer")
|
||||
def test_periodic_task_cleaned_up_on_execution(self, mock_layer, mock_artwork):
|
||||
"""When run_recording executes, it deletes its own PeriodicTask."""
|
||||
mock_layer.return_value = MagicMock()
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=future,
|
||||
end_time=future + timedelta(hours=1),
|
||||
custom_properties={},
|
||||
)
|
||||
|
||||
# post_save signal should have created the PeriodicTask
|
||||
task_name = f"dvr-recording-{rec.id}"
|
||||
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
pt = PeriodicTask.objects.get(name=task_name)
|
||||
clocked_id = pt.clocked_id
|
||||
|
||||
from apps.channels.tasks import run_recording as run_rec_task
|
||||
# This will proceed past guards, clean up the PeriodicTask, then
|
||||
# eventually fail on the actual stream connection (expected)
|
||||
try:
|
||||
run_rec_task(rec.id, self.channel.id, str(future), str(future + timedelta(hours=1)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
||||
self.assertFalse(ClockedSchedule.objects.filter(id=clocked_id).exists())
|
||||
356
apps/channels/tests/test_recording_stop_cancel.py
Normal file
356
apps/channels/tests/test_recording_stop_cancel.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
"""Tests for the DVR Stop/Cancel feature set.
|
||||
|
||||
Covers:
|
||||
- stop() endpoint
|
||||
- destroy() was_in_progress field in recording_cancelled WebSocket event
|
||||
- signals.py update_fields re-entrancy guard
|
||||
- run_recording race guard before status write
|
||||
- _stop_dvr_clients() DVR client isolation
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.models import Channel, Recording
|
||||
from apps.channels.api_views import RecordingViewSet, _stop_dvr_clients
|
||||
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
u, _ = User.objects.get_or_create(
|
||||
username="stop_test_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
u.set_password("pass")
|
||||
u.save()
|
||||
return u
|
||||
|
||||
|
||||
def _async_channel_layer_mock():
|
||||
layer = MagicMock()
|
||||
layer.group_send = AsyncMock()
|
||||
return layer
|
||||
|
||||
|
||||
class StopEndpointTests(TestCase):
|
||||
"""Tests for POST /api/channels/recordings/{id}/stop/"""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=99, name="Stop Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _stop(self, rec):
|
||||
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "stop"})
|
||||
return view(request, pk=rec.id)
|
||||
|
||||
def _make_rec(self, status="recording"):
|
||||
now = timezone.now()
|
||||
return Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": status},
|
||||
)
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
@patch("threading.Thread")
|
||||
def test_stop_writes_status_to_db_before_returning(self, mock_thread, mock_ws):
|
||||
"""DB write is synchronous — run_recording polls for this."""
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
response = self._stop(rec)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data.get("success"))
|
||||
rec.refresh_from_db()
|
||||
self.assertEqual(rec.custom_properties.get("status"), "stopped")
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
@patch("threading.Thread")
|
||||
def test_stop_writes_stopped_at_timestamp(self, mock_thread, mock_ws):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec()
|
||||
self._stop(rec)
|
||||
rec.refresh_from_db()
|
||||
self.assertIn("stopped_at", rec.custom_properties)
|
||||
|
||||
def test_stop_calls_stop_dvr_clients_in_background(self):
|
||||
"""stop() spawns a background thread whose target calls _stop_dvr_clients."""
|
||||
rec = self._make_rec()
|
||||
|
||||
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop, \
|
||||
patch("core.utils.send_websocket_update"), \
|
||||
patch("threading.Thread") as mock_thread:
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
self._stop(rec)
|
||||
|
||||
# Verify a daemon thread was spawned
|
||||
mock_thread.assert_called_once()
|
||||
thread_kwargs = mock_thread.call_args[1]
|
||||
self.assertTrue(thread_kwargs.get("daemon"), "Thread must be daemon")
|
||||
|
||||
# Execute the captured target with DB connection close patched out
|
||||
target = thread_kwargs["target"]
|
||||
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop2, \
|
||||
patch("apps.channels.signals.revoke_task", side_effect=Exception("skip")), \
|
||||
patch("django.db.connection") as mock_conn:
|
||||
target()
|
||||
|
||||
self.assertTrue(mock_stop2.called)
|
||||
args, kwargs = mock_stop2.call_args
|
||||
actual_rec_id = kwargs.get("recording_id") or (args[1] if len(args) > 1 else None)
|
||||
self.assertEqual(actual_rec_id, rec.id)
|
||||
|
||||
def test_stop_returns_404_for_nonexistent(self):
|
||||
request = self.factory.post("/api/channels/recordings/99999/stop/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = RecordingViewSet.as_view({"post": "stop"})
|
||||
self.assertEqual(view(request, pk=99999).status_code, 404)
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
@patch("threading.Thread")
|
||||
def test_stop_idempotent_on_already_stopped(self, mock_thread, mock_ws):
|
||||
mock_thread.return_value.start = MagicMock()
|
||||
rec = self._make_rec(status="stopped")
|
||||
self.assertEqual(self._stop(rec).status_code, 200)
|
||||
|
||||
|
||||
class CancelDestroyWasInProgressTests(TestCase):
|
||||
"""was_in_progress field in the recording_cancelled WebSocket event."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=98, name="Cancel Test Channel")
|
||||
self.user = _make_admin()
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _delete(self, rec):
|
||||
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
|
||||
force_authenticate(request, user=self.user)
|
||||
return RecordingViewSet.as_view({"delete": "destroy"})(request, pk=rec.id)
|
||||
|
||||
@patch("apps.channels.api_views._stop_dvr_clients", return_value=1)
|
||||
@patch("core.utils.send_websocket_update")
|
||||
def test_in_progress_sends_was_in_progress_true(self, mock_ws, _):
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=10),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "recording"},
|
||||
)
|
||||
self._delete(rec)
|
||||
payload = mock_ws.call_args[0][2]
|
||||
self.assertEqual(payload["type"], "recording_cancelled")
|
||||
self.assertTrue(payload["was_in_progress"])
|
||||
|
||||
@patch("core.utils.send_websocket_update")
|
||||
def test_completed_sends_was_in_progress_false(self, mock_ws):
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=timezone.now() - timedelta(hours=2),
|
||||
end_time=timezone.now() - timedelta(hours=1),
|
||||
custom_properties={"status": "completed"},
|
||||
)
|
||||
self._delete(rec)
|
||||
self.assertFalse(mock_ws.call_args[0][2]["was_in_progress"])
|
||||
|
||||
|
||||
class SignalUpdateFieldsReentrancyGuardTests(TestCase):
|
||||
"""update_fields guard in schedule_task_on_save prevents redundant WS events."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=97, name="Signal Guard Channel")
|
||||
|
||||
def _create_upcoming(self):
|
||||
future = timezone.now() + timedelta(hours=2)
|
||||
return Recording.objects.create(
|
||||
channel=self.channel, start_time=future,
|
||||
end_time=future + timedelta(hours=1), custom_properties={},
|
||||
)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_custom_properties_save_skips_artwork(self, mock_artwork):
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
rec.custom_properties = {"poster_url": "https://example.com/p.jpg"}
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_task_id_save_skips_artwork(self, mock_artwork):
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
rec.task_id = "dvr-recording-999"
|
||||
rec.save(update_fields=["task_id"])
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_combined_metadata_save_skips_artwork(self, mock_artwork):
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
rec.task_id = "dvr-recording-1000"
|
||||
rec.custom_properties = {"poster_url": "x"}
|
||||
rec.save(update_fields=["custom_properties", "task_id"])
|
||||
mock_artwork.apply_async.assert_not_called()
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_creation_dispatches_artwork(self, mock_artwork):
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
self._create_upcoming()
|
||||
self.assertTrue(mock_artwork.apply_async.called)
|
||||
|
||||
@patch("apps.channels.signals.prefetch_recording_artwork")
|
||||
def test_scheduling_field_update_dispatches_artwork(self, mock_artwork):
|
||||
"""save(update_fields=['start_time']) is not a metadata save — dispatch runs."""
|
||||
mock_artwork.apply_async.return_value = MagicMock()
|
||||
rec = self._create_upcoming()
|
||||
mock_artwork.reset_mock()
|
||||
future = timezone.now() + timedelta(hours=3)
|
||||
rec.start_time = future
|
||||
rec.end_time = future + timedelta(hours=1)
|
||||
rec.save(update_fields=["start_time", "end_time"])
|
||||
mock_artwork.apply_async.assert_called()
|
||||
|
||||
|
||||
class RunRecordingRaceGuardTests(TestCase):
|
||||
"""Race guard: stop() fires between idempotency check and status write."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=96, name="Race Guard Channel")
|
||||
|
||||
def test_race_guard_exits_when_stopped_at_db_read(self):
|
||||
"""If Recording.objects.get() shows 'stopped', the task must exit
|
||||
without writing 'recording' to the DB."""
|
||||
from apps.channels.tasks import run_recording as run_rec
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=1),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={},
|
||||
)
|
||||
mock_layer = _async_channel_layer_mock()
|
||||
original_get = Recording.objects.get
|
||||
|
||||
def patched_get(*args, **kwargs):
|
||||
obj = original_get(*args, **kwargs)
|
||||
if kwargs.get("id") == rec.id or (args and args[0] == rec.id):
|
||||
obj.custom_properties = {"status": "stopped"}
|
||||
return obj
|
||||
|
||||
with patch("apps.channels.tasks.get_channel_layer", return_value=mock_layer), \
|
||||
patch("core.utils.log_system_event", side_effect=Exception("skip")), \
|
||||
patch.object(Recording.objects, "get", side_effect=patched_get):
|
||||
result = run_rec(
|
||||
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
rec.refresh_from_db()
|
||||
self.assertNotEqual(
|
||||
rec.custom_properties.get("status"), "recording",
|
||||
"Race guard failed: task overwrote 'stopped' with 'recording'",
|
||||
)
|
||||
|
||||
def test_idempotency_guard_catches_stopped_before_channel_layer(self):
|
||||
"""When status='stopped' at the idempotency check, get_channel_layer is never called."""
|
||||
from apps.channels.tasks import run_recording as run_rec
|
||||
now = timezone.now()
|
||||
rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now - timedelta(minutes=5),
|
||||
end_time=now + timedelta(hours=1),
|
||||
custom_properties={"status": "stopped"},
|
||||
)
|
||||
with patch("apps.channels.tasks.get_channel_layer") as mock_get_layer:
|
||||
result = run_rec(
|
||||
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
mock_get_layer.assert_not_called()
|
||||
|
||||
|
||||
class StopDvrClientsTests(TestCase):
|
||||
"""_stop_dvr_clients() DVR client isolation."""
|
||||
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel")
|
||||
self._redis = "core.utils.RedisClient"
|
||||
self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client"
|
||||
self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel"
|
||||
|
||||
def _mock_redis(self, client_ids, ua_map):
|
||||
r = MagicMock()
|
||||
r.smembers.return_value = {c.encode() for c in client_ids}
|
||||
def hget_side(key, field):
|
||||
ks = key if isinstance(key, str) else key.decode("utf-8", errors="replace")
|
||||
for cid, ua in ua_map.items():
|
||||
if cid in ks:
|
||||
return ua.encode() if isinstance(ua, str) else ua
|
||||
return b""
|
||||
r.hget.side_effect = hget_side
|
||||
return r
|
||||
|
||||
def test_returns_zero_when_redis_none(self):
|
||||
with patch(self._redis) as rc:
|
||||
rc.get_client.return_value = None
|
||||
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
|
||||
|
||||
def test_stops_only_matching_client_when_recording_id_given(self):
|
||||
r = self._mock_redis(
|
||||
["client-a", "client-b"],
|
||||
{"client-a": "Dispatcharr-DVR/recording-42",
|
||||
"client-b": "Dispatcharr-DVR/recording-99"},
|
||||
)
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
result = _stop_dvr_clients(str(self.channel.uuid), recording_id=42)
|
||||
self.assertEqual(result, 1)
|
||||
stopped = [c[0][1] for c in sc.call_args_list]
|
||||
self.assertIn("client-a", stopped)
|
||||
self.assertNotIn("client-b", stopped)
|
||||
|
||||
def test_stops_all_dvr_clients_without_recording_id(self):
|
||||
r = self._mock_redis(
|
||||
["client-a", "client-b"],
|
||||
{"client-a": "Dispatcharr-DVR/recording-42",
|
||||
"client-b": "Dispatcharr-DVR/recording-99"},
|
||||
)
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
result = _stop_dvr_clients(str(self.channel.uuid))
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
def test_skips_non_dvr_clients(self):
|
||||
r = self._mock_redis(
|
||||
["viewer", "dvr-client"],
|
||||
{"viewer": "Mozilla/5.0", "dvr-client": "Dispatcharr-DVR/recording-1"},
|
||||
)
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
result = _stop_dvr_clients(str(self.channel.uuid))
|
||||
self.assertEqual(result, 1)
|
||||
stopped = [c[0][1] for c in sc.call_args_list]
|
||||
self.assertNotIn("viewer", stopped)
|
||||
|
||||
def test_returns_zero_for_empty_channel(self):
|
||||
r = MagicMock()
|
||||
r.smembers.return_value = set()
|
||||
with patch(self._redis) as rc, patch(self._sc) as sc:
|
||||
rc.get_client.return_value = r
|
||||
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
|
||||
sc.assert_not_called()
|
||||
|
||||
def test_never_calls_stop_channel(self):
|
||||
"""Must not stop the whole channel proxy — only individual clients."""
|
||||
r = self._mock_redis(["dvr-1"], {"dvr-1": "Dispatcharr-DVR/recording-1"})
|
||||
with patch(self._redis) as rc, patch(self._sc), patch(self._sch) as sch:
|
||||
rc.get_client.return_value = r
|
||||
_stop_dvr_clients(str(self.channel.uuid))
|
||||
sch.assert_not_called()
|
||||
324
apps/channels/tests/test_ts_proxy_keepalive.py
Normal file
324
apps/channels/tests/test_ts_proxy_keepalive.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""Tests for ts_proxy keepalive and stats-update behavior.
|
||||
|
||||
Covers:
|
||||
- stream_generator._should_send_keepalive() owner vs non-owner worker paths
|
||||
- stream_generator._should_send_keepalive() Redis last_data health check
|
||||
- client_manager._do_stats_update() error handling and WebSocket dispatch
|
||||
- client_manager.remove_client() non-blocking stats update
|
||||
- Keepalive/DVR-timeout timing invariants
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_send_keepalive: owner worker path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OwnerWorkerKeepaliveTests(TestCase):
|
||||
"""Owner worker has a stream_manager; keepalive logic uses it directly."""
|
||||
|
||||
def _make_generator(self, healthy, at_buffer_head, consecutive_empty):
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000001"
|
||||
gen.client_id = "test-client"
|
||||
|
||||
buffer = MagicMock()
|
||||
buffer.index = 10 if at_buffer_head else 100
|
||||
gen.local_index = 10
|
||||
gen.buffer = buffer
|
||||
|
||||
stream_manager = MagicMock()
|
||||
stream_manager.healthy = healthy
|
||||
gen.stream_manager = stream_manager
|
||||
|
||||
gen.consecutive_empty = consecutive_empty
|
||||
return gen
|
||||
|
||||
def test_owner_healthy_returns_false(self):
|
||||
"""Owner worker, healthy stream -> no keepalive."""
|
||||
gen = self._make_generator(healthy=True, at_buffer_head=True, consecutive_empty=10)
|
||||
self.assertFalse(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_unhealthy_at_head_returns_true(self):
|
||||
"""Owner worker, unhealthy stream, at buffer head -> send keepalive."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=10)
|
||||
self.assertTrue(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_unhealthy_not_at_head_returns_false(self):
|
||||
"""Owner worker, unhealthy stream, but NOT at buffer head -> no keepalive."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=False, consecutive_empty=10)
|
||||
self.assertFalse(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_insufficient_consecutive_empty_returns_false(self):
|
||||
"""Owner worker, unhealthy, at head but consecutive_empty < 5 -> no keepalive."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=3)
|
||||
self.assertFalse(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
def test_owner_exactly_5_consecutive_empty_returns_true(self):
|
||||
"""consecutive_empty == 5 is the minimum threshold."""
|
||||
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=5)
|
||||
self.assertTrue(gen._should_send_keepalive(gen.local_index))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_send_keepalive: non-owner worker path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class NonOwnerWorkerKeepaliveTests(TestCase):
|
||||
"""Non-owner worker has stream_manager=None; health determined from Redis."""
|
||||
|
||||
def _make_generator(self, consecutive_empty=10):
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000002"
|
||||
gen.client_id = "test-client-nonowner"
|
||||
|
||||
buffer = MagicMock()
|
||||
buffer.index = 10
|
||||
gen.local_index = 10
|
||||
gen.buffer = buffer
|
||||
|
||||
gen.stream_manager = None # non-owner worker
|
||||
gen.consecutive_empty = consecutive_empty
|
||||
return gen
|
||||
|
||||
def _mock_proxy_server(self, last_data_value):
|
||||
"""Return a mock ProxyServer with a redis_client pre-configured."""
|
||||
server = MagicMock()
|
||||
redis_client = MagicMock()
|
||||
server.redis_client = redis_client
|
||||
redis_client.get.return_value = last_data_value
|
||||
return server
|
||||
|
||||
def test_non_owner_fresh_data_returns_false(self):
|
||||
"""Non-owner, last_data < 10s ago -> stream healthy -> no keepalive."""
|
||||
gen = self._make_generator()
|
||||
fresh_ts = str(time.time() - 2.0).encode()
|
||||
server = self._mock_proxy_server(fresh_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result, "Fresh data should NOT trigger keepalive")
|
||||
|
||||
def test_non_owner_stale_data_returns_true(self):
|
||||
"""Non-owner, last_data >= 10s ago -> stream unhealthy -> send keepalive."""
|
||||
gen = self._make_generator()
|
||||
stale_ts = str(time.time() - 12.0).encode()
|
||||
server = self._mock_proxy_server(stale_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertTrue(result, "Stale data (12s) should trigger keepalive")
|
||||
|
||||
def test_non_owner_exactly_at_timeout_returns_true(self):
|
||||
"""Data age exactly equal to CONNECTION_TIMEOUT (10s) -> send keepalive."""
|
||||
gen = self._make_generator()
|
||||
ts = str(time.time() - 10.0).encode()
|
||||
server = self._mock_proxy_server(ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertTrue(result, "Data at exactly timeout threshold should trigger keepalive")
|
||||
|
||||
def test_non_owner_no_redis_key_returns_true(self):
|
||||
"""Non-owner, last_data key missing from Redis -> assume unhealthy."""
|
||||
gen = self._make_generator()
|
||||
server = self._mock_proxy_server(None)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertTrue(result, "Missing last_data key should trigger keepalive")
|
||||
|
||||
def test_non_owner_redis_client_none_returns_false(self):
|
||||
"""Non-owner, redis_client is None (disconnected) -> conservative, no keepalive."""
|
||||
gen = self._make_generator()
|
||||
server = MagicMock()
|
||||
server.redis_client = None
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result, "No redis_client -> conservative, no keepalive")
|
||||
|
||||
def test_non_owner_redis_exception_returns_false(self):
|
||||
"""Non-owner, Redis raises an exception -> conservative, no keepalive."""
|
||||
gen = self._make_generator()
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.side_effect = Exception("Redis error")
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result, "Redis error -> conservative, no keepalive")
|
||||
|
||||
def test_non_owner_not_at_buffer_head_returns_false(self):
|
||||
"""Non-owner, NOT at buffer head -> no keepalive regardless of Redis."""
|
||||
gen = self._make_generator()
|
||||
gen.buffer.index = 100 # far ahead of local_index=10
|
||||
server = self._mock_proxy_server(None)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_non_owner_insufficient_consecutive_empty_returns_false(self):
|
||||
"""Non-owner, at head, but consecutive_empty < 5 -> no keepalive."""
|
||||
gen = self._make_generator(consecutive_empty=2)
|
||||
stale_ts = str(time.time() - 30.0).encode()
|
||||
server = self._mock_proxy_server(stale_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _do_stats_update: error handling and WebSocket dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DoStatsUpdateTests(TestCase):
|
||||
"""_do_stats_update runs the actual Redis scan + WebSocket call."""
|
||||
|
||||
def _make_client_manager(self):
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
cm = ClientManager.__new__(ClientManager)
|
||||
cm.channel_id = "00000000-0000-0000-0000-000000000004"
|
||||
cm._heartbeat_running = False
|
||||
return cm
|
||||
|
||||
def test_do_stats_update_calls_send_websocket_update(self):
|
||||
"""_do_stats_update must call send_websocket_update with channel_stats."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.scan.return_value = (0, [])
|
||||
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \
|
||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
||||
cm._do_stats_update()
|
||||
|
||||
mock_ws.assert_called_once()
|
||||
event_type = mock_ws.call_args[0][1]
|
||||
self.assertEqual(event_type, "update")
|
||||
payload = mock_ws.call_args[0][2]
|
||||
self.assertEqual(payload["type"], "channel_stats")
|
||||
|
||||
def test_do_stats_update_does_not_raise_on_redis_error(self):
|
||||
"""Redis failure must be swallowed (logged), not propagated."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")):
|
||||
try:
|
||||
cm._do_stats_update()
|
||||
except Exception as e:
|
||||
self.fail(f"_do_stats_update raised an exception: {e}")
|
||||
|
||||
def test_do_stats_update_scans_channel_client_keys(self):
|
||||
"""Must scan for ts_proxy:channel:*:clients pattern."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.scan.return_value = (0, [])
|
||||
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \
|
||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
||||
cm._do_stats_update()
|
||||
|
||||
scan_call = mock_redis.scan.call_args
|
||||
self.assertIn("ts_proxy:channel:*:clients", str(scan_call))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: remove_client must not block on WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ClientRemoveIntegrationTests(TestCase):
|
||||
"""When remove_client() fires, _trigger_stats_update must not block."""
|
||||
|
||||
def test_remove_client_does_not_block_on_websocket(self):
|
||||
"""remove_client() must return quickly even if WebSocket is slow."""
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
|
||||
cm = ClientManager.__new__(ClientManager)
|
||||
cm.channel_id = "00000000-0000-0000-0000-000000000005"
|
||||
cm._heartbeat_running = False
|
||||
cm.clients = {"test-client-1"}
|
||||
cm.last_heartbeat_time = {"test-client-1": time.time()}
|
||||
cm.last_active_time = time.time()
|
||||
cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients"
|
||||
cm.client_ttl = 60
|
||||
cm.worker_id = "worker-1"
|
||||
cm.proxy_server = MagicMock()
|
||||
cm.proxy_server.am_i_owner.return_value = False
|
||||
cm.lock = threading.Lock()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.hgetall.return_value = {b"ip_address": b"127.0.0.1"}
|
||||
mock_redis.scard.return_value = 1
|
||||
cm.redis_client = mock_redis
|
||||
|
||||
slow_ws_called = threading.Event()
|
||||
|
||||
def slow_websocket(*args, **kwargs):
|
||||
time.sleep(2.0)
|
||||
slow_ws_called.set()
|
||||
|
||||
start = time.time()
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket):
|
||||
cm.remove_client("test-client-1")
|
||||
elapsed = time.time() - start
|
||||
|
||||
self.assertLess(elapsed, 1.0,
|
||||
f"remove_client() blocked for {elapsed:.2f}s waiting for WebSocket "
|
||||
f"(should dispatch to background thread and return immediately)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DVR timeout threshold vs keepalive timing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KeepaliveTimingTests(TestCase):
|
||||
"""Verify that keepalive threshold gives sufficient margin before DVR timeout."""
|
||||
|
||||
def test_keepalive_threshold_less_than_dvr_timeout(self):
|
||||
"""CONNECTION_TIMEOUT (keepalive trigger) must be < DVR read timeout (15s)."""
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
|
||||
dvr_read_timeout = 15 # hard-coded in run_recording: timeout=(10, 15)
|
||||
self.assertLess(
|
||||
connection_timeout,
|
||||
dvr_read_timeout,
|
||||
f"CONNECTION_TIMEOUT ({connection_timeout}s) must be < DVR timeout ({dvr_read_timeout}s) "
|
||||
f"so keepalives fire before DVR times out",
|
||||
)
|
||||
|
||||
def test_keepalive_interval_is_short(self):
|
||||
"""KEEPALIVE_INTERVAL must be short enough to send multiple keepalives in the gap."""
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
interval = getattr(Config, "KEEPALIVE_INTERVAL", 0.5)
|
||||
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
|
||||
remaining_window = 15 - connection_timeout
|
||||
self.assertGreater(
|
||||
remaining_window / interval,
|
||||
3,
|
||||
f"KEEPALIVE_INTERVAL ({interval}s) is too long: only "
|
||||
f"{remaining_window/interval:.1f} keepalives would fit in the "
|
||||
f"{remaining_window}s window before DVR timeout",
|
||||
)
|
||||
169
apps/channels/tests/test_validate_url.py
Normal file
169
apps/channels/tests/test_validate_url.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Tests for the _validate_url() helper in tasks.py.
|
||||
|
||||
Covers:
|
||||
- Rejection of None, empty, and non-string inputs
|
||||
- Non-HTTP URLs pass through without network requests
|
||||
- HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail)
|
||||
- Network errors (timeout, connection) treated as failures
|
||||
- Per-worker result cache: hits, expiry, eviction
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL
|
||||
|
||||
|
||||
class ValidateUrlInputTests(TestCase):
|
||||
"""Input validation — no network requests should be made."""
|
||||
|
||||
def setUp(self):
|
||||
_url_validation_cache.clear()
|
||||
|
||||
def test_none_returns_false(self):
|
||||
self.assertFalse(_validate_url(None))
|
||||
|
||||
def test_empty_string_returns_false(self):
|
||||
self.assertFalse(_validate_url(""))
|
||||
|
||||
def test_non_string_returns_false(self):
|
||||
self.assertFalse(_validate_url(123))
|
||||
self.assertFalse(_validate_url(["http://example.com"]))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_non_http_url_returns_true_without_request(self, mock_head):
|
||||
"""file:// and other non-HTTP schemes skip validation."""
|
||||
self.assertTrue(_validate_url("file:///local/path.jpg"))
|
||||
self.assertTrue(_validate_url("/data/images/poster.jpg"))
|
||||
mock_head.assert_not_called()
|
||||
|
||||
|
||||
class ValidateUrlNetworkTests(TestCase):
|
||||
"""HTTP(S) URL validation via HEAD request."""
|
||||
|
||||
def setUp(self):
|
||||
_url_validation_cache.clear()
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_200_returns_true(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
self.assertTrue(_validate_url("https://example.com/poster.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_302_redirect_returns_true(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=302)
|
||||
self.assertTrue(_validate_url("https://example.com/redirect"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_404_returns_false(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=404)
|
||||
self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_500_returns_false(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=500)
|
||||
self.assertFalse(_validate_url("https://broken.com/error"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_timeout_returns_false(self, mock_head):
|
||||
import requests
|
||||
mock_head.side_effect = requests.Timeout("timed out")
|
||||
self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_connection_error_returns_false(self, mock_head):
|
||||
import requests
|
||||
mock_head.side_effect = requests.ConnectionError("refused")
|
||||
self.assertFalse(_validate_url("https://unreachable.com/poster.jpg"))
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_custom_timeout_passed_to_head(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
_validate_url("https://example.com/img.jpg", timeout=10)
|
||||
mock_head.assert_called_once_with(
|
||||
"https://example.com/img.jpg", timeout=10, allow_redirects=True
|
||||
)
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_405_falls_back_to_get(self, mock_head, mock_get):
|
||||
"""When HEAD returns 405, fall back to a ranged GET request."""
|
||||
mock_head.return_value = MagicMock(status_code=405)
|
||||
mock_resp = MagicMock(status_code=200)
|
||||
mock_get.return_value = mock_resp
|
||||
self.assertTrue(_validate_url("https://no-head.com/poster.jpg"))
|
||||
mock_get.assert_called_once()
|
||||
mock_resp.close.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.requests.get")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_405_fallback_get_also_fails(self, mock_head, mock_get):
|
||||
"""When HEAD returns 405 and GET also fails, return False."""
|
||||
mock_head.return_value = MagicMock(status_code=405)
|
||||
mock_get.return_value = MagicMock(status_code=403)
|
||||
self.assertFalse(_validate_url("https://blocked.com/poster.jpg"))
|
||||
|
||||
|
||||
class ValidateUrlCacheTests(TestCase):
|
||||
"""Per-worker result caching."""
|
||||
|
||||
def setUp(self):
|
||||
_url_validation_cache.clear()
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_cache_hit_avoids_second_request(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
url = "https://cached.com/poster.jpg"
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertTrue(_validate_url(url))
|
||||
mock_head.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_cache_hit_returns_false_for_failed_url(self, mock_head):
|
||||
mock_head.return_value = MagicMock(status_code=404)
|
||||
url = "https://dead.com/missing.jpg"
|
||||
self.assertFalse(_validate_url(url))
|
||||
self.assertFalse(_validate_url(url))
|
||||
mock_head.assert_called_once()
|
||||
|
||||
@patch("apps.channels.tasks.time.monotonic")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_cache_expiry_triggers_new_request(self, mock_head, mock_time):
|
||||
"""After TTL expires, a new HEAD request is made."""
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
url = "https://expiring.com/poster.jpg"
|
||||
|
||||
mock_time.return_value = 1000.0
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertEqual(mock_head.call_count, 1)
|
||||
|
||||
# Within TTL — cache hit
|
||||
mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertEqual(mock_head.call_count, 1)
|
||||
|
||||
# Past TTL — new request
|
||||
mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1
|
||||
self.assertTrue(_validate_url(url))
|
||||
self.assertEqual(mock_head.call_count, 2)
|
||||
|
||||
@patch("apps.channels.tasks.time.monotonic")
|
||||
@patch("apps.channels.tasks.requests.head")
|
||||
def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time):
|
||||
"""Expired entries are evicted when cache grows past 512 entries."""
|
||||
mock_head.return_value = MagicMock(status_code=200)
|
||||
|
||||
# Fill cache with 513 entries at time 0
|
||||
mock_time.return_value = 0.0
|
||||
for i in range(513):
|
||||
_url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0)
|
||||
|
||||
# Advance past TTL and add one more — triggers eviction
|
||||
mock_time.return_value = _URL_CACHE_TTL + 1
|
||||
_validate_url("https://trigger-eviction.com/img.jpg")
|
||||
|
||||
# All 513 old entries expired and should be evicted
|
||||
remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")]
|
||||
self.assertEqual(len(remaining), 0)
|
||||
# The new entry should remain
|
||||
self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache)
|
||||
0
apps/connect/__init__.py
Normal file
0
apps/connect/__init__.py
Normal file
17
apps/connect/api_urls.py
Normal file
17
apps/connect/api_urls.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import (
|
||||
IntegrationViewSet,
|
||||
EventSubscriptionViewSet,
|
||||
DeliveryLogViewSet,
|
||||
)
|
||||
|
||||
app_name = 'connect'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
||||
router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription')
|
||||
router.register(r'logs', DeliveryLogViewSet, basename='delivery-log')
|
||||
|
||||
urlpatterns = []
|
||||
urlpatterns += router.urls
|
||||
198
apps/connect/api_views.py
Normal file
198
apps/connect/api_views.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
from rest_framework import viewsets, status
|
||||
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 .models import Integration, EventSubscription, DeliveryLog
|
||||
from .serializers import (
|
||||
IntegrationSerializer,
|
||||
EventSubscriptionSerializer,
|
||||
DeliveryLogSerializer,
|
||||
)
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
IsAdmin,
|
||||
)
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
|
||||
|
||||
class IntegrationViewSet(viewsets.ModelViewSet):
|
||||
queryset = Integration.objects.all()
|
||||
serializer_class = IntegrationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
perms = permission_classes_by_action[self.action]
|
||||
except KeyError:
|
||||
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
|
||||
perms = getattr(self, "permission_classes", [Authenticated])
|
||||
return [perm() for perm in perms]
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="subscriptions")
|
||||
def list_subscriptions(self, request, pk=None):
|
||||
qs = EventSubscription.objects.filter(integration_id=pk)
|
||||
serializer = EventSubscriptionSerializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
|
||||
def set_subscriptions(self, request, pk=None):
|
||||
"""
|
||||
Replace the integration's subscriptions with the provided list.
|
||||
Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...]
|
||||
Any existing subscriptions not in the list will be deleted; missing ones will be created/updated.
|
||||
"""
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
except Integration.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
data = request.data
|
||||
if not isinstance(data, list):
|
||||
return Response(
|
||||
{"detail": "Expected a list of subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate incoming items using serializer (without integration field)
|
||||
# We'll attach the integration explicitly
|
||||
valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES)
|
||||
incoming = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return Response(
|
||||
{"detail": "Each subscription must be an object"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
event = item.get("event")
|
||||
if event not in valid_events:
|
||||
return Response(
|
||||
{"detail": f"Invalid event: {event}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Only accept payload_template when the integration is a webhook
|
||||
payload_template = item.get("payload_template") if integration.type == "webhook" else None
|
||||
incoming.append(
|
||||
{
|
||||
"event": event,
|
||||
"enabled": bool(item.get("enabled", True)),
|
||||
"payload_template": payload_template,
|
||||
}
|
||||
)
|
||||
|
||||
incoming_events = {s["event"] for s in incoming}
|
||||
|
||||
# Delete subscriptions that are no longer present
|
||||
EventSubscription.objects.filter(integration=integration).exclude(
|
||||
event__in=incoming_events
|
||||
).delete()
|
||||
|
||||
# Upsert incoming subscriptions
|
||||
updated = []
|
||||
for sub in incoming:
|
||||
obj, _created = EventSubscription.objects.update_or_create(
|
||||
integration=integration,
|
||||
event=sub["event"],
|
||||
defaults={
|
||||
"enabled": sub["enabled"],
|
||||
"payload_template": sub.get("payload_template"),
|
||||
},
|
||||
)
|
||||
updated.append(obj)
|
||||
|
||||
serializer = EventSubscriptionSerializer(updated, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin])
|
||||
def test(self, request, pk=None):
|
||||
"""
|
||||
Execute a saved integration (connect) with a dummy payload to verify configuration.
|
||||
"""
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Build a dummy payload similar to system events
|
||||
now = timezone.now().isoformat()
|
||||
dummy_payload = {
|
||||
"event": "test",
|
||||
"timestamp": now,
|
||||
"channel_name": "Test Channel",
|
||||
"stream_name": "Test Stream",
|
||||
"stream_url": "http://example.com/stream.m3u8",
|
||||
"channel_url": "http://example.com/stream.m3u8",
|
||||
"provider_name": "Test Provider",
|
||||
"profile_used": "Default",
|
||||
"test": True,
|
||||
}
|
||||
|
||||
# Choose handler based on saved type
|
||||
if integration.type == "webhook":
|
||||
handler = WebhookHandler(integration, None, dummy_payload)
|
||||
elif integration.type == "script":
|
||||
handler = ScriptHandler(integration, None, dummy_payload)
|
||||
else:
|
||||
return Response(
|
||||
{"success": False, "error": f"Unsupported integration type: {integration.type}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
result = handler.execute()
|
||||
return Response(
|
||||
{
|
||||
"success": bool(result.get("success")),
|
||||
"type": integration.type,
|
||||
"request_payload": dummy_payload,
|
||||
"result": result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"type": integration.type,
|
||||
"request_payload": dummy_payload,
|
||||
"error": str(e),
|
||||
},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
|
||||
class EventSubscriptionViewSet(viewsets.ModelViewSet):
|
||||
queryset = EventSubscription.objects.all()
|
||||
serializer_class = EventSubscriptionSerializer
|
||||
|
||||
|
||||
class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = DeliveryLog.objects.all().order_by("-created_at")
|
||||
serializer_class = DeliveryLogSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
# Support server-side pagination with page_size query param
|
||||
class ConnectLogsPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 250
|
||||
|
||||
pagination_class = ConnectLogsPagination
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
|
||||
# Optional filters: integration id and type
|
||||
integration_id = self.request.query_params.get("integration")
|
||||
if integration_id:
|
||||
qs = qs.filter(subscription__integration_id=integration_id)
|
||||
|
||||
integration_type = self.request.query_params.get("type")
|
||||
if integration_type:
|
||||
qs = qs.filter(subscription__integration__type=integration_type)
|
||||
|
||||
return qs
|
||||
8
apps/connect/apps.py
Normal file
8
apps/connect/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConnectConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.connect'
|
||||
verbose_name = "Connect Integrations"
|
||||
label = 'dispatcharr_connect'
|
||||
0
apps/connect/handlers/__init__.py
Normal file
0
apps/connect/handlers/__init__.py
Normal file
0
apps/connect/handlers/api.py
Normal file
0
apps/connect/handlers/api.py
Normal file
12
apps/connect/handlers/base.py
Normal file
12
apps/connect/handlers/base.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# connect/handlers/base.py
|
||||
import abc
|
||||
|
||||
class IntegrationHandler(abc.ABC):
|
||||
def __init__(self, integration, subscription, payload):
|
||||
self.integration = integration
|
||||
self.subscription = subscription
|
||||
self.payload = payload
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute(self):
|
||||
pass
|
||||
81
apps/connect/handlers/script.py
Normal file
81
apps/connect/handlers/script.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# connect/handlers/script.py
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from django.conf import settings
|
||||
from .base import IntegrationHandler
|
||||
|
||||
|
||||
def _is_path_allowed(real_path: str) -> bool:
|
||||
# Ensure path is within one of the allowed directories
|
||||
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
|
||||
base_abs = os.path.abspath(base) + os.sep
|
||||
if real_path.startswith(base_abs):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ScriptHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
raw_path = self.integration.config.get("path")
|
||||
if not raw_path:
|
||||
raise ValueError("Missing 'path' in integration config")
|
||||
|
||||
# Resolve and validate path
|
||||
real_path = os.path.abspath(os.path.realpath(raw_path))
|
||||
|
||||
if not os.path.exists(real_path):
|
||||
raise FileNotFoundError(f"Script not found: {real_path}")
|
||||
|
||||
if not _is_path_allowed(real_path):
|
||||
raise PermissionError(
|
||||
f"Script path '{real_path}' not within allowed directories: "
|
||||
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
|
||||
)
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
|
||||
if not os.access(real_path, os.X_OK):
|
||||
raise PermissionError(f"Script is not executable: {real_path}")
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
|
||||
st = os.stat(real_path)
|
||||
if st.st_mode & stat.S_IWOTH:
|
||||
raise PermissionError(
|
||||
f"Refusing to execute world-writable script: {real_path}"
|
||||
)
|
||||
|
||||
# Build a sanitized minimal environment; avoid inheriting secrets
|
||||
env = {
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
for key, value in (self.payload or {}).items():
|
||||
env_key = f"DISPATCHARR_{str(key).upper()}"
|
||||
env[env_key] = "" if value is None else str(value)
|
||||
|
||||
# Run with a timeout to prevent hanging scripts
|
||||
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
|
||||
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
|
||||
|
||||
result = subprocess.run(
|
||||
[real_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
cwd=os.path.dirname(real_path) or None,
|
||||
)
|
||||
|
||||
# Truncate outputs to avoid excessive memory/logging
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
if len(stdout) > max_out:
|
||||
stdout = stdout[:max_out] + "... [truncated]"
|
||||
if len(stderr) > max_out:
|
||||
stderr = stderr[:max_out] + "... [truncated]"
|
||||
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"success": result.returncode == 0,
|
||||
}
|
||||
21
apps/connect/handlers/webhook.py
Normal file
21
apps/connect/handlers/webhook.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# connect/handlers/webhook.py
|
||||
import requests, json, logging
|
||||
from .base import IntegrationHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebhookHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
url = self.integration.config.get("url")
|
||||
headers = self.integration.config.get("headers", {})
|
||||
logger.info(self.payload)
|
||||
|
||||
try:
|
||||
parsed = json.loads(self.payload)
|
||||
headers["Content-Type"] = "application/json"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = requests.post(url, data=self.payload, headers=headers, timeout=10)
|
||||
|
||||
return {"status_code": response.status_code, "body": response.text, "success": response.ok}
|
||||
52
apps/connect/migrations/0001_initial.py
Normal file
52
apps/connect/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 5.2.9 on 2026-01-27 21:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EventSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Integration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)),
|
||||
('config', models.JSONField(default=dict)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)),
|
||||
('request_payload', models.JSONField(blank=True, default=dict)),
|
||||
('response_payload', models.JSONField(blank=True, default=dict)),
|
||||
('error_message', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventsubscription',
|
||||
name='integration',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.11 on 2026-02-26 19:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_connect', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventsubscription',
|
||||
name='event',
|
||||
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100),
|
||||
),
|
||||
]
|
||||
0
apps/connect/migrations/__init__.py
Normal file
0
apps/connect/migrations/__init__.py
Normal file
47
apps/connect/models.py
Normal file
47
apps/connect/models.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from django.db import models
|
||||
|
||||
SUPPORTED_EVENTS = {
|
||||
"channel_start": "Channel Started",
|
||||
"channel_stop": "Channel Stopped",
|
||||
"channel_reconnect": "Channel Reconnected",
|
||||
"channel_error": "Channel Error",
|
||||
"channel_failover": "Channel Failover",
|
||||
"stream_switch": "Stream Switch",
|
||||
"recording_start": "Recording Started",
|
||||
"recording_end": "Recording Ended",
|
||||
"epg_refresh": "EPG Refreshed",
|
||||
"m3u_refresh": "M3U Refreshed",
|
||||
"client_connect": "Client Connected",
|
||||
"client_disconnect": "Client Disconnected",
|
||||
"login_failed": "Login Failed",
|
||||
"epg_blocked": "EPG Blocked",
|
||||
"m3u_blocked": "M3U Blocked",
|
||||
}
|
||||
|
||||
class Integration(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
("webhook", "Webhook"),
|
||||
("api", "API"),
|
||||
("script", "Custom Script"),
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
|
||||
config = models.JSONField(default=dict)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class EventSubscription(models.Model):
|
||||
EVENT_CHOICES = list(SUPPORTED_EVENTS.items())
|
||||
event = models.CharField(max_length=100, choices=EVENT_CHOICES)
|
||||
integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions")
|
||||
enabled = models.BooleanField(default=True)
|
||||
payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload")
|
||||
|
||||
class DeliveryLog(models.Model):
|
||||
subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs")
|
||||
status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")])
|
||||
request_payload = models.JSONField(default=dict, blank=True)
|
||||
response_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
68
apps/connect/serializers.py
Normal file
68
apps/connect/serializers.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
import os
|
||||
|
||||
|
||||
class EventSubscriptionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = EventSubscription
|
||||
fields = [
|
||||
"id",
|
||||
"event",
|
||||
"enabled",
|
||||
"payload_template",
|
||||
"integration",
|
||||
]
|
||||
|
||||
|
||||
class IntegrationSerializer(serializers.ModelSerializer):
|
||||
subscriptions = EventSubscriptionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Integration
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"config",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"subscriptions",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None)
|
||||
config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {})
|
||||
|
||||
if type == "script":
|
||||
path = (config or {}).get("path")
|
||||
if not path or not isinstance(path, str):
|
||||
raise serializers.ValidationError({"config": "Script config must include a 'path' string"})
|
||||
|
||||
real_path = os.path.abspath(os.path.realpath(path))
|
||||
if not os.path.exists(real_path):
|
||||
raise serializers.ValidationError({"config": f"Script path does not exist: {path}"})
|
||||
elif type == "webhook":
|
||||
url = (config or {}).get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"})
|
||||
else:
|
||||
raise serializers.ValidationError({"type": "Unsupported integration type"})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class DeliveryLogSerializer(serializers.ModelSerializer):
|
||||
subscription = EventSubscriptionSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeliveryLog
|
||||
fields = [
|
||||
"id",
|
||||
"subscription",
|
||||
"status",
|
||||
"request_payload",
|
||||
"response_payload",
|
||||
"error_message",
|
||||
"created_at",
|
||||
]
|
||||
116
apps/connect/utils.py
Normal file
116
apps/connect/utils.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# connect/utils.py
|
||||
import logging, json
|
||||
from django.template import Template, Context
|
||||
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
from apps.plugins.loader import PluginManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HANDLERS = {
|
||||
"webhook": WebhookHandler,
|
||||
"script": ScriptHandler,
|
||||
}
|
||||
|
||||
|
||||
def trigger_event(event_name, payload):
|
||||
if event_name not in SUPPORTED_EVENTS:
|
||||
logger.debug(f"Unsupported event '{event_name}' - skipping")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}"
|
||||
)
|
||||
subscriptions = EventSubscription.objects.filter(
|
||||
event=event_name, enabled=True
|
||||
).select_related("integration")
|
||||
|
||||
count = subscriptions.count()
|
||||
logger.info(f"Found {count} connect subscription(s) for event '{event_name}'")
|
||||
|
||||
# First, fetch all subscriptions and trigger
|
||||
for sub in subscriptions:
|
||||
integration = sub.integration
|
||||
if not integration.enabled:
|
||||
logger.debug(
|
||||
f"Skipping disabled integration id={integration.id} name={integration.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# apply optional payload template (only for webhook integrations)
|
||||
# If the rendered template is valid JSON, use that object as the payload.
|
||||
# Otherwise, pass the rendered string as-is.
|
||||
final_payload = payload
|
||||
if integration.type == 'webhook' and sub.payload_template:
|
||||
try:
|
||||
template = Template(sub.payload_template)
|
||||
final_payload = template.render(Context(payload)).strip()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Payload template render failed for subscription id={sub.id}: {e}"
|
||||
)
|
||||
final_payload = payload
|
||||
|
||||
handler_cls = HANDLERS.get(integration.type)
|
||||
if not handler_cls:
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="failed",
|
||||
request_payload=final_payload,
|
||||
error_message=f"No handler for integration type '{integration.type}'",
|
||||
)
|
||||
logger.error(
|
||||
f"No handler for integration type '{integration.type}' (integration id={integration.id})"
|
||||
)
|
||||
continue
|
||||
|
||||
handler = handler_cls(integration, sub, final_payload)
|
||||
logger.debug(
|
||||
f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = handler.execute()
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="success" if result.get("success") else "failed",
|
||||
request_payload=final_payload,
|
||||
response_payload=result,
|
||||
)
|
||||
logger.info(
|
||||
f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="failed",
|
||||
request_payload=final_payload,
|
||||
error_message=str(e),
|
||||
)
|
||||
logger.error(
|
||||
f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}"
|
||||
)
|
||||
|
||||
pm = PluginManager.get()
|
||||
pm.discover_plugins(sync_db=False, use_cache=True)
|
||||
plugins = pm.list_plugins()
|
||||
|
||||
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
|
||||
for plugin in plugins:
|
||||
if not plugin["enabled"]:
|
||||
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
|
||||
continue
|
||||
|
||||
logger.debug(json.dumps(plugin))
|
||||
for action in plugin["actions"]:
|
||||
if "events" in action and event_name in action["events"]:
|
||||
key = plugin["key"]
|
||||
params = {"event": event_name, "payload": payload}
|
||||
action_name = action.get("label") or action.get("id")
|
||||
action_id = action.get("id")
|
||||
logger.debug(
|
||||
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
|
||||
)
|
||||
if action_id:
|
||||
pm.run_action(key, action_id, params)
|
||||
|
|
@ -31,7 +31,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
API endpoint that allows EPG sources to be viewed or edited.
|
||||
"""
|
||||
|
||||
queryset = EPGSource.objects.all()
|
||||
queryset = EPGSource.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).all()
|
||||
serializer_class = EPGSourceSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
@ -40,6 +42,17 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
from django.db.models import Exists, OuterRef
|
||||
from apps.channels.models import Channel
|
||||
return EPGSource.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).annotate(
|
||||
has_channels=Exists(
|
||||
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
|
||||
)
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
logger.debug("Listing all EPG sources.")
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
|
@ -439,43 +452,32 @@ class CurrentProgramsAPIView(APIView):
|
|||
request=inline_serializer(
|
||||
name="CurrentProgramsRequest",
|
||||
fields={
|
||||
"channel_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
"channel_uuids": serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
|
||||
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={200: ProgramDataSerializer(many=True)},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Get channel IDs from request body
|
||||
channel_ids = request.data.get('channel_ids', None)
|
||||
|
||||
# Import Channel model
|
||||
from apps.channels.models import Channel
|
||||
|
||||
# Build query for channels with EPG data
|
||||
query = Channel.objects.filter(epg_data__isnull=False)
|
||||
|
||||
# Filter by specific channel IDs if provided
|
||||
if channel_ids is not None:
|
||||
if not isinstance(channel_ids, list):
|
||||
channel_uuids = request.data.get('channel_uuids', None)
|
||||
|
||||
if channel_uuids is not None:
|
||||
if not isinstance(channel_uuids, list):
|
||||
return Response(
|
||||
{"error": "channel_ids must be an array of integers or null"},
|
||||
{"error": "channel_uuids must be an array of strings or null"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
channel_ids = [int(id) for id in channel_ids]
|
||||
except (ValueError, TypeError):
|
||||
return Response(
|
||||
{"error": "channel_ids must contain valid integers"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
query = query.filter(id__in=channel_ids)
|
||||
query = query.filter(uuid__in=channel_uuids)
|
||||
|
||||
# Get channels with EPG data
|
||||
channels = query.select_related('epg_data')
|
||||
|
|
@ -495,9 +497,8 @@ class CurrentProgramsAPIView(APIView):
|
|||
).first()
|
||||
|
||||
if program:
|
||||
# Serialize program and add channel_id for easy mapping
|
||||
program_data = ProgramDataSerializer(program).data
|
||||
program_data['channel_id'] = channel.id
|
||||
program_data['channel_uuid'] = str(channel.uuid)
|
||||
current_programs.append(program_data)
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from apps.channels.models import Channel
|
|||
|
||||
class EPGSourceSerializer(serializers.ModelSerializer):
|
||||
epg_data_count = serializers.SerializerMethodField()
|
||||
has_channels = serializers.BooleanField(read_only=True, default=False)
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
url = serializers.CharField(
|
||||
required=False,
|
||||
|
|
@ -12,6 +13,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
allow_null=True,
|
||||
validators=[validate_flexible_url]
|
||||
)
|
||||
cron_expression = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
model = EPGSource
|
||||
|
|
@ -24,19 +26,57 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'is_active',
|
||||
'file_path',
|
||||
'refresh_interval',
|
||||
'cron_expression',
|
||||
'priority',
|
||||
'status',
|
||||
'last_message',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'custom_properties',
|
||||
'epg_data_count'
|
||||
'epg_data_count',
|
||||
'has_channels',
|
||||
]
|
||||
|
||||
def get_epg_data_count(self, obj):
|
||||
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
|
||||
return obj.epgs.count()
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
|
||||
# But first check if we have a transient _cron_expression (from create/update before signal runs)
|
||||
cron_expr = ''
|
||||
if hasattr(instance, '_cron_expression'):
|
||||
cron_expr = instance._cron_expression
|
||||
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
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
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# 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:
|
||||
cron_expr = validated_data.pop('cron_expression')
|
||||
else:
|
||||
cron_expr = ''
|
||||
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||
instance._cron_expression = cron_expr
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
cron_expr = validated_data.pop('cron_expression', '')
|
||||
instance = EPGSource(**validated_data)
|
||||
instance._cron_expression = cron_expr
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
class ProgramDataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProgramData
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
|
|||
from django.dispatch import receiver
|
||||
from .models import EPGSource, EPGData
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.utils import is_protected_path, send_websocket_update
|
||||
import json
|
||||
import logging
|
||||
|
|
@ -70,10 +70,11 @@ def create_dummy_epg_data(sender, instance, created, **kwargs):
|
|||
logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})")
|
||||
|
||||
@receiver(post_save, sender=EPGSource)
|
||||
def create_or_update_refresh_task(sender, instance, **kwargs):
|
||||
def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs):
|
||||
"""
|
||||
Create or update a Celery Beat periodic task when an EPGSource is created/updated.
|
||||
Skip creating tasks for dummy EPG sources as they don't need refreshing.
|
||||
Supports both interval-based and cron-based scheduling via the shared utility.
|
||||
"""
|
||||
# Skip task creation for dummy EPGs
|
||||
if instance.source_type == 'dummy':
|
||||
|
|
@ -83,39 +84,48 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
instance.refresh_task.save(update_fields=['enabled'])
|
||||
return
|
||||
|
||||
# Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message
|
||||
# updates from the refresh task itself). We only need to reschedule when schedule-relevant
|
||||
# fields change or when _cron_expression was explicitly set by the serializer.
|
||||
SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'}
|
||||
if (
|
||||
not created
|
||||
and update_fields is not None
|
||||
and not (set(update_fields) & SCHEDULE_FIELDS)
|
||||
and not hasattr(instance, '_cron_expression')
|
||||
):
|
||||
return
|
||||
|
||||
task_name = f"epg_source-refresh-{instance.id}"
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=int(instance.refresh_interval),
|
||||
period=IntervalSchedule.HOURS
|
||||
should_be_enabled = instance.is_active
|
||||
|
||||
# Read cron_expression from transient attribute set by the serializer.
|
||||
# If not set (e.g. save came from a task updating status/last_message),
|
||||
# preserve the existing crontab so we don't accidentally revert to interval.
|
||||
if hasattr(instance, "_cron_expression"):
|
||||
cron_expr = instance._cron_expression
|
||||
else:
|
||||
cron_expr = ""
|
||||
try:
|
||||
existing_task = instance.refresh_task
|
||||
if existing_task and existing_task.crontab:
|
||||
ct = existing_task.crontab
|
||||
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task = create_or_update_periodic_task(
|
||||
task_name=task_name,
|
||||
celery_task_path="apps.epg.tasks.refresh_epg_data",
|
||||
kwargs={"source_id": instance.id},
|
||||
interval_hours=int(instance.refresh_interval),
|
||||
cron_expression=cron_expr,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={
|
||||
"interval": interval,
|
||||
"task": "apps.epg.tasks.refresh_epg_data",
|
||||
"kwargs": json.dumps({"source_id": instance.id}),
|
||||
"enabled": instance.refresh_interval != 0 and instance.is_active,
|
||||
})
|
||||
|
||||
update_fields = []
|
||||
if created:
|
||||
task.interval = interval
|
||||
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
update_fields.append("interval")
|
||||
|
||||
# Check both refresh_interval and is_active to determine if task should be enabled
|
||||
should_be_enabled = instance.refresh_interval != 0 and instance.is_active
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
update_fields.append("enabled")
|
||||
|
||||
if update_fields:
|
||||
task.save(update_fields=update_fields)
|
||||
|
||||
if instance.refresh_task != task:
|
||||
instance.refresh_task = task
|
||||
instance.save(update_fields=["refresh_task"]) # Fixed field name
|
||||
instance.save(update_fields=["refresh_task"])
|
||||
|
||||
@receiver(post_delete, sender=EPGSource)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from asgiref.sync import async_to_sync
|
|||
from channels.layers import get_channel_layer
|
||||
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event
|
||||
from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -146,12 +146,15 @@ def refresh_all_epg_data():
|
|||
return "EPG data refreshed."
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(time_limit=1800, soft_time_limit=1700)
|
||||
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")
|
||||
return
|
||||
|
||||
lock_renewer = TaskLockRenewer('refresh_epg_data', source_id)
|
||||
lock_renewer.start()
|
||||
|
||||
source = None
|
||||
try:
|
||||
# Try to get the EPG source
|
||||
|
|
@ -168,6 +171,7 @@ def refresh_epg_data(source_id):
|
|||
logger.info(f"No orphaned task found for EPG source {source_id}")
|
||||
|
||||
# Release the lock and exit
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
|
|
@ -176,6 +180,7 @@ def refresh_epg_data(source_id):
|
|||
# The source exists but is not active, just skip processing
|
||||
if not source.is_active:
|
||||
logger.info(f"EPG source {source_id} is not active. Skipping.")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
|
|
@ -184,6 +189,7 @@ def refresh_epg_data(source_id):
|
|||
# Skip refresh for dummy EPG sources - they don't need refreshing
|
||||
if source.source_type == 'dummy':
|
||||
logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
gc.collect()
|
||||
return
|
||||
|
|
@ -194,6 +200,7 @@ def refresh_epg_data(source_id):
|
|||
fetch_success = fetch_xmltv(source)
|
||||
if not fetch_success:
|
||||
logger.error(f"Failed to fetch XMLTV for source {source.name}")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
|
|
@ -202,6 +209,7 @@ def refresh_epg_data(source_id):
|
|||
parse_channels_success = parse_channels_only(source)
|
||||
if not parse_channels_success:
|
||||
logger.error(f"Failed to parse channels for source {source.name}")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
|
|
@ -234,6 +242,7 @@ def refresh_epg_data(source_id):
|
|||
source = None
|
||||
# Force garbage collection before releasing the lock
|
||||
gc.collect()
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
|
||||
|
||||
|
|
@ -1126,12 +1135,15 @@ def parse_channels_only(source):
|
|||
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(time_limit=3600, soft_time_limit=3500)
|
||||
def parse_programs_for_tvg_id(epg_id):
|
||||
if not acquire_task_lock('parse_epg_programs', epg_id):
|
||||
logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task")
|
||||
return "Task already running"
|
||||
|
||||
lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id)
|
||||
lock_renewer.start()
|
||||
|
||||
source_file = None
|
||||
program_parser = None
|
||||
programs_to_create = []
|
||||
|
|
@ -1161,11 +1173,13 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
# Skip program parsing for dummy EPG sources - they don't have program data files
|
||||
if epg_source.source_type == 'dummy':
|
||||
logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
if not Channel.objects.filter(epg_data=epg).exists():
|
||||
logger.info(f"No channels matched to EPG {epg.tvg_id}")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
|
|
@ -1207,6 +1221,7 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
epg_source.last_message = f"Failed to download EPG data, cannot parse programs"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
|
|
@ -1217,6 +1232,7 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
epg_source.last_message = f"Failed to download EPG data, file missing after download"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
|
|
@ -1232,6 +1248,7 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
epg_source.last_message = f"No URL provided, cannot fetch EPG data"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
|
|
@ -1379,7 +1396,7 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
epg_source = None
|
||||
# Add comprehensive cleanup before releasing lock
|
||||
cleanup_memory(log_usage=should_log_memory, force_collection=True)
|
||||
# Memory tracking after processing
|
||||
# Memory tracking after processing
|
||||
if process:
|
||||
try:
|
||||
mem_after = process.memory_info().rss / 1024 / 1024
|
||||
|
|
@ -1389,6 +1406,7 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
process = None
|
||||
epg = None
|
||||
programs_processed = None
|
||||
lock_renewer.stop()
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from apps.accounts.permissions import (
|
|||
)
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from django.core.cache import cache
|
||||
|
|
@ -37,7 +38,9 @@ import json
|
|||
class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U accounts"""
|
||||
|
||||
queryset = M3UAccount.objects.prefetch_related("channel_group")
|
||||
queryset = M3UAccount.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).prefetch_related("channel_group")
|
||||
serializer_class = M3UAccountSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
@ -262,38 +265,50 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
category_settings = request.data.get("category_settings", [])
|
||||
|
||||
try:
|
||||
for setting in group_settings:
|
||||
group_id = setting.get("channel_group")
|
||||
enabled = setting.get("enabled", True)
|
||||
auto_sync = setting.get("auto_channel_sync", False)
|
||||
sync_start = setting.get("auto_sync_channel_start")
|
||||
custom_properties = setting.get("custom_properties", {})
|
||||
|
||||
if group_id:
|
||||
ChannelGroupM3UAccount.objects.update_or_create(
|
||||
channel_group_id=group_id,
|
||||
with transaction.atomic():
|
||||
group_objects = [
|
||||
ChannelGroupM3UAccount(
|
||||
channel_group_id=setting["channel_group"],
|
||||
m3u_account=account,
|
||||
defaults={
|
||||
"enabled": enabled,
|
||||
"auto_channel_sync": auto_sync,
|
||||
"auto_sync_channel_start": sync_start,
|
||||
"custom_properties": custom_properties,
|
||||
},
|
||||
enabled=setting.get("enabled", True),
|
||||
auto_channel_sync=setting.get("auto_channel_sync", False),
|
||||
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
|
||||
custom_properties=setting.get("custom_properties", {}),
|
||||
)
|
||||
for setting in group_settings
|
||||
if setting.get("channel_group")
|
||||
]
|
||||
|
||||
if group_objects:
|
||||
ChannelGroupM3UAccount.objects.bulk_create(
|
||||
group_objects,
|
||||
update_conflicts=True,
|
||||
unique_fields=["channel_group", "m3u_account"],
|
||||
update_fields=[
|
||||
"enabled",
|
||||
"auto_channel_sync",
|
||||
"auto_sync_channel_start",
|
||||
"custom_properties",
|
||||
],
|
||||
)
|
||||
|
||||
for setting in category_settings:
|
||||
category_id = setting.get("id")
|
||||
enabled = setting.get("enabled", True)
|
||||
custom_properties = setting.get("custom_properties", {})
|
||||
|
||||
if category_id:
|
||||
M3UVODCategoryRelation.objects.update_or_create(
|
||||
category_id=category_id,
|
||||
category_objects = [
|
||||
M3UVODCategoryRelation(
|
||||
category_id=setting["id"],
|
||||
m3u_account=account,
|
||||
defaults={
|
||||
"enabled": enabled,
|
||||
"custom_properties": custom_properties,
|
||||
},
|
||||
enabled=setting.get("enabled", True),
|
||||
custom_properties=setting.get("custom_properties", {}),
|
||||
)
|
||||
for setting in category_settings
|
||||
if setting.get("id")
|
||||
]
|
||||
|
||||
if category_objects:
|
||||
M3UVODCategoryRelation.objects.bulk_create(
|
||||
category_objects,
|
||||
update_conflicts=True,
|
||||
unique_fields=["m3u_account", "category"],
|
||||
update_fields=["enabled", "custom_properties"],
|
||||
)
|
||||
|
||||
return Response({"message": "Group settings updated successfully"})
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
|
||||
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
|
||||
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
|
||||
cron_expression = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
model = M3UAccount
|
||||
|
|
@ -158,6 +159,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
"locked",
|
||||
"channel_groups",
|
||||
"refresh_interval",
|
||||
"cron_expression",
|
||||
"custom_properties",
|
||||
"account_type",
|
||||
"username",
|
||||
|
|
@ -188,9 +190,30 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
|
||||
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
|
||||
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
|
||||
|
||||
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
|
||||
# But first check if we have a transient _cron_expression (from create/update before signal runs)
|
||||
cron_expr = ""
|
||||
if hasattr(instance, '_cron_expression'):
|
||||
cron_expr = instance._cron_expression
|
||||
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
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
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# 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:
|
||||
cron_expr = validated_data.pop("cron_expression")
|
||||
else:
|
||||
cron_expr = ""
|
||||
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
|
||||
instance._cron_expression = cron_expr
|
||||
|
||||
# Handle enable_vod preference and auto_enable_new_groups settings
|
||||
enable_vod = validated_data.pop("enable_vod", None)
|
||||
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
|
||||
|
|
@ -244,6 +267,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
# Pop cron_expression — it's not a model field
|
||||
cron_expr = validated_data.pop("cron_expression", "")
|
||||
|
||||
# Handle enable_vod preference and auto_enable_new_groups settings during creation
|
||||
enable_vod = validated_data.pop("enable_vod", False)
|
||||
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
|
||||
|
|
@ -260,7 +286,11 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
|
||||
validated_data["custom_properties"] = custom_props
|
||||
|
||||
return super().create(validated_data)
|
||||
# Build instance manually so we can attach transient attr before save triggers signal
|
||||
instance = M3UAccount(**validated_data)
|
||||
instance._cron_expression = cron_expr
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def get_filters(self, obj):
|
||||
filters = obj.filters.order_by("order")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
|
|||
from django.dispatch import receiver
|
||||
from .models import M3UAccount
|
||||
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
|
@ -20,51 +20,53 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
|
|||
refresh_m3u_groups.delay(instance.id)
|
||||
|
||||
@receiver(post_save, sender=M3UAccount)
|
||||
def create_or_update_refresh_task(sender, instance, **kwargs):
|
||||
def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs):
|
||||
"""
|
||||
Create or update a Celery Beat periodic task when an M3UAccount is created/updated.
|
||||
Supports both interval-based and cron-based scheduling via the shared utility.
|
||||
"""
|
||||
task_name = f"m3u_account-refresh-{instance.id}"
|
||||
# Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message
|
||||
# updates from the refresh task itself). We only need to reschedule when schedule-relevant
|
||||
# fields change or when _cron_expression was explicitly set by the serializer.
|
||||
SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'}
|
||||
if (
|
||||
not created
|
||||
and update_fields is not None
|
||||
and not (set(update_fields) & SCHEDULE_FIELDS)
|
||||
and not hasattr(instance, '_cron_expression')
|
||||
):
|
||||
return
|
||||
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=int(instance.refresh_interval),
|
||||
period=IntervalSchedule.HOURS
|
||||
task_name = f"m3u_account-refresh-{instance.id}"
|
||||
should_be_enabled = instance.is_active
|
||||
|
||||
# Read cron_expression from transient attribute set by the serializer.
|
||||
# If not set (e.g. save came from a task updating status/last_message),
|
||||
# preserve the existing crontab so we don't accidentally revert to interval.
|
||||
if hasattr(instance, "_cron_expression"):
|
||||
cron_expr = instance._cron_expression
|
||||
else:
|
||||
cron_expr = ""
|
||||
try:
|
||||
existing_task = instance.refresh_task
|
||||
if existing_task and existing_task.crontab:
|
||||
ct = existing_task.crontab
|
||||
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task = create_or_update_periodic_task(
|
||||
task_name=task_name,
|
||||
celery_task_path="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs={"account_id": instance.id},
|
||||
interval_hours=int(instance.refresh_interval),
|
||||
cron_expression=cron_expr,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
# Task should be enabled only if refresh_interval != 0 AND account is active
|
||||
should_be_enabled = (instance.refresh_interval != 0) and instance.is_active
|
||||
|
||||
# First check if the task already exists to avoid validation errors
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
# Task exists, just update it
|
||||
updated_fields = []
|
||||
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
updated_fields.append("enabled")
|
||||
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
updated_fields.append("interval")
|
||||
|
||||
if updated_fields:
|
||||
task.save(update_fields=updated_fields)
|
||||
|
||||
# Ensure instance has the task
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
except PeriodicTask.DoesNotExist:
|
||||
# Create new task if it doesn't exist
|
||||
refresh_task = PeriodicTask.objects.create(
|
||||
name=task_name,
|
||||
interval=interval,
|
||||
task="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs=json.dumps({"account_id": instance.id}),
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
|
||||
# Ensure instance has the task linked
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
@receiver(post_delete, sender=M3UAccount)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from core.utils import (
|
|||
RedisClient,
|
||||
acquire_task_lock,
|
||||
release_task_lock,
|
||||
TaskLockRenewer,
|
||||
natural_sort_key,
|
||||
log_system_event,
|
||||
)
|
||||
|
|
@ -66,7 +67,8 @@ def fetch_m3u_lines(account, use_cache=False):
|
|||
account.save(update_fields=["status", "last_message"])
|
||||
|
||||
response = requests.get(
|
||||
account.server_url, headers=headers, stream=True
|
||||
account.server_url, headers=headers, stream=True,
|
||||
timeout=(30, 60), # 30s connect, 60s read between chunks
|
||||
)
|
||||
|
||||
# Log the actual response details for debugging
|
||||
|
|
@ -126,119 +128,60 @@ def fetch_m3u_lines(account, use_cache=False):
|
|||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
progress = 0
|
||||
temp_content = b"" # Store content temporarily to validate before saving
|
||||
has_content = False
|
||||
|
||||
# First, let's collect the content and validate it
|
||||
send_m3u_update(account.id, "downloading", 0)
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
temp_content += chunk
|
||||
has_content = True
|
||||
# Stream directly to a temp file to avoid holding the entire
|
||||
# M3U in memory (large files can be 100MB+, which would use
|
||||
# ~3x that in RAM in certain approaches).
|
||||
temp_path = file_path + ".tmp"
|
||||
try:
|
||||
send_m3u_update(account.id, "downloading", 0)
|
||||
with open(temp_path, "wb") as tmp_file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
tmp_file.write(chunk)
|
||||
has_content = True
|
||||
|
||||
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 # in KB/s
|
||||
# Calculate download speed in KB/s
|
||||
speed = downloaded / elapsed_time / 1024 # in KB/s
|
||||
|
||||
# Calculate progress percentage
|
||||
if total_size and total_size > 0:
|
||||
progress = (downloaded / total_size) * 100
|
||||
# Calculate progress percentage
|
||||
if total_size and total_size > 0:
|
||||
progress = (downloaded / total_size) * 100
|
||||
|
||||
# Time remaining (in seconds)
|
||||
time_remaining = (
|
||||
(total_size - downloaded) / (speed * 1024)
|
||||
if speed > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_update_time >= 0.5:
|
||||
last_update_time = current_time
|
||||
if progress > 0:
|
||||
# Update the account's last_message with detailed progress info
|
||||
progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining"
|
||||
account.last_message = progress_msg
|
||||
account.save(update_fields=["last_message"])
|
||||
|
||||
send_m3u_update(
|
||||
account.id,
|
||||
"downloading",
|
||||
progress,
|
||||
speed=speed,
|
||||
elapsed_time=elapsed_time,
|
||||
time_remaining=time_remaining,
|
||||
message=progress_msg,
|
||||
# Time remaining (in seconds)
|
||||
time_remaining = (
|
||||
(total_size - downloaded) / (speed * 1024)
|
||||
if speed > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# Check if we actually received any content
|
||||
logger.info(f"Download completed. Has content: {has_content}, Content length: {len(temp_content)} bytes")
|
||||
if not has_content or len(temp_content) == 0:
|
||||
error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
account.id,
|
||||
"downloading",
|
||||
100,
|
||||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
return [], False
|
||||
current_time = time.time()
|
||||
if current_time - last_update_time >= 0.5:
|
||||
last_update_time = current_time
|
||||
if progress > 0:
|
||||
# Update the account's last_message with detailed progress info
|
||||
progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining"
|
||||
account.last_message = progress_msg
|
||||
account.save(update_fields=["last_message"])
|
||||
|
||||
# Basic validation: check if content looks like an M3U file
|
||||
try:
|
||||
content_str = temp_content.decode('utf-8', errors='ignore')
|
||||
content_lines = content_str.strip().split('\n')
|
||||
send_m3u_update(
|
||||
account.id,
|
||||
"downloading",
|
||||
progress,
|
||||
speed=speed,
|
||||
elapsed_time=elapsed_time,
|
||||
time_remaining=time_remaining,
|
||||
message=progress_msg,
|
||||
)
|
||||
|
||||
# Log first few lines for debugging (be careful not to log too much)
|
||||
preview_lines = content_lines[:5]
|
||||
logger.info(f"Content preview (first 5 lines): {preview_lines}")
|
||||
logger.info(f"Total lines in content: {len(content_lines)}")
|
||||
|
||||
# Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content)
|
||||
is_valid_m3u = False
|
||||
|
||||
# First, check if this looks like an error response disguised as 200 OK
|
||||
content_lower = content_str.lower()
|
||||
if any(error_indicator in content_lower for error_indicator in [
|
||||
'<html', '<!doctype html', 'error', 'not found', '404', '403', '500',
|
||||
'access denied', 'unauthorized', 'forbidden', 'invalid', 'expired'
|
||||
]):
|
||||
logger.warning(f"Content appears to be an error response disguised as HTTP 200: {content_str[:200]!r}")
|
||||
# Continue with M3U validation, but this gives us a clue
|
||||
|
||||
if content_lines and content_lines[0].strip().upper().startswith('#EXTM3U'):
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: starts with #EXTM3U")
|
||||
elif any(line.strip().startswith('#EXTINF:') for line in content_lines):
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: contains #EXTINF entries")
|
||||
elif any(line.strip().startswith('http') for line in content_lines):
|
||||
# Has HTTP URLs, might be a simple M3U without headers
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: contains HTTP URLs")
|
||||
elif any(line.strip().startswith(('rtsp', 'rtp', 'udp')) for line in content_lines):
|
||||
# Has RTSP/RTP/UDP URLs, might be a simple M3U without headers
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: contains RTSP/RTP/UDP URLs")
|
||||
|
||||
if not is_valid_m3u:
|
||||
# Log what we actually received for debugging
|
||||
logger.error(f"Invalid M3U content received. First 200 characters: {content_str[:200]!r}")
|
||||
|
||||
# Try to provide more specific error messages based on content
|
||||
if '<html' in content_lower or '<!doctype html' in content_lower:
|
||||
error_msg = f"Server returned HTML page instead of M3U file from URL: {account.server_url}. This usually indicates an error or authentication issue."
|
||||
elif 'error' in content_lower or 'not found' in content_lower:
|
||||
error_msg = f"Server returned an error message instead of M3U file from URL: {account.server_url}. Content: {content_str[:100]}"
|
||||
elif len(content_str.strip()) == 0:
|
||||
error_msg = f"Server returned completely empty response from URL: {account.server_url}"
|
||||
else:
|
||||
error_msg = f"Server provided invalid M3U content from URL: {account.server_url}. Content does not appear to be a valid M3U file."
|
||||
# Check if we actually received any content
|
||||
logger.info(f"Download completed. Has content: {has_content}, Content length: {downloaded} bytes")
|
||||
if not has_content or downloaded == 0:
|
||||
error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
|
|
@ -252,31 +195,113 @@ def fetch_m3u_lines(account, use_cache=False):
|
|||
)
|
||||
return [], False
|
||||
|
||||
except UnicodeDecodeError:
|
||||
logger.error(f"Non-text content received. First 200 bytes: {temp_content[:200]!r}")
|
||||
error_msg = f"Server provided non-text content from URL: {account.server_url}. Unable to process as M3U file."
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
account.id,
|
||||
"downloading",
|
||||
100,
|
||||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
return [], False
|
||||
# Validate the file by reading only the first portion from
|
||||
# disk — no need to load the entire file into memory just
|
||||
# to check the header.
|
||||
VALIDATION_READ_SIZE = 32768 # 32KB covers headers comfortably
|
||||
try:
|
||||
with open(temp_path, "rb") as vf:
|
||||
head_bytes = vf.read(VALIDATION_READ_SIZE)
|
||||
head_str = head_bytes.decode('utf-8', errors='ignore')
|
||||
head_lines = head_str.strip().split('\n')
|
||||
|
||||
# Content is valid, save it to file
|
||||
with open(file_path, "wb") as file:
|
||||
file.write(temp_content)
|
||||
# Count total lines efficiently without loading full file
|
||||
with open(temp_path, "rb") as vf:
|
||||
total_lines = sum(1 for _ in vf)
|
||||
|
||||
# Final update with 100% progress
|
||||
final_msg = f"Download complete. Size: {total_size/1024/1024:.2f} MB, Time: {time.time() - start_time:.1f}s"
|
||||
account.last_message = final_msg
|
||||
account.save(update_fields=["last_message"])
|
||||
send_m3u_update(account.id, "downloading", 100, message=final_msg)
|
||||
# Log first few lines for debugging (be careful not to log too much)
|
||||
preview_lines = head_lines[:5]
|
||||
logger.info(f"Content preview (first 5 lines): {preview_lines}")
|
||||
logger.info(f"Total lines in content: {total_lines}")
|
||||
|
||||
# Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content)
|
||||
is_valid_m3u = False
|
||||
|
||||
# First, check if this looks like an error response disguised as 200 OK
|
||||
head_lower = head_str.lower()
|
||||
if any(error_indicator in head_lower for error_indicator in [
|
||||
'<html', '<!doctype html', 'error', 'not found', '404', '403', '500',
|
||||
'access denied', 'unauthorized', 'forbidden', 'invalid', 'expired'
|
||||
]):
|
||||
logger.warning(f"Content appears to be an error response disguised as HTTP 200: {head_str[:200]!r}")
|
||||
# Continue with M3U validation, but this gives us a clue
|
||||
|
||||
if head_lines and head_lines[0].strip().upper().startswith('#EXTM3U'):
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: starts with #EXTM3U")
|
||||
elif any(line.strip().startswith('#EXTINF:') for line in head_lines):
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: contains #EXTINF entries")
|
||||
elif any(line.strip().startswith('http') for line in head_lines):
|
||||
# Has HTTP URLs, might be a simple M3U without headers
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: contains HTTP URLs")
|
||||
elif any(line.strip().startswith(('rtsp', 'rtp', 'udp')) for line in head_lines):
|
||||
# Has RTSP/RTP/UDP URLs, might be a simple M3U without headers
|
||||
is_valid_m3u = True
|
||||
logger.info("Content validated as M3U: contains RTSP/RTP/UDP URLs")
|
||||
|
||||
if not is_valid_m3u:
|
||||
# Log what we actually received for debugging
|
||||
logger.error(f"Invalid M3U content received. First 200 characters: {head_str[:200]!r}")
|
||||
|
||||
# Try to provide more specific error messages based on content
|
||||
if '<html' in head_lower or '<!doctype html' in head_lower:
|
||||
error_msg = f"Server returned HTML page instead of M3U file from URL: {account.server_url}. This usually indicates an error or authentication issue."
|
||||
elif 'error' in head_lower or 'not found' in head_lower:
|
||||
error_msg = f"Server returned an error message instead of M3U file from URL: {account.server_url}. Content: {head_str[:100]}"
|
||||
elif len(head_str.strip()) == 0:
|
||||
error_msg = f"Server returned completely empty response from URL: {account.server_url}"
|
||||
else:
|
||||
error_msg = f"Server provided invalid M3U content from URL: {account.server_url}. Content does not appear to be a valid M3U file."
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
account.id,
|
||||
"downloading",
|
||||
100,
|
||||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
return [], False
|
||||
|
||||
except UnicodeDecodeError:
|
||||
with open(temp_path, "rb") as vf:
|
||||
first_bytes = vf.read(200)
|
||||
logger.error(f"Non-text content received. First 200 bytes: {first_bytes!r}")
|
||||
error_msg = f"Server provided non-text content from URL: {account.server_url}. Unable to process as M3U file."
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
account.id,
|
||||
"downloading",
|
||||
100,
|
||||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
return [], False
|
||||
|
||||
# Validation passed — promote temp file to final path
|
||||
os.replace(temp_path, file_path)
|
||||
|
||||
# Final update with 100% progress
|
||||
dl_size = downloaded / 1024 / 1024
|
||||
final_msg = f"Download complete. Size: {dl_size:.2f} MB, Time: {time.time() - start_time:.1f}s"
|
||||
account.last_message = final_msg
|
||||
account.save(update_fields=["last_message"])
|
||||
send_m3u_update(account.id, "downloading", 100, message=final_msg)
|
||||
|
||||
finally:
|
||||
# Clean up temp file on any failure path
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Handle HTTP errors specifically with more context
|
||||
status_code = e.response.status_code if e.response else "unknown"
|
||||
|
|
@ -437,6 +462,13 @@ def get_case_insensitive_attr(attributes, key, default=""):
|
|||
return default
|
||||
|
||||
|
||||
def parse_is_adult(value):
|
||||
try:
|
||||
return int(value) == 1
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def parse_extinf_line(line: str) -> dict:
|
||||
"""
|
||||
Parse an EXTINF line from an M3U file.
|
||||
|
|
@ -457,8 +489,10 @@ def parse_extinf_line(line: str) -> dict:
|
|||
attrs = {}
|
||||
last_attr_end = 0
|
||||
|
||||
# Use a single regex that handles both quote types
|
||||
for match in re.finditer(r'([^\s]+)=(["\'])([^\2]*?)\2', content):
|
||||
# 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
|
||||
|
|
@ -724,6 +758,14 @@ def collect_xc_streams(account_id, enabled_groups):
|
|||
# Filter streams based on enabled categories
|
||||
filtered_count = 0
|
||||
for stream in all_xc_streams:
|
||||
# Fall back to a generated name if the provider returns null/empty
|
||||
stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}"
|
||||
if not stream.get("name"):
|
||||
logger.warning(
|
||||
f"XC stream has null/empty name; using generated name '{stream_name}' "
|
||||
f"(stream_id={stream.get('stream_id', 'unknown')})"
|
||||
)
|
||||
|
||||
# Get the category_id for this stream
|
||||
category_id = str(stream.get("category_id", ""))
|
||||
|
||||
|
|
@ -733,7 +775,7 @@ def collect_xc_streams(account_id, enabled_groups):
|
|||
|
||||
# Convert XC stream to our standard format with all properties preserved
|
||||
stream_data = {
|
||||
"name": stream["name"],
|
||||
"name": stream_name,
|
||||
"url": xc_client.get_stream_url(stream["stream_id"]),
|
||||
"attributes": {
|
||||
"tvg-id": stream.get("epg_channel_id", ""),
|
||||
|
|
@ -817,7 +859,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
)
|
||||
|
||||
for stream in streams:
|
||||
name = stream["name"]
|
||||
name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}"
|
||||
if not stream.get("name"):
|
||||
logger.warning(
|
||||
f"XC stream has null/empty name in category {group_name}; "
|
||||
f"using generated name '{name}' (stream_id={stream.get('stream_id', 'unknown')})"
|
||||
)
|
||||
raw_stream_id = stream.get("stream_id", "")
|
||||
provider_stream_id = None
|
||||
if raw_stream_id:
|
||||
|
|
@ -850,7 +897,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
"channel_group_id": int(group_id),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": stream,
|
||||
"is_adult": int(stream.get("is_adult", 0)) == 1,
|
||||
"is_adult": parse_is_adult(stream.get("is_adult", 0)),
|
||||
"is_stale": False,
|
||||
"stream_id": provider_stream_id,
|
||||
"stream_chno": stream_chno,
|
||||
|
|
@ -1068,7 +1115,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
"channel_group_id": int(groups.get(group_title)),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": stream_info["attributes"],
|
||||
"is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1,
|
||||
"is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)),
|
||||
"is_stale": False,
|
||||
"stream_id": provider_stream_id,
|
||||
"stream_chno": channel_num,
|
||||
|
|
@ -1145,14 +1192,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
|
||||
retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
|
||||
|
||||
# Aggressive garbage collection
|
||||
# del streams_to_create, streams_to_update, stream_hashes, existing_streams
|
||||
# from core.utils import cleanup_memory
|
||||
# cleanup_memory(log_usage=True, force_collection=True)
|
||||
|
||||
# Clean up database connections for threading
|
||||
connections.close_all()
|
||||
|
||||
# Free batch data structures (reference-counted deallocation)
|
||||
del streams_to_create, streams_to_update, stream_hashes, existing_streams
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
|
|
@ -1210,9 +1255,13 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
if not acquire_task_lock("refresh_m3u_account_groups", account_id):
|
||||
return f"Task already running for account_id={account_id}.", None
|
||||
|
||||
lock_renewer = TaskLockRenewer("refresh_m3u_account_groups", account_id)
|
||||
lock_renewer.start()
|
||||
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id, is_active=True)
|
||||
except M3UAccount.DoesNotExist:
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return f"M3UAccount with ID={account_id} not found or inactive.", None
|
||||
|
||||
|
|
@ -1238,6 +1287,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
send_m3u_update(
|
||||
account_id, "processing_groups", 100, status="error", error=error_msg
|
||||
)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
|
||||
|
|
@ -1250,6 +1300,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
send_m3u_update(
|
||||
account_id, "processing_groups", 100, status="error", error=error_msg
|
||||
)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
|
||||
|
|
@ -1359,6 +1410,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
|
||||
|
|
@ -1397,6 +1449,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
|
||||
|
|
@ -1413,6 +1466,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
except Exception as e:
|
||||
|
|
@ -1424,6 +1478,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
send_m3u_update(
|
||||
account_id, "processing_groups", 100, status="error", error=error_msg
|
||||
)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
else:
|
||||
|
|
@ -1431,6 +1486,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
lines, success = fetch_m3u_lines(account, use_cache)
|
||||
if not success:
|
||||
# If fetch failed, don't continue processing
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return f"Failed to fetch M3U data for account_id={account_id}.", None
|
||||
|
||||
|
|
@ -1525,6 +1581,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
|
||||
process_groups(account, groups, scan_start_time)
|
||||
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
|
||||
if not full_refresh:
|
||||
|
|
@ -1648,6 +1705,13 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
channels_updated = 0
|
||||
channels_deleted = 0
|
||||
|
||||
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
|
||||
used_numbers = set(
|
||||
Channel.objects.exclude(
|
||||
auto_created=True, auto_created_by=account
|
||||
).values_list("channel_number", flat=True)
|
||||
)
|
||||
|
||||
for group_relation in auto_sync_groups:
|
||||
channel_group = group_relation.channel_group
|
||||
start_number = group_relation.auto_sync_channel_start or 1.0
|
||||
|
|
@ -1665,6 +1729,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
stream_profile_id = None
|
||||
custom_logo_id = None
|
||||
custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg)
|
||||
channel_numbering_mode = "fixed" # Default mode
|
||||
channel_numbering_fallback = 1 # Default fallback for provider mode
|
||||
if group_relation.custom_properties:
|
||||
group_custom_props = group_relation.custom_properties
|
||||
force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
|
||||
|
|
@ -1682,6 +1748,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
)
|
||||
stream_profile_id = group_custom_props.get("stream_profile_id")
|
||||
custom_logo_id = group_custom_props.get("custom_logo_id")
|
||||
channel_numbering_mode = group_custom_props.get("channel_numbering_mode", "fixed")
|
||||
channel_numbering_fallback = group_custom_props.get("channel_numbering_fallback", 1)
|
||||
|
||||
# Determine which group to use for created channels
|
||||
target_group = channel_group
|
||||
|
|
@ -1697,7 +1765,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
)
|
||||
|
||||
logger.info(
|
||||
f"Processing auto sync for group: {channel_group.name} (start: {start_number})"
|
||||
f"Processing auto sync for group: {channel_group.name} (mode: {channel_numbering_mode}, start: {start_number})"
|
||||
)
|
||||
|
||||
# Get all current streams in this group for this M3U account, filter out stale streams
|
||||
|
|
@ -1837,21 +1905,35 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
channels_to_renumber = []
|
||||
temp_channel_number = start_number
|
||||
|
||||
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
|
||||
used_numbers = set(
|
||||
Channel.objects.exclude(
|
||||
auto_created=True, auto_created_by=account
|
||||
).values_list("channel_number", flat=True)
|
||||
)
|
||||
|
||||
for stream in current_streams:
|
||||
if stream.id in existing_channel_map:
|
||||
channel = existing_channel_map[stream.id]
|
||||
|
||||
# Find next available number starting from temp_channel_number
|
||||
target_number = temp_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
# Determine target number based on numbering mode
|
||||
if channel_numbering_mode == "provider":
|
||||
# Use provider number if available, otherwise use fallback with next available logic
|
||||
if stream.stream_chno is not None:
|
||||
target_number = stream.stream_chno
|
||||
# If provider number is already used, find next available
|
||||
if target_number in used_numbers:
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else:
|
||||
# No provider number, use fallback and find next available
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
elif channel_numbering_mode == "next_available":
|
||||
# Find next available starting from 1
|
||||
target_number = 1
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else: # fixed mode (default)
|
||||
# Find next available number starting from temp_channel_number
|
||||
target_number = temp_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
|
||||
# Add this number to used_numbers so we don't reuse it in this batch
|
||||
used_numbers.add(target_number)
|
||||
|
|
@ -1863,9 +1945,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
f"Will renumber channel '{channel.name}' to {target_number}"
|
||||
)
|
||||
|
||||
temp_channel_number += 1.0
|
||||
if temp_channel_number % 1 != 0: # Has decimal
|
||||
temp_channel_number = int(temp_channel_number) + 1.0
|
||||
# Only increment temp_channel_number in fixed mode
|
||||
if channel_numbering_mode == "fixed":
|
||||
temp_channel_number += 1.0
|
||||
if temp_channel_number % 1 != 0: # Has decimal
|
||||
temp_channel_number = int(temp_channel_number) + 1.0
|
||||
|
||||
# Bulk update channel numbers if any need renumbering
|
||||
if channels_to_renumber:
|
||||
|
|
@ -2060,10 +2144,31 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
|
||||
else:
|
||||
# Create new channel
|
||||
# Find next available channel number
|
||||
target_number = current_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
# Determine channel number based on numbering mode
|
||||
if channel_numbering_mode == "provider":
|
||||
# Use provider number if available, otherwise use fallback with next available logic
|
||||
if stream.stream_chno is not None:
|
||||
target_number = stream.stream_chno
|
||||
# If provider number is already used, find next available from fallback
|
||||
if target_number in used_numbers:
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else:
|
||||
# No provider number, use fallback and find next available
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
elif channel_numbering_mode == "next_available":
|
||||
# Find next available starting from 1
|
||||
target_number = 1
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else: # fixed mode (default)
|
||||
# Find next available channel number starting from current_channel_number
|
||||
target_number = current_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
|
||||
# Add this number to used_numbers
|
||||
used_numbers.add(target_number)
|
||||
|
|
@ -2190,10 +2295,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
f"Created auto channel: {channel.channel_number} - {channel.name}"
|
||||
)
|
||||
|
||||
# Increment channel number for next iteration
|
||||
current_channel_number += 1.0
|
||||
if current_channel_number % 1 != 0: # Has decimal
|
||||
current_channel_number = int(current_channel_number) + 1.0
|
||||
# Increment channel number for next iteration (only in fixed mode)
|
||||
if channel_numbering_mode == "fixed":
|
||||
current_channel_number += 1.0
|
||||
if current_channel_number % 1 != 0: # Has decimal
|
||||
current_channel_number = int(current_channel_number) + 1.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -2305,10 +2411,12 @@ def get_transformed_credentials(account, profile=None):
|
|||
parsed_url = urllib.parse.urlparse(transformed_complete_url)
|
||||
path_parts = [part for part in parsed_url.path.split('/') if part]
|
||||
|
||||
if len(path_parts) >= 2:
|
||||
# Extract username and password from path
|
||||
transformed_username = path_parts[1]
|
||||
transformed_password = path_parts[2]
|
||||
if len(path_parts) >= 4 and path_parts[-1] == '1234.ts':
|
||||
# Extract username and password from the known structure:
|
||||
# .../{live}/{username}/{password}/1234.ts
|
||||
# Using negative indices so sub-paths in the server URL don't shift extraction
|
||||
transformed_username = path_parts[-3]
|
||||
transformed_password = path_parts[-2]
|
||||
|
||||
# Rebuild server URL without the username/password path
|
||||
transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
|
|
@ -2542,12 +2650,28 @@ def refresh_account_info(profile_id):
|
|||
|
||||
release_task_lock("refresh_account_info", profile_id)
|
||||
return error_msg
|
||||
@shared_task
|
||||
@shared_task(time_limit=3600, soft_time_limit=3500)
|
||||
def refresh_single_m3u_account(account_id):
|
||||
"""Splits M3U processing into chunks and dispatches them as parallel tasks."""
|
||||
if not acquire_task_lock("refresh_single_m3u_account", account_id):
|
||||
return f"Task already running for account_id={account_id}."
|
||||
|
||||
# Keep the lock alive while this long-running task is working.
|
||||
# Without renewal, the 300s lock TTL can expire during large
|
||||
# downloads/parses, allowing duplicate tasks to start.
|
||||
lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id)
|
||||
lock_renewer.start()
|
||||
|
||||
try:
|
||||
return _refresh_single_m3u_account_impl(account_id)
|
||||
finally:
|
||||
# Guaranteed cleanup on all exit paths (success, exception, early return)
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
|
||||
|
||||
def _refresh_single_m3u_account_impl(account_id):
|
||||
"""Implementation of M3U account refresh with guaranteed memory cleanup."""
|
||||
# Record start time
|
||||
refresh_start_timestamp = timezone.now() # For the cleanup function
|
||||
start_time = time.time() # For tracking elapsed time as float
|
||||
|
|
@ -2559,7 +2683,6 @@ def refresh_single_m3u_account(account_id):
|
|||
account = M3UAccount.objects.get(id=account_id, is_active=True)
|
||||
if not account.is_active:
|
||||
logger.debug(f"Account {account_id} is not active, skipping.")
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
return
|
||||
|
||||
# Set status to fetching
|
||||
|
|
@ -2588,7 +2711,6 @@ def refresh_single_m3u_account(account_id):
|
|||
else:
|
||||
logger.debug(f"No orphaned task found for M3U account {account_id}")
|
||||
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up"
|
||||
|
||||
# Fetch M3U lines and handle potential issues
|
||||
|
|
@ -2603,6 +2725,7 @@ def refresh_single_m3u_account(account_id):
|
|||
|
||||
extinf_data = data["extinf_data"]
|
||||
groups = data["groups"]
|
||||
del data # Free top-level dict; extinf_data/groups retain their references
|
||||
except json.JSONDecodeError as e:
|
||||
# Handle corrupted JSON file
|
||||
logger.error(
|
||||
|
|
@ -2638,7 +2761,6 @@ def refresh_single_m3u_account(account_id):
|
|||
logger.error(
|
||||
f"Failed to refresh M3U groups for account {account_id}: {result}"
|
||||
)
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
return "Failed to update m3u account - download failed or other error"
|
||||
|
||||
extinf_data, groups = result
|
||||
|
|
@ -2671,7 +2793,6 @@ def refresh_single_m3u_account(account_id):
|
|||
status="error",
|
||||
error=f"Error refreshing M3U groups: {str(e)}",
|
||||
)
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
return "Failed to update m3u account"
|
||||
|
||||
# Only proceed with parsing if we actually have data and no errors were encountered
|
||||
|
|
@ -2694,7 +2815,6 @@ def refresh_single_m3u_account(account_id):
|
|||
status="error",
|
||||
error="No data available for processing",
|
||||
)
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
return "Failed to update m3u account, no data available"
|
||||
|
||||
hash_keys = CoreSettings.get_m3u_hash_key().split(",")
|
||||
|
|
@ -2840,6 +2960,9 @@ def refresh_single_m3u_account(account_id):
|
|||
|
||||
logger.info(f"Processing {len(all_xc_streams)} XC streams in {len(batches)} batches")
|
||||
|
||||
# Free the original list; batches hold independent sliced copies
|
||||
del all_xc_streams
|
||||
|
||||
# Use threading for XC stream processing - now with consistent batch sizes
|
||||
max_workers = min(4, len(batches))
|
||||
logger.debug(f"Using {max_workers} threads for XC stream processing")
|
||||
|
|
@ -3000,31 +3123,38 @@ def refresh_single_m3u_account(account_id):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing M3U for account {account_id}: {str(e)}")
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error processing M3U: {str(e)}"
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
try:
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error processing M3U: {str(e)}"
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
except Exception:
|
||||
logger.debug(f"Failed to update account {account_id} status during error handling")
|
||||
raise # Re-raise the exception for Celery to handle
|
||||
finally:
|
||||
# Free large data structures regardless of success or failure
|
||||
if 'existing_groups' in locals():
|
||||
del existing_groups
|
||||
if 'extinf_data' in locals():
|
||||
del extinf_data
|
||||
if 'groups' in locals():
|
||||
del groups
|
||||
if 'batches' in locals():
|
||||
del batches
|
||||
if 'all_xc_streams' in locals():
|
||||
del all_xc_streams
|
||||
if 'data' in locals():
|
||||
del data
|
||||
if 'filtered_groups' in locals():
|
||||
del filtered_groups
|
||||
if 'channel_group_relationships' in locals():
|
||||
del channel_group_relationships
|
||||
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
|
||||
# Aggressive garbage collection
|
||||
# Only delete variables if they exist
|
||||
if 'existing_groups' in locals():
|
||||
del existing_groups
|
||||
if 'extinf_data' in locals():
|
||||
del extinf_data
|
||||
if 'groups' in locals():
|
||||
del groups
|
||||
if 'batches' in locals():
|
||||
del batches
|
||||
|
||||
from core.utils import cleanup_memory
|
||||
|
||||
cleanup_memory(log_usage=True, force_collection=True)
|
||||
|
||||
# Clean up cache file since we've fully processed it
|
||||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
# Remove cache file after processing (success or failure)
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
try:
|
||||
os.remove(cache_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return f"Dispatched jobs complete."
|
||||
|
||||
|
|
|
|||
0
apps/m3u/tests/__init__.py
Normal file
0
apps/m3u/tests/__init__.py
Normal file
41
apps/m3u/tests/test_extinf_parsing.py
Normal file
41
apps/m3u/tests/test_extinf_parsing.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.m3u.tasks import parse_extinf_line
|
||||
|
||||
|
||||
class ParseExtinfLineTests(SimpleTestCase):
|
||||
def test_preserves_equals_padding_in_tvg_logo(self):
|
||||
line = (
|
||||
'#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" '
|
||||
'tvg-chno="1001" '
|
||||
'tvg-name="UK Sky Sports Premier League" '
|
||||
'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?'
|
||||
'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" '
|
||||
'group-title="Team Games",UK Sky Sports Premier League'
|
||||
)
|
||||
|
||||
parsed = parse_extinf_line(line)
|
||||
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(
|
||||
parsed["attributes"]["tvg-logo"],
|
||||
"https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==",
|
||||
)
|
||||
self.assertEqual(parsed["attributes"]["group-title"], "Team Games")
|
||||
self.assertEqual(parsed["name"], "UK Sky Sports Premier League")
|
||||
|
||||
def test_supports_single_quoted_attributes(self):
|
||||
line = (
|
||||
"#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' "
|
||||
"group-title='Sports',Channel One"
|
||||
)
|
||||
|
||||
parsed = parse_extinf_line(line)
|
||||
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(
|
||||
parsed["attributes"]["tvg-logo"],
|
||||
"https://example.com/logo==.png",
|
||||
)
|
||||
self.assertEqual(parsed["attributes"]["group-title"], "Sports")
|
||||
self.assertEqual(parsed["display_name"], "Channel One")
|
||||
106
apps/m3u/tests/test_memory_cleanup.py
Normal file
106
apps/m3u/tests/test_memory_cleanup.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""
|
||||
Tests for memory cleanup behavior in M3U refresh tasks.
|
||||
|
||||
Verifies that database connections are properly closed, task locks are
|
||||
released on all exit paths, and garbage collection runs where expected.
|
||||
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
|
||||
class ProcessM3UBatchCleanupTests(SimpleTestCase):
|
||||
"""Verify process_m3u_batch_direct cleans up after processing."""
|
||||
|
||||
@patch("apps.m3u.tasks.Stream")
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls):
|
||||
"""Database connections must be closed after batch processing (thread safety)."""
|
||||
from apps.m3u.tasks import process_m3u_batch_direct
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account.filters.order_by.return_value = []
|
||||
mock_account_cls.objects.get.return_value = mock_account
|
||||
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
|
||||
[]
|
||||
)
|
||||
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
|
||||
|
||||
with patch("django.db.connections") as mock_connections:
|
||||
process_m3u_batch_direct(1, [], {}, ["name", "url"])
|
||||
mock_connections.close_all.assert_called()
|
||||
|
||||
|
||||
class LockReleaseTests(SimpleTestCase):
|
||||
"""Verify task lock is released on all exit paths."""
|
||||
|
||||
@patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False)
|
||||
def test_lock_released_on_account_not_found(self, mock_delete):
|
||||
"""release_task_lock must be called when account does not exist."""
|
||||
with patch(
|
||||
"apps.m3u.tasks.acquire_task_lock", return_value=True
|
||||
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
|
||||
"apps.m3u.tasks.TaskLockRenewer"
|
||||
):
|
||||
with patch(
|
||||
"apps.m3u.tasks.M3UAccount.objects.get",
|
||||
side_effect=M3UAccount.DoesNotExist,
|
||||
):
|
||||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
|
||||
refresh_single_m3u_account(99999)
|
||||
|
||||
mock_release.assert_called_once_with(
|
||||
"refresh_single_m3u_account", 99999
|
||||
)
|
||||
|
||||
def test_lock_released_on_exception(self):
|
||||
"""release_task_lock must be called when an exception is raised."""
|
||||
mock_account = MagicMock()
|
||||
mock_account.is_active = True
|
||||
mock_account.account_type = "STD"
|
||||
mock_account.custom_properties = {}
|
||||
mock_account.filters.all.return_value = []
|
||||
mock_account.status = MagicMock()
|
||||
|
||||
with patch(
|
||||
"apps.m3u.tasks.acquire_task_lock", return_value=True
|
||||
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
|
||||
"apps.m3u.tasks.TaskLockRenewer"
|
||||
):
|
||||
with patch(
|
||||
"apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account
|
||||
):
|
||||
with patch("os.path.exists", return_value=False):
|
||||
with patch(
|
||||
"apps.m3u.tasks.refresh_m3u_groups",
|
||||
side_effect=RuntimeError("test"),
|
||||
):
|
||||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
|
||||
try:
|
||||
refresh_single_m3u_account(1)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
mock_release.assert_called_once_with("refresh_single_m3u_account", 1)
|
||||
|
||||
|
||||
class XCCategoryCleanupTests(SimpleTestCase):
|
||||
"""Regression guard: process_xc_category_direct must continue to clean up."""
|
||||
|
||||
@patch("apps.m3u.tasks.XCClient")
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client):
|
||||
"""gc.collect() must be called after XC category processing."""
|
||||
from apps.m3u.tasks import process_xc_category_direct
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account_cls.objects.get.return_value = mock_account
|
||||
|
||||
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
|
||||
process_xc_category_direct(1, {}, {}, ["name", "url"])
|
||||
mock_gc.assert_called()
|
||||
|
|
@ -183,8 +183,9 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
# Check if this is an XC API request (has username/password in GET params and user is authenticated)
|
||||
xc_username = request.GET.get('username')
|
||||
xc_password = request.GET.get('password')
|
||||
is_xc_request = user is not None and xc_username and xc_password
|
||||
|
||||
if user is not None and xc_username and xc_password:
|
||||
if is_xc_request:
|
||||
# This is an XC API request - use XC-style EPG URL
|
||||
base_url = build_absolute_uri_with_port(request, '')
|
||||
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
|
||||
|
|
@ -254,8 +255,12 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n'
|
||||
)
|
||||
|
||||
# Determine the stream URL based on the direct parameter
|
||||
if use_direct_urls:
|
||||
# Determine the stream URL based on request type
|
||||
if is_xc_request:
|
||||
# XC API request - use XC-style stream URL format
|
||||
base_url = build_absolute_uri_with_port(request, '')
|
||||
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}"
|
||||
elif use_direct_urls:
|
||||
# Try to get the first stream's direct URL
|
||||
first_stream = channel.streams.order_by('channelstream__order').first()
|
||||
if first_stream and first_stream.url:
|
||||
|
|
@ -506,6 +511,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone
|
||||
program_duration = custom_properties.get('program_duration', 180) # Minutes
|
||||
title_template = custom_properties.get('title_template', '')
|
||||
subtitle_template = custom_properties.get('subtitle_template', '')
|
||||
description_template = custom_properties.get('description_template', '')
|
||||
|
||||
# Templates for upcoming/ended programs
|
||||
|
|
@ -911,6 +917,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
title_parts.append(all_groups['title'])
|
||||
main_event_title = ' - '.join(title_parts) if title_parts else channel_name
|
||||
|
||||
if subtitle_template:
|
||||
main_event_subtitle = format_template(subtitle_template, all_groups)
|
||||
else:
|
||||
main_event_subtitle = None
|
||||
|
||||
if description_template:
|
||||
main_event_description = format_template(description_template, all_groups)
|
||||
else:
|
||||
|
|
@ -961,6 +972,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": upcoming_title,
|
||||
"sub_title": None, # No subtitle for filler programs
|
||||
"description": upcoming_description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1000,6 +1012,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": event_start_utc,
|
||||
"end_time": event_end_utc,
|
||||
"title": main_event_title,
|
||||
"sub_title": main_event_subtitle,
|
||||
"description": main_event_description,
|
||||
"custom_properties": main_event_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1044,6 +1057,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": ended_title,
|
||||
"sub_title": None, # No subtitle for filler programs
|
||||
"description": ended_description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1104,6 +1118,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": program_title,
|
||||
"sub_title": None, # No subtitle for filler programs
|
||||
"description": program_description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url,
|
||||
|
|
@ -1131,6 +1146,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
title_parts.append(all_groups['title'])
|
||||
title = ' - '.join(title_parts) if title_parts else channel_name
|
||||
|
||||
if subtitle_template:
|
||||
subtitle = format_template(subtitle_template, all_groups)
|
||||
else:
|
||||
subtitle = None
|
||||
|
||||
if description_template:
|
||||
description = format_template(description_template, all_groups)
|
||||
else:
|
||||
|
|
@ -1167,6 +1187,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": title,
|
||||
"sub_title": subtitle,
|
||||
"description": description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1206,6 +1227,11 @@ def generate_dummy_epg(
|
|||
f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(program["channel_id"])}">'
|
||||
)
|
||||
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
|
||||
|
||||
# Add subtitle if available
|
||||
if program.get('sub_title'):
|
||||
xml_lines.append(f" <sub-title>{html.escape(program['sub_title'])}</sub-title>")
|
||||
|
||||
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")
|
||||
|
||||
# Add custom_properties if present
|
||||
|
|
@ -1525,6 +1551,11 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
# Create program entry with escaped channel name
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
|
||||
# Add subtitle if available
|
||||
if program.get('sub_title'):
|
||||
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
|
||||
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
|
||||
# Add custom_properties if present
|
||||
|
|
@ -1574,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
|
||||
# Add subtitle if available
|
||||
if program.get('sub_title'):
|
||||
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
|
||||
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
|
||||
# Add custom_properties if present
|
||||
|
|
@ -1607,12 +1643,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(
|
||||
start_time__gte=now,
|
||||
end_time__gte=now,
|
||||
start_time__lt=cutoff_date
|
||||
).order_by('id') # Explicit ordering for consistent chunking
|
||||
else:
|
||||
# Return all programs if days=0 or not specified
|
||||
programs_qs = channel.epg_data.programs.all().order_by('id')
|
||||
# Return all non-expired programs if days=0 or not specified
|
||||
programs_qs = channel.epg_data.programs.filter(
|
||||
end_time__gte=now
|
||||
).order_by('id')
|
||||
|
||||
# Process programs in chunks to avoid cursor timeout issues
|
||||
program_batch = []
|
||||
|
|
@ -1909,6 +1947,7 @@ def xc_get_user(request):
|
|||
return None
|
||||
|
||||
user = get_object_or_404(User, username=username)
|
||||
|
||||
custom_properties = user.custom_properties or {}
|
||||
|
||||
if "xc_password" not in custom_properties:
|
||||
|
|
@ -2208,7 +2247,7 @@ def xc_get_live_streams(request, user, category_id=None):
|
|||
)
|
||||
),
|
||||
"epg_channel_id": str(channel_num_int),
|
||||
"added": int(channel.created_at.timestamp()),
|
||||
"added": str(int(channel.created_at.timestamp())),
|
||||
"is_adult": int(channel.is_adult),
|
||||
"category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id),
|
||||
"category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id],
|
||||
|
|
@ -2523,6 +2562,8 @@ def xc_get_series(request, user, category_id=None):
|
|||
"episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
"category_ids": [int(relation.category.id)] if relation.category else [],
|
||||
"tmdb_id": series.tmdb_id or "",
|
||||
"imdb_id": series.imdb_id or "",
|
||||
})
|
||||
|
||||
return series_list
|
||||
|
|
@ -2870,7 +2911,7 @@ def xc_get_vod_info(request, user, vod_id):
|
|||
"movie_data": {
|
||||
"stream_id": movie.id,
|
||||
"name": movie.name,
|
||||
"added": int(movie_relation.created_at.timestamp()),
|
||||
"added": str(int(movie_relation.created_at.timestamp())),
|
||||
"category_id": str(movie_relation.category.id) if movie_relation.category else "0",
|
||||
"category_ids": [int(movie_relation.category.id)] if movie_relation.category else [],
|
||||
"container_extension": movie_relation.container_extension or "mp4",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ class PluginActionSerializer(serializers.Serializer):
|
|||
button_label = serializers.CharField(required=False, allow_blank=True)
|
||||
button_variant = serializers.CharField(required=False, allow_blank=True)
|
||||
button_color = serializers.CharField(required=False, allow_blank=True)
|
||||
events = serializers.ListField(
|
||||
child=serializers.CharField(), required=False, allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class PluginFieldOptionSerializer(serializers.Serializer):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@ class ProxyConfig(AppConfig):
|
|||
from .hls_proxy.server import ProxyServer as HLSProxyServer
|
||||
from .ts_proxy.server import ProxyServer as TSProxyServer
|
||||
|
||||
# Initialize proxy servers
|
||||
# Initialize proxy servers (TS uses singleton to prevent duplicate instances)
|
||||
self.hls_proxy = HLSProxyServer()
|
||||
self.ts_proxy = TSProxyServer()
|
||||
self.ts_proxy = TSProxyServer.get_instance()
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class BaseConfig:
|
|||
"redis_chunk_ttl": 60,
|
||||
"channel_shutdown_delay": 0,
|
||||
"channel_init_grace_period": 5,
|
||||
"new_client_behind_seconds": 5,
|
||||
}
|
||||
|
||||
finally:
|
||||
|
|
@ -81,6 +82,7 @@ class TSConfig(BaseConfig):
|
|||
# Buffer settings
|
||||
INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB)
|
||||
CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch
|
||||
NEW_CLIENT_BEHIND_SECONDS = 5 # Start new clients this many seconds behind live (0 = start at live)
|
||||
KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head
|
||||
# Chunk read timeout
|
||||
CHUNK_TIMEOUT = 5 # Seconds to wait for each chunk read
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class ClientManager:
|
|||
self.channel_id = channel_id
|
||||
self.redis_client = redis_client
|
||||
self.clients = set()
|
||||
self.lock = threading.Lock()
|
||||
self.lock = threading.RLock()
|
||||
self.last_active_time = time.time()
|
||||
self.worker_id = worker_id # Store worker ID as instance variable
|
||||
self._heartbeat_running = True # Flag to control heartbeat thread
|
||||
|
|
@ -43,14 +43,20 @@ class ClientManager:
|
|||
self._registered_clients = set() # Track already registered client IDs
|
||||
|
||||
def _trigger_stats_update(self):
|
||||
"""Trigger a channel stats update via WebSocket"""
|
||||
"""Trigger a channel stats update via WebSocket in a background thread.
|
||||
|
||||
Offloaded so the caller is not blocked. send_websocket_update is
|
||||
gevent-safe (offloads async_to_sync to a native OS thread).
|
||||
"""
|
||||
threading.Thread(target=self._do_stats_update, daemon=True).start()
|
||||
|
||||
def _do_stats_update(self):
|
||||
"""Perform the stats update in the background."""
|
||||
try:
|
||||
# Import here to avoid potential import issues
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
import redis
|
||||
from django.conf import settings
|
||||
|
||||
# Get all channels from Redis using settings
|
||||
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
|
||||
redis_client = redis.Redis.from_url(redis_url, decode_responses=True)
|
||||
all_channels = []
|
||||
|
|
@ -59,7 +65,6 @@ class ClientManager:
|
|||
while True:
|
||||
cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100)
|
||||
for key in keys:
|
||||
# Extract channel ID from key
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 4:
|
||||
ch_id = parts[2]
|
||||
|
|
@ -70,7 +75,6 @@ class ClientManager:
|
|||
if cursor == 0:
|
||||
break
|
||||
|
||||
# Send WebSocket update using existing infrastructure
|
||||
send_websocket_update(
|
||||
"updates",
|
||||
"update",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ class ConfigHelper:
|
|||
"""Get number of chunks to start behind"""
|
||||
return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 4)
|
||||
|
||||
@staticmethod
|
||||
def new_client_behind_seconds():
|
||||
"""Get number of seconds behind live to start new clients.
|
||||
0 means start at live (buffer head).
|
||||
Loaded from DB proxy_settings so users can change it at runtime."""
|
||||
from apps.proxy.config import TSConfig
|
||||
settings = TSConfig.get_proxy_settings()
|
||||
return settings.get('new_client_behind_seconds', 5)
|
||||
|
||||
@staticmethod
|
||||
def keepalive_interval():
|
||||
"""Get keepalive interval in seconds"""
|
||||
|
|
|
|||
|
|
@ -79,6 +79,12 @@ class RedisKeys:
|
|||
"""Key for worker heartbeat"""
|
||||
return f"ts_proxy:worker:{worker_id}:heartbeat"
|
||||
|
||||
@staticmethod
|
||||
def chunk_timestamps(channel_id):
|
||||
"""Sorted set mapping chunk receive-timestamps (score) to chunk indices (member).
|
||||
Used for time-based client positioning."""
|
||||
return f"ts_proxy:channel:{channel_id}:buffer:chunk_timestamps"
|
||||
|
||||
@staticmethod
|
||||
def transcode_active(channel_id):
|
||||
"""Key indicating active transcode process"""
|
||||
|
|
|
|||
|
|
@ -34,18 +34,32 @@ logger = get_logger()
|
|||
class ProxyServer:
|
||||
"""Manages TS proxy server instance with worker coordination"""
|
||||
_instance = None
|
||||
_INITIALIZING = object() # sentinel for gevent-safe singleton
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
from .server import ProxyServer
|
||||
from .stream_manager import StreamManager
|
||||
from .stream_buffer import StreamBuffer
|
||||
from .client_manager import ClientManager
|
||||
|
||||
cls._instance = ProxyServer()
|
||||
|
||||
return cls._instance
|
||||
inst = cls._instance
|
||||
if inst is not None and inst is not cls._INITIALIZING:
|
||||
return inst
|
||||
if inst is None:
|
||||
cls._instance = cls._INITIALIZING
|
||||
try:
|
||||
from .server import ProxyServer
|
||||
from .stream_manager import StreamManager
|
||||
from .stream_buffer import StreamBuffer
|
||||
from .client_manager import ClientManager
|
||||
real_instance = ProxyServer()
|
||||
cls._instance = real_instance
|
||||
return real_instance
|
||||
except Exception:
|
||||
cls._instance = None # Reset so next call can retry
|
||||
raise
|
||||
# Another greenlet is initializing — wait for completion
|
||||
while True:
|
||||
inst = cls._instance
|
||||
if inst is not None and inst is not cls._INITIALIZING:
|
||||
return inst
|
||||
gevent.sleep(0.05)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize proxy server with worker identification"""
|
||||
|
|
@ -353,9 +367,16 @@ class ProxyServer:
|
|||
|
||||
try:
|
||||
lock_key = RedisKeys.channel_owner(channel_id)
|
||||
return self._execute_redis_command(
|
||||
lambda: self.redis_client.get(lock_key).decode('utf-8') if self.redis_client.get(lock_key) else None
|
||||
result = self._execute_redis_command(
|
||||
lambda: self.redis_client.get(lock_key)
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
try:
|
||||
return result.decode('utf-8')
|
||||
except (AttributeError, UnicodeDecodeError) as e:
|
||||
logger.error(f"Error decoding channel owner for {channel_id}: {e}, raw={result!r}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting channel owner: {e}")
|
||||
return None
|
||||
|
|
@ -374,20 +395,16 @@ class ProxyServer:
|
|||
# Create a lock key with proper namespace
|
||||
lock_key = RedisKeys.channel_owner(channel_id)
|
||||
|
||||
# Use Redis SETNX for atomic locking with error handling
|
||||
# Use atomic SET NX EX for locking with error handling
|
||||
acquired = self._execute_redis_command(
|
||||
lambda: self.redis_client.setnx(lock_key, self.worker_id)
|
||||
lambda: self.redis_client.set(lock_key, self.worker_id, nx=True, ex=ttl)
|
||||
)
|
||||
|
||||
if acquired is None: # Redis command failed
|
||||
logger.warning(f"Redis command failed during ownership acquisition - assuming ownership")
|
||||
return True
|
||||
|
||||
# If acquired, set expiry to prevent orphaned locks
|
||||
if acquired:
|
||||
self._execute_redis_command(
|
||||
lambda: self.redis_client.expire(lock_key, ttl)
|
||||
)
|
||||
logger.info(f"Worker {self.worker_id} acquired ownership of channel {channel_id}")
|
||||
return True
|
||||
|
||||
|
|
@ -433,7 +450,7 @@ class ProxyServer:
|
|||
logger.error(f"Error releasing channel ownership: {e}")
|
||||
|
||||
def extend_ownership(self, channel_id, ttl=30):
|
||||
"""Extend ownership lease with grace period"""
|
||||
"""Extend ownership lease, re-acquiring if key expired"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
|
|
@ -441,10 +458,23 @@ class ProxyServer:
|
|||
lock_key = RedisKeys.channel_owner(channel_id)
|
||||
current = self.redis_client.get(lock_key)
|
||||
|
||||
# Only extend if we're still the owner
|
||||
if current and current.decode('utf-8') == self.worker_id:
|
||||
if current is None:
|
||||
# Key expired — re-acquire if we have the stream_manager
|
||||
if channel_id in self.stream_managers:
|
||||
acquired = self.redis_client.set(lock_key, self.worker_id, nx=True, ex=ttl)
|
||||
if acquired:
|
||||
logger.warning(f"Re-acquired expired ownership for channel {channel_id}")
|
||||
return True
|
||||
else:
|
||||
new_owner = self.redis_client.get(lock_key)
|
||||
logger.warning(f"Could not re-acquire ownership for {channel_id}, new owner: {new_owner}")
|
||||
return False
|
||||
return False
|
||||
|
||||
if current.decode('utf-8') == self.worker_id:
|
||||
self.redis_client.expire(lock_key, ttl)
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error extending ownership: {e}")
|
||||
|
|
@ -766,7 +796,10 @@ class ProxyServer:
|
|||
if self.redis_client.exists(key):
|
||||
# Found orphaned keys without metadata - clean them up
|
||||
logger.warning(f"Found orphaned keys for channel {channel_id} without metadata - cleaning up")
|
||||
self._clean_redis_keys(channel_id)
|
||||
try:
|
||||
self._clean_redis_keys(channel_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning redis keys for channel {channel_id}: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
|
@ -788,13 +821,17 @@ class ProxyServer:
|
|||
# Force release resources in the Channel model
|
||||
try:
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
logger.info(f"Released stream allocation for zombie channel {channel_id}")
|
||||
if not channel.release_stream():
|
||||
logger.warning(f"Failed to release stream for zombie channel {channel_id}")
|
||||
else:
|
||||
logger.info(f"Released stream allocation for zombie channel {channel_id}")
|
||||
except Exception as e:
|
||||
try:
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
stream.release_stream()
|
||||
logger.info(f"Released stream allocation for zombie channel {channel_id}")
|
||||
if not stream.release_stream():
|
||||
logger.warning(f"Failed to release stream for zombie channel {channel_id}")
|
||||
else:
|
||||
logger.info(f"Released stream allocation for zombie channel {channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}")
|
||||
|
||||
|
|
@ -1173,6 +1210,33 @@ class ProxyServer:
|
|||
|
||||
else:
|
||||
# === NON-OWNER CHANNEL HANDLING ===
|
||||
# Safety: if we have a stream_manager, we ARE the real owner
|
||||
# but the Redis key may have expired. Try to re-acquire.
|
||||
if channel_id in self.stream_managers:
|
||||
logger.warning(
|
||||
f"Ownership gap for {channel_id}: this worker has stream_manager "
|
||||
f"but am_i_owner returned False. Attempting re-acquisition."
|
||||
)
|
||||
reacquired = self.extend_ownership(channel_id)
|
||||
if reacquired:
|
||||
logger.info(f"Successfully re-acquired ownership for {channel_id}")
|
||||
continue
|
||||
else:
|
||||
# Defer cleanup if we still have active clients — give the
|
||||
# new owner time to spin up its own stream before we tear
|
||||
# ours down, so viewers don't get disconnected.
|
||||
has_clients = (
|
||||
channel_id in self.client_managers
|
||||
and self.client_managers[channel_id].get_client_count() > 0
|
||||
)
|
||||
if has_clients:
|
||||
logger.warning(
|
||||
f"Ownership lost for {channel_id} but {self.client_managers[channel_id].get_client_count()} "
|
||||
f"client(s) still connected — deferring cleanup to next cycle"
|
||||
)
|
||||
continue
|
||||
logger.error(f"Failed to re-acquire ownership for {channel_id}, will clean up")
|
||||
|
||||
# For channels we don't own, check if they've been stopped/cleaned up in Redis
|
||||
if self.redis_client:
|
||||
# Method 1: Check for stopping key
|
||||
|
|
@ -1339,10 +1403,15 @@ class ProxyServer:
|
|||
# Release the channel, stream, and profile keys from the channel
|
||||
try:
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
except:
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
stream.release_stream()
|
||||
if not channel.release_stream():
|
||||
logger.debug(f"Channel {channel_id}: release_stream found no keys to clean")
|
||||
except (Channel.DoesNotExist, Exception):
|
||||
try:
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
if not stream.release_stream():
|
||||
logger.debug(f"Stream {channel_id}: release_stream found no keys to clean")
|
||||
except (Stream.DoesNotExist, Exception):
|
||||
logger.debug(f"No Channel or Stream found for {channel_id}")
|
||||
|
||||
if not self.redis_client:
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -265,18 +265,23 @@ class ChannelService:
|
|||
# Release the channel in the channel model if applicable
|
||||
try:
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
logger.info(f"Released channel {channel_id} stream allocation")
|
||||
model_released = True
|
||||
except Channel.DoesNotExist:
|
||||
model_released = channel.release_stream()
|
||||
if model_released:
|
||||
logger.info(f"Released channel {channel_id} stream allocation")
|
||||
else:
|
||||
logger.warning(f"Channel {channel_id}: release_stream found no keys to clean")
|
||||
except (Channel.DoesNotExist, Exception):
|
||||
logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash")
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
stream.release_stream()
|
||||
logger.info(f"Released stream {channel_id} stream allocation")
|
||||
model_released = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing channel stream: {e}")
|
||||
model_released = False
|
||||
try:
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
model_released = stream.release_stream()
|
||||
if model_released:
|
||||
logger.info(f"Released stream {channel_id} stream allocation")
|
||||
else:
|
||||
logger.warning(f"Stream {channel_id}: release_stream found no keys to clean")
|
||||
except (Stream.DoesNotExist, Exception) as e:
|
||||
logger.error(f"No Channel or Stream found for {channel_id}: {e}")
|
||||
model_released = False
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ class StreamBuffer:
|
|||
self._write_buffer = bytearray()
|
||||
self.target_chunk_size = ConfigHelper.get('BUFFER_CHUNK_SIZE', TS_PACKET_SIZE * 5644) # ~1MB default
|
||||
|
||||
# Sorted-set key for chunk receive-timestamps (time-based positioning)
|
||||
self.chunk_timestamps_key = RedisKeys.chunk_timestamps(channel_id) if channel_id else ""
|
||||
|
||||
# Register Lua scripts once — subsequent calls use EVALSHA (just the
|
||||
# SHA hash) instead of sending the full script text on every invocation.
|
||||
if self.redis_client:
|
||||
self._find_oldest_chunk_sha = self.redis_client.register_script(
|
||||
self._FIND_OLDEST_CHUNK_LUA
|
||||
)
|
||||
self._find_chunk_by_time_sha = self.redis_client.register_script(
|
||||
self._FIND_CHUNK_BY_TIME_LUA
|
||||
)
|
||||
else:
|
||||
self._find_oldest_chunk_sha = None
|
||||
self._find_chunk_by_time_sha = None
|
||||
|
||||
# Track timers for proper cleanup
|
||||
self.stopping = False
|
||||
self.fill_timers = []
|
||||
|
|
@ -92,6 +108,14 @@ class StreamBuffer:
|
|||
chunk_key = RedisKeys.buffer_chunk(self.channel_id, chunk_index)
|
||||
self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(chunk_data))
|
||||
|
||||
# Record receive timestamp for time-based client positioning
|
||||
if self.chunk_timestamps_key:
|
||||
now = time.time()
|
||||
self.redis_client.zadd(self.chunk_timestamps_key, {str(chunk_index): now})
|
||||
# Prune entries whose chunks have expired from Redis
|
||||
self.redis_client.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl)
|
||||
self.redis_client.expire(self.chunk_timestamps_key, self.chunk_ttl)
|
||||
|
||||
# Update local tracking
|
||||
self.index = chunk_index
|
||||
writes_done += 1
|
||||
|
|
@ -275,6 +299,13 @@ class StreamBuffer:
|
|||
if hasattr(self, '_partial_packet'):
|
||||
self._partial_packet = bytearray()
|
||||
|
||||
# Clean up the chunk timestamps sorted set
|
||||
if self.redis_client and self.chunk_timestamps_key:
|
||||
try:
|
||||
self.redis_client.delete(self.chunk_timestamps_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting chunk timestamps key: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during buffer stop: {e}")
|
||||
|
||||
|
|
@ -328,6 +359,146 @@ class StreamBuffer:
|
|||
|
||||
return chunks, client_index + chunk_count
|
||||
|
||||
# Lua script that runs an atomic binary search on the Redis server.
|
||||
# Chunks expire in FIFO order (same TTL, sequential writes), so the
|
||||
# alive range is contiguous: [oldest_surviving .. buffer_head].
|
||||
# Binary search finds the boundary in O(log N) EXISTS calls with zero
|
||||
# round-trips between steps and no TOCTOU races (Lua scripts are atomic).
|
||||
#
|
||||
# ARGV[1] = key prefix (e.g. "ts_proxy:channel:<id>:buffer:chunk:")
|
||||
# ARGV[2] = low index (client_index + 1, first chunk the client needs)
|
||||
# ARGV[3] = high index (buffer head, most recent chunk)
|
||||
#
|
||||
# Returns: the index of the oldest existing chunk, or -1 if none exist.
|
||||
_FIND_OLDEST_CHUNK_LUA = """
|
||||
local prefix = ARGV[1]
|
||||
local low = tonumber(ARGV[2])
|
||||
local high = tonumber(ARGV[3])
|
||||
|
||||
if redis.call('EXISTS', prefix .. high) == 0 then
|
||||
return -1
|
||||
end
|
||||
|
||||
local result = high
|
||||
while low <= high do
|
||||
local mid = math.floor((low + high) / 2)
|
||||
if redis.call('EXISTS', prefix .. mid) == 1 then
|
||||
result = mid
|
||||
high = mid - 1
|
||||
else
|
||||
low = mid + 1
|
||||
end
|
||||
end
|
||||
return result
|
||||
"""
|
||||
|
||||
def find_oldest_available_chunk(self, client_index):
|
||||
"""Find the oldest (lowest-index) chunk that still exists in Redis.
|
||||
|
||||
Executes an atomic Lua binary search on the Redis server — one
|
||||
round-trip, ~log2(N) EXISTS calls, no TOCTOU between steps.
|
||||
|
||||
The actual read attempt (get_optimized_client_data) is what
|
||||
authoritatively detects expiration; this method is best-effort
|
||||
positioning that self-corrects on the next iteration if the found
|
||||
chunk also expires before the client can read it.
|
||||
|
||||
Args:
|
||||
client_index: The client's current local_index (last consumed chunk).
|
||||
|
||||
Returns:
|
||||
int or None: The local_index value the client should jump to
|
||||
(one before the first available chunk), or None if no
|
||||
chunks are available at all.
|
||||
"""
|
||||
if not self.redis_client:
|
||||
return None
|
||||
|
||||
low = client_index + 1 # First chunk the client needs
|
||||
high = self.index # Latest chunk written
|
||||
|
||||
if low > high:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Uses EVALSHA under the hood — sends only the SHA hash,
|
||||
# not the full script text, on every call after the first.
|
||||
result = self._find_oldest_chunk_sha(
|
||||
args=[
|
||||
RedisKeys.buffer_chunk_prefix(self.channel_id),
|
||||
low,
|
||||
high,
|
||||
],
|
||||
)
|
||||
|
||||
if result == -1:
|
||||
return None
|
||||
|
||||
# Return result - 1 so local_index points to one before the
|
||||
# first available chunk (matching the "last consumed" convention).
|
||||
return int(result) - 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running find_oldest_chunk Lua script for channel {self.channel_id}: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lua script: atomic reverse-scan of the chunk_timestamps sorted set.
|
||||
# Finds the chunk whose receive-timestamp is closest to (but <=) a
|
||||
# target wall-clock time. Returns the chunk index or -1.
|
||||
#
|
||||
# KEYS[1] = chunk_timestamps sorted-set key
|
||||
# ARGV[1] = target timestamp (time.time() - desired_seconds_behind)
|
||||
# ------------------------------------------------------------------
|
||||
_FIND_CHUNK_BY_TIME_LUA = """
|
||||
local ts_key = KEYS[1]
|
||||
local target = tonumber(ARGV[1])
|
||||
|
||||
-- ZREVRANGEBYSCORE returns members with score <= target, highest first.
|
||||
local result = redis.call('ZREVRANGEBYSCORE', ts_key, target, '-inf', 'LIMIT', 0, 1)
|
||||
if #result == 0 then
|
||||
return -1
|
||||
end
|
||||
return tonumber(result[1])
|
||||
"""
|
||||
|
||||
def find_chunk_index_by_time(self, seconds_behind):
|
||||
"""Find the chunk index that was received approximately *seconds_behind*
|
||||
seconds ago.
|
||||
|
||||
Uses an atomic Lua script against the chunk_timestamps sorted set so
|
||||
no data can expire between the lookup and the read.
|
||||
|
||||
Returns:
|
||||
int or None: The chunk index to position the client at (this is
|
||||
the *last consumed* convention, so the next read
|
||||
starts at index+1). None if no suitable chunk
|
||||
exists.
|
||||
"""
|
||||
if not self.redis_client or not self.chunk_timestamps_key:
|
||||
return None
|
||||
|
||||
target_time = time.time() - seconds_behind
|
||||
|
||||
try:
|
||||
result = self._find_chunk_by_time_sha(
|
||||
keys=[self.chunk_timestamps_key],
|
||||
args=[target_time],
|
||||
)
|
||||
if result is None or int(result) == -1:
|
||||
# No chunk old enough — fall back to the oldest available chunk
|
||||
oldest = self.redis_client.zrange(self.chunk_timestamps_key, 0, 0)
|
||||
if oldest:
|
||||
return max(0, int(oldest[0]) - 1) # "last consumed" convention
|
||||
return None
|
||||
|
||||
# Return index - 1 so next read starts at that chunk
|
||||
return max(0, int(result) - 1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in find_chunk_index_by_time for channel {self.channel_id}: {e}")
|
||||
return None
|
||||
|
||||
# Add a new method to safely create timers
|
||||
def schedule_timer(self, delay, callback, *args, **kwargs):
|
||||
"""Schedule a timer and track it for proper cleanup"""
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import logging
|
|||
import threading
|
||||
import gevent # Add this import at the top of your file
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.models import Channel, Stream
|
||||
from core.utils import log_system_event
|
||||
from .server import ProxyServer
|
||||
from .utils import create_ts_packet, get_logger
|
||||
|
|
@ -58,6 +58,19 @@ class StreamGenerator:
|
|||
self.last_ttl_refresh = time.time()
|
||||
self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming
|
||||
|
||||
# Cached proxy server reference
|
||||
self.proxy_server = None
|
||||
|
||||
# Non-owner health check throttle: avoid Redis GET on every loop iteration
|
||||
self._last_health_check_time = 0.0
|
||||
self._last_health_check_result = False
|
||||
self._health_check_interval = 2.0 # seconds
|
||||
|
||||
# Resource check throttle: Redis stop/state checks are expensive; throttle
|
||||
# them while allowing cheap in-memory checks to run every iteration.
|
||||
self._last_resource_check_time = 0.0
|
||||
self._resource_check_interval = 1.0 # seconds
|
||||
|
||||
def generate(self):
|
||||
"""
|
||||
Generator function that produces the stream content for the client.
|
||||
|
|
@ -186,12 +199,50 @@ class StreamGenerator:
|
|||
logger.error(f"[{self.client_id}] No buffer found for channel {self.channel_id}")
|
||||
return False
|
||||
|
||||
# Client state tracking - use config for initial position
|
||||
initial_behind = ConfigHelper.initial_behind_chunks()
|
||||
# Client state tracking — determine start position
|
||||
# When behind_seconds > 0, use time-based positioning to start
|
||||
# the client that many seconds behind live.
|
||||
# When behind_seconds == 0, start at live (buffer head).
|
||||
behind_seconds = ConfigHelper.new_client_behind_seconds()
|
||||
current_buffer_index = buffer.index
|
||||
self.local_index = max(0, current_buffer_index - initial_behind)
|
||||
|
||||
if behind_seconds > 0:
|
||||
time_index = buffer.find_chunk_index_by_time(behind_seconds)
|
||||
if time_index is not None:
|
||||
self.local_index = max(0, time_index)
|
||||
logger.info(
|
||||
f"[{self.client_id}] Time-based positioning: "
|
||||
f"{behind_seconds}s behind -> index {self.local_index} "
|
||||
f"(buffer head at {current_buffer_index})"
|
||||
)
|
||||
else:
|
||||
# Not enough buffer for the requested time — start as far
|
||||
# back as possible (oldest available chunk).
|
||||
oldest = buffer.find_oldest_available_chunk(0)
|
||||
if oldest is not None:
|
||||
self.local_index = max(0, oldest)
|
||||
logger.info(
|
||||
f"[{self.client_id}] Buffer shorter than {behind_seconds}s, "
|
||||
f"starting at oldest available chunk {self.local_index} "
|
||||
f"(buffer head at {current_buffer_index})"
|
||||
)
|
||||
else:
|
||||
# No timestamp data at all — start at live
|
||||
self.local_index = current_buffer_index
|
||||
logger.info(
|
||||
f"[{self.client_id}] No timestamp data, starting at live: "
|
||||
f"index {self.local_index} (buffer head at {current_buffer_index})"
|
||||
)
|
||||
else:
|
||||
# 0 = start at live (buffer head)
|
||||
self.local_index = current_buffer_index
|
||||
logger.info(
|
||||
f"[{self.client_id}] Starting at live (behind_seconds=0): "
|
||||
f"index {self.local_index} (buffer head at {current_buffer_index})"
|
||||
)
|
||||
|
||||
# Store important objects as instance variables
|
||||
self.proxy_server = proxy_server
|
||||
self.buffer = buffer
|
||||
self.stream_manager = stream_manager
|
||||
self.last_yield_time = time.time()
|
||||
|
|
@ -224,17 +275,35 @@ class StreamGenerator:
|
|||
self.empty_reads += 1
|
||||
self.consecutive_empty += 1
|
||||
|
||||
# Check if we're too far behind (chunks expired from Redis)
|
||||
# We got no data despite being behind the buffer head.
|
||||
# The read itself is the authoritative signal — no separate
|
||||
# existence check needed, avoiding TOCTOU races with Redis TTL.
|
||||
chunks_behind = self.buffer.index - self.local_index
|
||||
if chunks_behind > 50: # If more than 50 chunks behind, jump forward
|
||||
# Calculate new position: stay a few chunks behind current buffer
|
||||
initial_behind = ConfigHelper.initial_behind_chunks()
|
||||
new_index = max(self.local_index, self.buffer.index - initial_behind)
|
||||
if chunks_behind > 0:
|
||||
# Next chunk has expired — find the oldest chunk still in Redis
|
||||
new_index = self.buffer.find_oldest_available_chunk(self.local_index)
|
||||
|
||||
logger.warning(f"[{self.client_id}] Client too far behind ({chunks_behind} chunks), jumping from {self.local_index} to {new_index}")
|
||||
self.local_index = new_index
|
||||
self.consecutive_empty = 0 # Reset since we're repositioning
|
||||
continue # Try again immediately with new position
|
||||
if new_index is not None:
|
||||
skipped = new_index - self.local_index
|
||||
logger.warning(
|
||||
f"[{self.client_id}] Next chunk expired (index {self.local_index + 1}), "
|
||||
f"jumping to oldest available: {new_index + 1} "
|
||||
f"(skipped {skipped} chunks, buffer head at {self.buffer.index})"
|
||||
)
|
||||
self.local_index = new_index
|
||||
else:
|
||||
# No chunks available at all — jump to near the buffer head
|
||||
initial_behind = ConfigHelper.initial_behind_chunks()
|
||||
new_index = max(self.local_index, self.buffer.index - initial_behind)
|
||||
logger.warning(
|
||||
f"[{self.client_id}] No chunks available in buffer, "
|
||||
f"jumping to near buffer head: {new_index} "
|
||||
f"(buffer head at {self.buffer.index})"
|
||||
)
|
||||
self.local_index = new_index
|
||||
|
||||
self.consecutive_empty = 0
|
||||
continue # Retry immediately with the new position
|
||||
|
||||
if self._should_send_keepalive(self.local_index):
|
||||
keepalive_packet = create_ts_packet('keepalive')
|
||||
|
|
@ -265,9 +334,7 @@ class StreamGenerator:
|
|||
|
||||
def _check_resources(self):
|
||||
"""Check if required resources still exist."""
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
||||
# Enhanced resource checks
|
||||
proxy_server = self.proxy_server or ProxyServer.get_instance()
|
||||
if self.channel_id not in proxy_server.stream_buffers:
|
||||
logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream")
|
||||
return False
|
||||
|
|
@ -276,35 +343,43 @@ class StreamGenerator:
|
|||
logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream")
|
||||
return False
|
||||
|
||||
# Check if this specific client has been stopped (Redis keys, etc.)
|
||||
if proxy_server.redis_client:
|
||||
# Channel stop check - with extended key set
|
||||
stop_key = RedisKeys.channel_stopping(self.channel_id)
|
||||
if proxy_server.redis_client.exists(stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id not in client_manager.clients:
|
||||
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
|
||||
return False
|
||||
|
||||
# --- Redis checks: throttled to _resource_check_interval (default 1s) ---
|
||||
# 3 Redis round-trips on every iteration is expensive at stream rates;
|
||||
# stop/state signals change infrequently so a 1-second poll is sufficient.
|
||||
if not proxy_server.redis_client:
|
||||
return True
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_resource_check_time < self._resource_check_interval:
|
||||
return True
|
||||
|
||||
self._last_resource_check_time = now
|
||||
|
||||
# Channel stop check
|
||||
stop_key = RedisKeys.channel_stopping(self.channel_id)
|
||||
if proxy_server.redis_client.exists(stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
# 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 state in ['error', 'stopped', 'stopping']:
|
||||
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
|
||||
return False
|
||||
|
||||
# Also check 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 state in ['error', 'stopped', 'stopping']:
|
||||
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
|
||||
return False
|
||||
|
||||
# Client stop check
|
||||
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
|
||||
if proxy_server.redis_client.exists(client_stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
# Also check if client has been removed from client_manager
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id not in client_manager.clients:
|
||||
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
|
||||
return False
|
||||
# Client stop check
|
||||
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
|
||||
if proxy_server.redis_client.exists(client_stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -313,7 +388,7 @@ class StreamGenerator:
|
|||
# Process and send chunks
|
||||
total_size = sum(len(c) for c in chunks)
|
||||
logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}")
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
proxy_server = self.proxy_server or ProxyServer.get_instance()
|
||||
|
||||
# Send the chunks to the client
|
||||
for chunk in chunks:
|
||||
|
|
@ -381,10 +456,38 @@ class StreamGenerator:
|
|||
"""Determine if a keepalive packet should be sent."""
|
||||
# Check if we're caught up to buffer head
|
||||
at_buffer_head = local_index >= self.buffer.index
|
||||
if not at_buffer_head or self.consecutive_empty < 5:
|
||||
return False
|
||||
|
||||
# If we're at buffer head and no data is coming, send keepalive
|
||||
stream_healthy = self.stream_manager.healthy if self.stream_manager else True
|
||||
return at_buffer_head and not stream_healthy and self.consecutive_empty >= 5
|
||||
if self.stream_manager is not None:
|
||||
# Owner worker: use the in-memory health flag directly.
|
||||
return not self.stream_manager.healthy
|
||||
else:
|
||||
# Non-owner worker: stream_manager only exists in the owner process.
|
||||
# Approximate health from the Redis last_data timestamp; if stale
|
||||
# beyond CONNECTION_TIMEOUT, send keepalives to prevent DVR timeout.
|
||||
# Throttled: only re-query Redis every _health_check_interval seconds
|
||||
# to avoid a Redis GET on every loop iteration during sustained waits.
|
||||
now = time.time()
|
||||
if now - self._last_health_check_time < self._health_check_interval:
|
||||
return self._last_health_check_result
|
||||
try:
|
||||
proxy_server = self.proxy_server or ProxyServer.get_instance()
|
||||
if proxy_server.redis_client:
|
||||
raw = proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id))
|
||||
if raw:
|
||||
age = now - float(raw)
|
||||
timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10)
|
||||
result = age >= timeout_threshold
|
||||
else:
|
||||
# No timestamp in Redis → key missing or expired → unhealthy
|
||||
result = True
|
||||
self._last_health_check_time = now
|
||||
self._last_health_check_result = result
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _is_ghost_client(self, local_index):
|
||||
"""Check if this appears to be a ghost client (stuck but buffer advancing)."""
|
||||
|
|
@ -437,13 +540,17 @@ class StreamGenerator:
|
|||
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
|
||||
# Only the last client or owner should release the stream
|
||||
if client_count <= 1 and proxy_server.am_i_owner(self.channel_id):
|
||||
from apps.channels.models import Channel
|
||||
try:
|
||||
# Get the channel by UUID
|
||||
channel = Channel.objects.get(uuid=self.channel_id)
|
||||
channel.release_stream()
|
||||
stream_released = True
|
||||
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
|
||||
# Try Channel first (normal flow), fall back to Stream (preview flow)
|
||||
try:
|
||||
obj = Channel.objects.get(uuid=self.channel_id)
|
||||
except (Channel.DoesNotExist, Exception):
|
||||
obj = Stream.objects.get(stream_hash=self.channel_id)
|
||||
stream_released = obj.release_stream()
|
||||
if stream_released:
|
||||
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
|
||||
else:
|
||||
logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}")
|
||||
except Exception as e:
|
||||
|
|
@ -472,8 +579,7 @@ class StreamGenerator:
|
|||
logger.error(f"Could not log client disconnect event: {e}")
|
||||
|
||||
# Schedule channel shutdown if no clients left
|
||||
if not stream_released: # Only if we haven't already released the stream
|
||||
self._schedule_channel_shutdown_if_needed(local_clients)
|
||||
self._schedule_channel_shutdown_if_needed(local_clients)
|
||||
|
||||
def _schedule_channel_shutdown_if_needed(self, local_clients):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -39,84 +39,44 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
|
|||
|
||||
# Handle direct stream preview (custom streams)
|
||||
if isinstance(channel_or_stream, Stream):
|
||||
from core.utils import RedisClient
|
||||
|
||||
stream = channel_or_stream
|
||||
logger.info(f"Previewing stream directly: {stream.id} ({stream.name})")
|
||||
|
||||
# For custom streams, we need to get the M3U account and profile
|
||||
m3u_account = stream.m3u_account
|
||||
if not m3u_account:
|
||||
if not stream.m3u_account:
|
||||
logger.error(f"Stream {stream.id} has no M3U account")
|
||||
return None, None, False, None
|
||||
|
||||
# Get active profiles for this M3U account
|
||||
m3u_profiles = m3u_account.profiles.filter(is_active=True)
|
||||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
|
||||
if not default_profile:
|
||||
logger.error(f"No default active profile found for M3U account {m3u_account.id}")
|
||||
# Use get_stream() to atomically reserve a slot and write the
|
||||
# channel_stream / stream_profile Redis keys, matching the channel
|
||||
# path so stream_name and stream_stats work correctly.
|
||||
stream_id, profile_id, error_reason = stream.get_stream()
|
||||
if not stream_id or not profile_id:
|
||||
logger.error(f"No profile available for stream {stream.id}: {error_reason}")
|
||||
return None, None, False, None
|
||||
|
||||
# Check profiles in order: default first, then others
|
||||
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
|
||||
try:
|
||||
profile = M3UAccountProfile.objects.get(id=profile_id)
|
||||
m3u_account = stream.m3u_account
|
||||
|
||||
# Try to find an available profile with connection capacity
|
||||
redis_client = RedisClient.get_client()
|
||||
selected_profile = None
|
||||
stream_user_agent = m3u_account.get_user_agent().user_agent
|
||||
if stream_user_agent is None:
|
||||
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
|
||||
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
|
||||
|
||||
for profile in profiles:
|
||||
logger.info(profile)
|
||||
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
|
||||
|
||||
# Check connection availability
|
||||
if redis_client:
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
current_connections = int(redis_client.get(profile_connections_key) or 0)
|
||||
stream_profile = stream.get_stream_profile()
|
||||
logger.debug(f"Using stream profile: {stream_profile.name}")
|
||||
|
||||
# Check if profile has available slots (or unlimited connections)
|
||||
if profile.max_streams == 0 or current_connections < profile.max_streams:
|
||||
selected_profile = profile
|
||||
logger.debug(f"Selected profile {profile.id} with {current_connections}/{profile.max_streams} connections for stream preview")
|
||||
break
|
||||
else:
|
||||
logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}")
|
||||
else:
|
||||
# No Redis available, use first active profile
|
||||
selected_profile = profile
|
||||
break
|
||||
transcode = not stream_profile.is_proxy()
|
||||
stream_profile_id = stream_profile.id
|
||||
|
||||
if not selected_profile:
|
||||
logger.error(f"No profiles available with connection capacity for M3U account {m3u_account.id}")
|
||||
return stream_url, stream_user_agent, transcode, stream_profile_id
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating stream URL for stream {stream.id}: {e}")
|
||||
stream.release_stream()
|
||||
return None, None, False, None
|
||||
|
||||
# Get the appropriate user agent
|
||||
stream_user_agent = m3u_account.get_user_agent().user_agent
|
||||
if stream_user_agent is None:
|
||||
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
|
||||
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
|
||||
|
||||
# Get stream URL with the selected profile's URL transformation
|
||||
stream_url = transform_url(stream.url, selected_profile.search_pattern, selected_profile.replace_pattern)
|
||||
|
||||
# Check if the stream has its own stream_profile set, otherwise use default
|
||||
if stream.stream_profile:
|
||||
stream_profile = stream.stream_profile
|
||||
logger.debug(f"Using stream's own stream profile: {stream_profile.name}")
|
||||
else:
|
||||
stream_profile = StreamProfile.objects.get(
|
||||
id=CoreSettings.get_default_stream_profile_id()
|
||||
)
|
||||
logger.debug(f"Using default stream profile: {stream_profile.name}")
|
||||
|
||||
# Check if transcoding is needed
|
||||
if stream_profile.is_proxy() or stream_profile is None:
|
||||
transcode = False
|
||||
else:
|
||||
transcode = True
|
||||
|
||||
stream_profile_id = stream_profile.id
|
||||
|
||||
return stream_url, stream_user_agent, transcode, stream_profile_id
|
||||
|
||||
# Handle channel preview (existing logic)
|
||||
channel = channel_or_stream
|
||||
|
|
@ -129,39 +89,42 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
|
|||
logger.error(f"No stream available for channel {channel_id}: {error_reason}")
|
||||
return None, None, False, None
|
||||
|
||||
# Look up the Stream and Profile objects
|
||||
# get_stream() allocated a connection slot - ensure it's released on any error
|
||||
try:
|
||||
# Look up the Stream and Profile objects
|
||||
stream = Stream.objects.get(id=stream_id)
|
||||
profile = M3UAccountProfile.objects.get(id=profile_id)
|
||||
except (Stream.DoesNotExist, M3UAccountProfile.DoesNotExist) as e:
|
||||
logger.error(f"Error getting stream or profile: {e}")
|
||||
|
||||
# Get the M3U account profile for URL pattern
|
||||
m3u_profile = profile
|
||||
|
||||
# Get the appropriate user agent
|
||||
m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id)
|
||||
stream_user_agent = m3u_account.get_user_agent().user_agent
|
||||
|
||||
if stream_user_agent is None:
|
||||
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
|
||||
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
|
||||
|
||||
# Generate stream URL based on the selected profile
|
||||
input_url = stream.url
|
||||
stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern)
|
||||
|
||||
# Check if transcoding is needed
|
||||
stream_profile = channel.get_stream_profile()
|
||||
if stream_profile.is_proxy() or stream_profile is None:
|
||||
transcode = False
|
||||
else:
|
||||
transcode = True
|
||||
|
||||
stream_profile_id = stream_profile.id
|
||||
|
||||
return stream_url, stream_user_agent, transcode, stream_profile_id
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating stream URL for channel {channel_id}: {e}")
|
||||
if not channel.release_stream():
|
||||
logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error")
|
||||
return None, None, False, None
|
||||
|
||||
# Get the M3U account profile for URL pattern
|
||||
m3u_profile = profile
|
||||
|
||||
# Get the appropriate user agent
|
||||
m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id)
|
||||
stream_user_agent = m3u_account.get_user_agent().user_agent
|
||||
|
||||
if stream_user_agent is None:
|
||||
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
|
||||
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
|
||||
|
||||
# Generate stream URL based on the selected profile
|
||||
input_url = stream.url
|
||||
stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern)
|
||||
|
||||
# Check if transcoding is needed
|
||||
stream_profile = channel.get_stream_profile()
|
||||
if stream_profile.is_proxy() or stream_profile is None:
|
||||
transcode = False
|
||||
else:
|
||||
transcode = True
|
||||
|
||||
stream_profile_id = stream_profile.id
|
||||
|
||||
return stream_url, stream_user_agent, transcode, stream_profile_id
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating stream URL: {e}")
|
||||
return None, None, False, None
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ def stream_ts(request, channel_id):
|
|||
|
||||
client_user_agent = None
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
connection_allocated = False # Track if connection slot was allocated via get_stream()
|
||||
|
||||
try:
|
||||
# Generate a unique client ID
|
||||
|
|
@ -219,9 +220,10 @@ def stream_ts(request, channel_id):
|
|||
)
|
||||
|
||||
if stream_url is None:
|
||||
# Release the channel's stream lock if one was acquired
|
||||
# Note: Only call this if get_stream() actually assigned a stream
|
||||
# In our case, if stream_url is None, no stream was ever assigned, so don't release
|
||||
# Release any connection slot that may have been allocated
|
||||
# by the error-checking get_stream() call during retries
|
||||
if not channel.release_stream():
|
||||
logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup")
|
||||
|
||||
# Get the specific error message if available
|
||||
wait_duration = f"{int(time.time() - wait_start_time)}s"
|
||||
|
|
@ -237,8 +239,23 @@ def stream_ts(request, channel_id):
|
|||
{"error": error_msg, "waited": wait_duration}, status=503
|
||||
) # 503 Service Unavailable is appropriate here
|
||||
|
||||
# Get the stream ID from the channel
|
||||
stream_id, m3u_profile_id, _ = channel.get_stream()
|
||||
# generate_stream_url() called get_stream() which allocated a connection
|
||||
# slot (INCR'd profile_connections) - track this for cleanup on error
|
||||
if needs_initialization:
|
||||
connection_allocated = True
|
||||
|
||||
# Read stream assignment from Redis (already set by generate_stream_url → get_stream).
|
||||
# Avoid calling get_stream() again — (INCR profile counter)
|
||||
# It could double-allocate if the keys were cleared by a concurrent release.
|
||||
stream_id = None
|
||||
m3u_profile_id = None
|
||||
if proxy_server.redis_client:
|
||||
stream_id_bytes = proxy_server.redis_client.get(f"channel_stream:{channel.id}")
|
||||
if stream_id_bytes:
|
||||
stream_id = int(stream_id_bytes)
|
||||
profile_id_bytes = proxy_server.redis_client.get(f"stream_profile:{stream_id}")
|
||||
if profile_id_bytes:
|
||||
m3u_profile_id = int(profile_id_bytes)
|
||||
logger.info(
|
||||
f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}"
|
||||
)
|
||||
|
|
@ -308,7 +325,9 @@ def stream_ts(request, channel_id):
|
|||
f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}"
|
||||
)
|
||||
# Release stream lock before redirecting
|
||||
channel.release_stream()
|
||||
if not channel.release_stream():
|
||||
logger.warning(f"[{client_id}] Failed to release stream before redirect")
|
||||
connection_allocated = False
|
||||
# Final decision based on validation results
|
||||
if is_valid:
|
||||
logger.info(
|
||||
|
|
@ -344,10 +363,17 @@ def stream_ts(request, channel_id):
|
|||
)
|
||||
|
||||
if not success:
|
||||
if connection_allocated:
|
||||
if not channel.release_stream():
|
||||
logger.warning(f"[{client_id}] Failed to release stream after init failure")
|
||||
connection_allocated = False
|
||||
return JsonResponse(
|
||||
{"error": "Failed to initialize channel"}, status=500
|
||||
)
|
||||
|
||||
# Channel initialized - cleanup lifecycle now owns the connection release
|
||||
connection_allocated = False
|
||||
|
||||
# If we're the owner, wait for connection to establish
|
||||
if proxy_server.am_i_owner(channel_id):
|
||||
manager = proxy_server.stream_managers.get(channel_id)
|
||||
|
|
@ -507,6 +533,12 @@ def stream_ts(request, channel_id):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in stream_ts: {e}", exc_info=True)
|
||||
if connection_allocated:
|
||||
try:
|
||||
if not channel.release_stream():
|
||||
logger.warning(f"[{client_id}] Failed to release stream in exception handler")
|
||||
except Exception:
|
||||
pass
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -459,13 +459,12 @@ class VODConnectionManager:
|
|||
return False
|
||||
|
||||
try:
|
||||
# Check profile connection limits using standardized key
|
||||
if not self._check_profile_limits(m3u_profile):
|
||||
# Atomically check and reserve a profile connection slot (INCR-first)
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"Profile {m3u_profile.name} connection limit exceeded")
|
||||
return False
|
||||
|
||||
connection_key = self._get_connection_key(content_type, content_uuid, client_id)
|
||||
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
|
||||
content_connections_key = self._get_content_connections_key(content_type, content_uuid)
|
||||
|
||||
# Check if connection already exists to prevent duplicate counting
|
||||
|
|
@ -473,6 +472,9 @@ class VODConnectionManager:
|
|||
logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}")
|
||||
# Update activity but don't increment profile counter
|
||||
self.redis_client.hset(connection_key, "last_activity", str(time.time()))
|
||||
# Roll back the reservation — connection already counted
|
||||
if m3u_profile.max_streams > 0:
|
||||
self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id))
|
||||
return True
|
||||
|
||||
# Connection data
|
||||
|
|
@ -499,8 +501,7 @@ class VODConnectionManager:
|
|||
pipe.hset(connection_key, mapping=connection_data)
|
||||
pipe.expire(connection_key, self.connection_ttl)
|
||||
|
||||
# Increment profile connections using standardized method
|
||||
pipe.incr(profile_connections_key)
|
||||
# Profile counter already incremented atomically above — no pipe.incr needed
|
||||
|
||||
# Add to content connections set
|
||||
pipe.sadd(content_connections_key, client_id)
|
||||
|
|
@ -513,6 +514,9 @@ class VODConnectionManager:
|
|||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Roll back the profile reservation on failure
|
||||
if m3u_profile.max_streams > 0:
|
||||
self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id))
|
||||
logger.error(f"Error creating VOD connection: {e}")
|
||||
return False
|
||||
|
||||
|
|
@ -531,6 +535,41 @@ class VODConnectionManager:
|
|||
logger.error(f"Error checking profile limits: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_reserve_profile_slot(self, m3u_profile: M3UAccountProfile) -> bool:
|
||||
"""
|
||||
Atomically check and reserve a connection slot for the given profile.
|
||||
|
||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
||||
condition where separate GET > check > INCR operations could allow
|
||||
concurrent requests to both pass the capacity check.
|
||||
|
||||
For profiles with max_streams=0 (unlimited), no reservation is needed.
|
||||
|
||||
Returns:
|
||||
bool: True if slot was reserved (or unlimited), False if at capacity
|
||||
"""
|
||||
if m3u_profile.max_streams == 0: # Unlimited
|
||||
return True
|
||||
|
||||
try:
|
||||
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
|
||||
|
||||
# Atomically increment first — single Redis command eliminates race window
|
||||
new_count = self.redis_client.incr(profile_connections_key)
|
||||
|
||||
if new_count <= m3u_profile.max_streams:
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
|
||||
return True
|
||||
|
||||
# Over capacity — roll back the increment
|
||||
self.redis_client.decr(profile_connections_key)
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving profile slot: {e}")
|
||||
return False
|
||||
|
||||
def update_connection_activity(self, content_type: str, content_uuid: str,
|
||||
client_id: str, bytes_sent: int = 0,
|
||||
position_seconds: int = 0) -> bool:
|
||||
|
|
@ -1363,17 +1402,25 @@ class VODConnectionManager:
|
|||
client_id = session_id
|
||||
|
||||
# Remove individual connection tracking keys created during streaming
|
||||
# Check if connection key exists first - remove_connection()
|
||||
# handles the profile DECR when the key exists, but skips it
|
||||
# if the key already expired by TTL
|
||||
connection_key_exists = False
|
||||
if content_type and content_uuid:
|
||||
connection_key = self._get_connection_key(content_type, content_uuid, client_id)
|
||||
connection_key_exists = bool(self.redis_client.exists(connection_key))
|
||||
logger.info(f"[{session_id}] Cleaning up connection tracking keys")
|
||||
self.remove_connection(content_type, content_uuid, client_id)
|
||||
|
||||
# Remove from profile connections if counted (additional safety check)
|
||||
if session_data.get(b'connection_counted') == b'True' and profile_id:
|
||||
profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8')))
|
||||
current_count = int(self.redis_client.get(profile_key) or 0)
|
||||
if current_count > 0:
|
||||
self.redis_client.decr(profile_key)
|
||||
logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections")
|
||||
# Fallback DECR: only if the connection tracking key had already
|
||||
# expired (remove_connection skips DECR in that case)
|
||||
if not connection_key_exists:
|
||||
if session_data.get(b'connection_counted') == b'True' and profile_id:
|
||||
profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8')))
|
||||
current_count = int(self.redis_client.get(profile_key) or 0)
|
||||
if current_count > 0:
|
||||
self.redis_client.decr(profile_key)
|
||||
logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections (fallback)")
|
||||
|
||||
# Remove session tracking key
|
||||
self.redis_client.delete(session_key)
|
||||
|
|
|
|||
|
|
@ -416,8 +416,22 @@ class RedisBackedVODConnection:
|
|||
|
||||
logger.info(f"[{self.session_id}] Updated connection state: length={state.content_length}, type={state.content_type}")
|
||||
|
||||
# Save updated state
|
||||
self._save_connection_state(state)
|
||||
# Save updated state under lock to avoid overwriting concurrent
|
||||
# active_streams changes (e.g., another stream's GeneratorExit decrement)
|
||||
if self._acquire_lock():
|
||||
try:
|
||||
current = self._get_connection_state()
|
||||
if current:
|
||||
# Preserve the current active_streams value — it may have been
|
||||
# modified by concurrent increment/decrement operations while
|
||||
# waiting for the upstream HTTP response.
|
||||
state.active_streams = current.active_streams
|
||||
self._save_connection_state(state)
|
||||
finally:
|
||||
self._release_lock()
|
||||
else:
|
||||
# Fallback: save without lock but skip active_streams to avoid overwrite
|
||||
logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save")
|
||||
|
||||
self.local_response = response
|
||||
return response
|
||||
|
|
@ -466,35 +480,44 @@ class RedisBackedVODConnection:
|
|||
return range_header
|
||||
|
||||
def increment_active_streams(self):
|
||||
"""Increment active streams count in Redis"""
|
||||
"""Increment active streams count in Redis. Returns new active_streams count, or 0 on failure."""
|
||||
if not self._acquire_lock():
|
||||
return False
|
||||
logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock")
|
||||
return 0
|
||||
|
||||
try:
|
||||
state = self._get_connection_state()
|
||||
if state:
|
||||
old = state.active_streams
|
||||
state.active_streams += 1
|
||||
state.last_activity = time.time()
|
||||
self._save_connection_state(state)
|
||||
logger.debug(f"[{self.session_id}] Active streams incremented to {state.active_streams}")
|
||||
return True
|
||||
return False
|
||||
logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}")
|
||||
return state.active_streams
|
||||
logger.warning(f"[{self.session_id}] INCR-AS failed: no state")
|
||||
return 0
|
||||
finally:
|
||||
self._release_lock()
|
||||
|
||||
def decrement_active_streams(self):
|
||||
"""Decrement active streams count in Redis"""
|
||||
if not self._acquire_lock():
|
||||
logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock")
|
||||
return False
|
||||
|
||||
try:
|
||||
state = self._get_connection_state()
|
||||
if state and state.active_streams > 0:
|
||||
old = state.active_streams
|
||||
state.active_streams -= 1
|
||||
state.last_activity = time.time()
|
||||
self._save_connection_state(state)
|
||||
logger.debug(f"[{self.session_id}] Active streams decremented to {state.active_streams}")
|
||||
logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}")
|
||||
return True
|
||||
if not state:
|
||||
logger.warning(f"[{self.session_id}] DECR-AS failed: no state")
|
||||
else:
|
||||
logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}")
|
||||
return False
|
||||
finally:
|
||||
self._release_lock()
|
||||
|
|
@ -674,6 +697,41 @@ class MultiWorkerVODConnectionManager:
|
|||
logger.error(f"Error checking profile limits: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_reserve_profile_slot(self, m3u_profile) -> bool:
|
||||
"""
|
||||
Atomically check and reserve a connection slot for the given profile.
|
||||
|
||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
||||
condition where separate GET > check > INCR operations could allow
|
||||
concurrent requests to both pass the capacity check.
|
||||
|
||||
For profiles with max_streams=0 (unlimited), no reservation is needed.
|
||||
|
||||
Returns:
|
||||
bool: True if slot was reserved (or unlimited), False if at capacity
|
||||
"""
|
||||
if m3u_profile.max_streams == 0: # Unlimited
|
||||
return True
|
||||
|
||||
try:
|
||||
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
|
||||
|
||||
# Atomically increment first — single Redis command eliminates race window
|
||||
new_count = self.redis_client.incr(profile_connections_key)
|
||||
|
||||
if new_count <= m3u_profile.max_streams:
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
|
||||
return True
|
||||
|
||||
# Over capacity — roll back the increment
|
||||
self.redis_client.decr(profile_connections_key)
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving profile slot: {e}")
|
||||
return False
|
||||
|
||||
def _increment_profile_connections(self, m3u_profile):
|
||||
"""Increment profile connection count"""
|
||||
try:
|
||||
|
|
@ -756,10 +814,11 @@ class MultiWorkerVODConnectionManager:
|
|||
if not existing_state:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection")
|
||||
|
||||
# Check profile limits before creating new connection
|
||||
if not self._check_profile_limits(m3u_profile):
|
||||
# Atomically check and reserve a profile connection slot (INCR-first)
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded")
|
||||
return HttpResponse("Connection limit exceeded for profile", status=429)
|
||||
profile_connections_incremented = True
|
||||
|
||||
# Apply timeshift parameters
|
||||
modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset)
|
||||
|
|
@ -802,16 +861,43 @@ class MultiWorkerVODConnectionManager:
|
|||
worker_id=self.worker_id
|
||||
):
|
||||
logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection")
|
||||
# Roll back the profile slot reservation since connection failed
|
||||
self._decrement_profile_connections(m3u_profile.id)
|
||||
profile_connections_incremented = False
|
||||
return HttpResponse("Failed to create connection", status=500)
|
||||
|
||||
# Increment profile connections after successful connection creation
|
||||
self._increment_profile_connections(m3u_profile)
|
||||
profile_connections_incremented = True
|
||||
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata")
|
||||
else:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection")
|
||||
|
||||
# Immediately increment active_streams to prevent cleanup race condition.
|
||||
# Without this, stream's GeneratorExit can see active_streams=0
|
||||
# and DECR the profile counter before the new generator starts.
|
||||
if matching_session_id:
|
||||
# Idle session reuse: active_streams already incremented at line 776
|
||||
# Always need to re-reserve profile slot (GeneratorExit DECRed it)
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse")
|
||||
redis_connection.decrement_active_streams()
|
||||
return HttpResponse("Connection limit exceeded for profile", status=429)
|
||||
profile_connections_incremented = True
|
||||
else:
|
||||
# Concurrent/reconnect: increment active_streams now (not in generator)
|
||||
new_count = redis_connection.increment_active_streams()
|
||||
if new_count == 1:
|
||||
# 0→1 transition: previous stream's GeneratorExit already DECRed
|
||||
# the profile counter, need to re-reserve the slot
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect")
|
||||
redis_connection.decrement_active_streams()
|
||||
return HttpResponse("Connection limit exceeded for profile", status=429)
|
||||
profile_connections_incremented = True
|
||||
elif new_count == 0:
|
||||
logger.error(f"[{client_id}] Failed to increment active streams")
|
||||
return HttpResponse("Failed to reserve stream", status=500)
|
||||
# else: new_count > 1, another stream is already active and profile
|
||||
# counter already reflects it — no INCR needed
|
||||
|
||||
# Transfer ownership to current worker and update session activity
|
||||
if redis_connection._acquire_lock():
|
||||
try:
|
||||
|
|
@ -834,6 +920,12 @@ class MultiWorkerVODConnectionManager:
|
|||
|
||||
if upstream_response is None:
|
||||
logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable")
|
||||
if existing_state:
|
||||
# Roll back the active_streams increment from the else branch
|
||||
redis_connection.decrement_active_streams()
|
||||
if profile_connections_incremented:
|
||||
self._decrement_profile_connections(m3u_profile.id)
|
||||
profile_connections_incremented = False
|
||||
return HttpResponse("Requested Range Not Satisfiable", status=416)
|
||||
|
||||
# Get connection headers
|
||||
|
|
@ -846,13 +938,14 @@ class MultiWorkerVODConnectionManager:
|
|||
try:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream")
|
||||
|
||||
# Increment active streams (unless we already did it for session reuse)
|
||||
if not matching_session_id:
|
||||
# New session - increment active streams
|
||||
# Increment active streams only for brand-new connections.
|
||||
# For existing connections (session reuse or concurrent requests),
|
||||
# active_streams was already incremented in the else branch above
|
||||
# to prevent cleanup race conditions with GeneratorExit.
|
||||
if not existing_state:
|
||||
redis_connection.increment_active_streams()
|
||||
else:
|
||||
# Reused session - we already incremented when reserving the session
|
||||
logger.debug(f"[{client_id}] Using pre-reserved session - active streams already incremented")
|
||||
logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path")
|
||||
|
||||
bytes_sent = 0
|
||||
chunk_count = 0
|
||||
|
|
@ -898,11 +991,19 @@ class MultiWorkerVODConnectionManager:
|
|||
|
||||
# Schedule smart cleanup if no active streams after normal completion
|
||||
if not redis_connection.has_active_streams():
|
||||
# Decrement profile counter immediately — don't defer to daemon thread
|
||||
state = redis_connection._get_connection_state()
|
||||
profile_id = state.m3u_profile_id if state else m3u_profile.id
|
||||
if profile_id:
|
||||
self._decrement_profile_connections(profile_id)
|
||||
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on normal completion")
|
||||
|
||||
def delayed_cleanup():
|
||||
time.sleep(1) # Wait 1 second
|
||||
# Smart cleanup: check active streams and ownership
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion")
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
# No connection_manager — profile already decremented above
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
|
||||
import threading
|
||||
cleanup_thread = threading.Thread(target=delayed_cleanup)
|
||||
|
|
@ -917,11 +1018,19 @@ class MultiWorkerVODConnectionManager:
|
|||
|
||||
# Schedule smart cleanup if no active streams
|
||||
if not redis_connection.has_active_streams():
|
||||
# Decrement profile counter immediately — don't defer to daemon thread
|
||||
state = redis_connection._get_connection_state()
|
||||
profile_id = state.m3u_profile_id if state else m3u_profile.id
|
||||
if profile_id:
|
||||
self._decrement_profile_connections(profile_id)
|
||||
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on client disconnect")
|
||||
|
||||
def delayed_cleanup():
|
||||
time.sleep(1) # Wait 1 second
|
||||
# Smart cleanup: check active streams and ownership
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect")
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
# No connection_manager — profile already decremented above
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
|
||||
import threading
|
||||
cleanup_thread = threading.Thread(target=delayed_cleanup)
|
||||
|
|
@ -933,8 +1042,17 @@ class MultiWorkerVODConnectionManager:
|
|||
if not decremented:
|
||||
redis_connection.decrement_active_streams()
|
||||
decremented = True
|
||||
# Smart cleanup on error - immediate cleanup since we're in error state
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
|
||||
# Decrement profile counter immediately if no other active streams
|
||||
if not redis_connection.has_active_streams():
|
||||
state = redis_connection._get_connection_state()
|
||||
profile_id = state.m3u_profile_id if state else m3u_profile.id
|
||||
if profile_id:
|
||||
self._decrement_profile_connections(profile_id)
|
||||
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on stream error")
|
||||
# Smart cleanup on error - immediate cleanup since we're in error state
|
||||
# No connection_manager — profile already decremented above
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
yield b"Error: Stream interrupted"
|
||||
|
||||
finally:
|
||||
|
|
@ -1036,9 +1154,10 @@ class MultiWorkerVODConnectionManager:
|
|||
self._decrement_profile_connections(m3u_profile.id)
|
||||
|
||||
# Also clean up the Redis connection state since we won't be using it
|
||||
# Pass connection_manager=None since we already decremented above
|
||||
if redis_connection:
|
||||
try:
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"[{client_id}] Error during cleanup after connection failure: {cleanup_error}")
|
||||
|
||||
|
|
@ -1262,12 +1381,14 @@ class MultiWorkerVODConnectionManager:
|
|||
profile_id = connection_data.get('m3u_profile_id')
|
||||
if profile_id:
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
current_count = int(self.redis_client.get(profile_connections_key) or 0)
|
||||
|
||||
# Use pipeline for atomic operations
|
||||
pipe = self.redis_client.pipeline()
|
||||
pipe.delete(connection_key)
|
||||
pipe.srem(content_connections_key, client_id)
|
||||
pipe.decr(profile_connections_key)
|
||||
if current_count > 0:
|
||||
pipe.decr(profile_connections_key)
|
||||
pipe.execute()
|
||||
|
||||
logger.info(f"Removed Redis-backed connection: {client_id}")
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class M3USeriesRelationAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(M3UEpisodeRelation)
|
||||
class M3UEpisodeRelationAdmin(admin.ModelAdmin):
|
||||
list_display = ['episode', 'm3u_account', 'stream_id', 'created_at']
|
||||
list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at']
|
||||
list_filter = ['m3u_account', 'created_at']
|
||||
search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.2.11 on 2026-02-24 23:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='m3uepisoderelation',
|
||||
name='series_relation',
|
||||
field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'),
|
||||
),
|
||||
]
|
||||
|
|
@ -261,6 +261,14 @@ class M3UEpisodeRelation(models.Model):
|
|||
"""Links M3U accounts to Episodes with provider-specific information"""
|
||||
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations')
|
||||
episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations')
|
||||
series_relation = models.ForeignKey(
|
||||
'M3USeriesRelation',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='episode_relations',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed."
|
||||
)
|
||||
|
||||
# Streaming information (provider-specific)
|
||||
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
||||
|
|
|
|||
|
|
@ -1258,20 +1258,15 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N
|
|||
else:
|
||||
episodes_data = {}
|
||||
|
||||
# Clear existing episodes for this account to handle deletions
|
||||
Episode.objects.filter(
|
||||
series=series,
|
||||
m3u_relations__m3u_account=account
|
||||
).delete()
|
||||
# Fetch the series relation once — used both to pass into batch_process_episodes
|
||||
# (so episode relations get the FK set) and to update metadata afterwards.
|
||||
series_relation = M3USeriesRelation.objects.filter(
|
||||
m3u_account=account,
|
||||
external_series_id=external_series_id
|
||||
).first()
|
||||
|
||||
# Process all episodes in batch
|
||||
batch_process_episodes(account, series, episodes_data)
|
||||
|
||||
# Update the series relation to mark episodes as fetched
|
||||
series_relation = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
m3u_account=account
|
||||
).first()
|
||||
batch_process_episodes(account, series, episodes_data, series_relation=series_relation)
|
||||
|
||||
if series_relation:
|
||||
custom_props = series_relation.custom_properties or {}
|
||||
|
|
@ -1285,13 +1280,18 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N
|
|||
logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}")
|
||||
|
||||
|
||||
def batch_process_episodes(account, series, episodes_data, scan_start_time=None):
|
||||
def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None):
|
||||
"""Process episodes in batches for better performance.
|
||||
|
||||
Note: Multiple streams can represent the same episode (e.g., different languages
|
||||
or qualities). Each stream has a unique stream_id, but they share the same
|
||||
season/episode number. We create one Episode record per (series, season, episode)
|
||||
and multiple M3UEpisodeRelation records pointing to it.
|
||||
|
||||
series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so
|
||||
that CASCADE correctly removes episode relations when their parent series relation
|
||||
is deleted, and so that stale-stream cleanup is scoped precisely to relations that
|
||||
came from this specific provider query.
|
||||
"""
|
||||
if not episodes_data:
|
||||
return
|
||||
|
|
@ -1445,10 +1445,11 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
|
|||
# Update existing relation
|
||||
relation = existing_relations[episode_id]
|
||||
relation.episode = episode
|
||||
relation.series_relation = series_relation
|
||||
relation.container_extension = episode_data.get('container_extension', 'mp4')
|
||||
relation.custom_properties = {
|
||||
'info': episode_data,
|
||||
'season_number': season_number
|
||||
'season_number': season_number,
|
||||
}
|
||||
relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan
|
||||
relations_to_update.append(relation)
|
||||
|
|
@ -1457,11 +1458,12 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
|
|||
relation = M3UEpisodeRelation(
|
||||
m3u_account=account,
|
||||
episode=episode,
|
||||
series_relation=series_relation,
|
||||
stream_id=episode_id,
|
||||
container_extension=episode_data.get('container_extension', 'mp4'),
|
||||
custom_properties={
|
||||
'info': episode_data,
|
||||
'season_number': season_number
|
||||
'season_number': season_number,
|
||||
},
|
||||
last_seen=scan_start_time or timezone.now() # Mark as seen during this scan
|
||||
)
|
||||
|
|
@ -1524,9 +1526,28 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
|
|||
# Update existing episode relations
|
||||
if relations_to_update:
|
||||
M3UEpisodeRelation.objects.bulk_update(relations_to_update, [
|
||||
'episode', 'container_extension', 'custom_properties', 'last_seen'
|
||||
'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen'
|
||||
])
|
||||
|
||||
# Delete relations for streams no longer returned by the provider.
|
||||
# Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows
|
||||
# for the same account+series (pre-migration rows whose stream is now gone — the
|
||||
# update path only backfills the FK for streams still present in the response).
|
||||
# Falls back to account+series scope when series_relation is None (shouldn't occur).
|
||||
if series_relation is not None:
|
||||
stale_qs = M3UEpisodeRelation.objects.filter(
|
||||
Q(series_relation=series_relation) |
|
||||
Q(series_relation__isnull=True, m3u_account=account, episode__series=series)
|
||||
)
|
||||
else:
|
||||
stale_qs = M3UEpisodeRelation.objects.filter(
|
||||
m3u_account=account,
|
||||
episode__series=series
|
||||
)
|
||||
removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0]
|
||||
if removed_count:
|
||||
logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}")
|
||||
|
||||
logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, "
|
||||
f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations")
|
||||
|
||||
|
|
@ -1611,16 +1632,12 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=
|
|||
stale_movie_count = stale_movie_relations.count()
|
||||
stale_movie_relations.delete()
|
||||
|
||||
# Clean up stale series relations
|
||||
# Clean up stale series relations.
|
||||
# Episode relations are removed via CASCADE on the series_relation FK.
|
||||
stale_series_relations = M3USeriesRelation.objects.filter(**base_filters)
|
||||
stale_series_count = stale_series_relations.count()
|
||||
stale_series_relations.delete()
|
||||
|
||||
# Clean up stale episode relations
|
||||
stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters)
|
||||
stale_episode_count = stale_episode_relations.count()
|
||||
stale_episode_relations.delete()
|
||||
|
||||
# Clean up movies with no relations (orphaned)
|
||||
# Safe to delete even during account-specific cleanup because if ANY account
|
||||
# has a relation, m3u_relations will not be null
|
||||
|
|
@ -1628,20 +1645,33 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=
|
|||
orphaned_movie_count = orphaned_movies.count()
|
||||
if orphaned_movie_count > 0:
|
||||
logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations")
|
||||
orphaned_movies.delete()
|
||||
try:
|
||||
orphaned_movies.delete()
|
||||
except IntegrityError:
|
||||
# A concurrent refresh task created a new relation for one of these movies
|
||||
# between our query and the DELETE. Skip and let the next cleanup run handle it.
|
||||
logger.warning(
|
||||
"Skipped some orphaned movie deletions due to concurrent modifications; "
|
||||
"they will be retried on the next cleanup run."
|
||||
)
|
||||
orphaned_movie_count = 0
|
||||
|
||||
# Clean up series with no relations (orphaned)
|
||||
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
|
||||
orphaned_series_count = orphaned_series.count()
|
||||
if orphaned_series_count > 0:
|
||||
logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations")
|
||||
orphaned_series.delete()
|
||||
|
||||
# Episodes will be cleaned up via CASCADE when series are deleted
|
||||
try:
|
||||
orphaned_series.delete()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
"Skipped some orphaned series deletions due to concurrent modifications; "
|
||||
"they will be retried on the next cleanup run."
|
||||
)
|
||||
orphaned_series_count = 0
|
||||
|
||||
result = (f"Cleaned up {stale_movie_count} stale movie relations, "
|
||||
f"{stale_series_count} stale series relations, "
|
||||
f"{stale_episode_count} stale episode relations, "
|
||||
f"{orphaned_movie_count} orphaned movies, and "
|
||||
f"{orphaned_series_count} orphaned series")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue