Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# core/admin.py
|
||||
|
||||
from django.contrib import admin
|
||||
from .models import UserAgent, StreamProfile, CoreSettings
|
||||
|
||||
@admin.register(UserAgent)
|
||||
class UserAgentAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"user_agent",
|
||||
"description",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name", "user_agent", "description")
|
||||
list_filter = ("is_active",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@admin.register(StreamProfile)
|
||||
class StreamProfileAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"command",
|
||||
"is_active",
|
||||
"user_agent",
|
||||
)
|
||||
search_fields = ("name", "command", "user_agent")
|
||||
list_filter = ("is_active",)
|
||||
|
||||
@admin.register(CoreSettings)
|
||||
class CoreSettingsAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Because CoreSettings is typically a single 'singleton' row,
|
||||
you can either allow multiple or restrict it. For now, we
|
||||
just list and allow editing of any instance.
|
||||
"""
|
||||
list_display = (
|
||||
"key",
|
||||
"value",
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# core/api_urls.py
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import (
|
||||
UserAgentViewSet,
|
||||
StreamProfileViewSet,
|
||||
CoreSettingsViewSet,
|
||||
SystemNotificationViewSet,
|
||||
environment,
|
||||
version,
|
||||
rehash_streams_endpoint,
|
||||
TimezoneListView,
|
||||
get_system_events
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'useragents', UserAgentViewSet, basename='useragent')
|
||||
router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile')
|
||||
router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
|
||||
router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification')
|
||||
urlpatterns = [
|
||||
path('settings/env/', environment, name='token_refresh'),
|
||||
path('version/', version, name='version'),
|
||||
path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'),
|
||||
path('timezones/', TimezoneListView.as_view(), name='timezones'),
|
||||
path('system-events/', get_system_events, name='system_events'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,665 @@
|
||||
# core/api_views.py
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from django.conf import settings as django_settings
|
||||
from django.db import models
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from .models import (
|
||||
UserAgent,
|
||||
StreamProfile,
|
||||
CoreSettings,
|
||||
STREAM_SETTINGS_KEY,
|
||||
DVR_SETTINGS_KEY,
|
||||
NETWORK_ACCESS_KEY,
|
||||
PROXY_SETTINGS_KEY,
|
||||
)
|
||||
from .serializers import (
|
||||
UserAgentSerializer,
|
||||
StreamProfileSerializer,
|
||||
CoreSettingsSerializer,
|
||||
ProxySettingsSerializer,
|
||||
)
|
||||
|
||||
import socket
|
||||
import requests
|
||||
import os
|
||||
from core.tasks import rehash_streams
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
IsAdmin,
|
||||
IsStandardUser,
|
||||
permission_classes_by_action,
|
||||
)
|
||||
from dispatcharr.utils import get_client_ip
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserAgentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows user agents to be viewed, created, edited, or deleted.
|
||||
"""
|
||||
|
||||
queryset = UserAgent.objects.all()
|
||||
serializer_class = UserAgentSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
|
||||
class StreamProfileViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows stream profiles to be viewed, created, edited, or deleted.
|
||||
"""
|
||||
|
||||
queryset = StreamProfile.objects.all()
|
||||
serializer_class = StreamProfileSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
|
||||
class CoreSettingsViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for editing core settings.
|
||||
This is treated as a singleton: only one instance should exist.
|
||||
"""
|
||||
|
||||
queryset = CoreSettings.objects.all()
|
||||
serializer_class = CoreSettingsSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
old_value = instance.value
|
||||
response = super().update(request, *args, **kwargs)
|
||||
|
||||
# If stream settings changed and m3u_hash_key is different, rehash streams
|
||||
if instance.key == STREAM_SETTINGS_KEY:
|
||||
new_value = request.data.get("value", {})
|
||||
if isinstance(new_value, dict) and isinstance(old_value, dict):
|
||||
old_hash = old_value.get("m3u_hash_key", "")
|
||||
new_hash = new_value.get("m3u_hash_key", "")
|
||||
if old_hash != new_hash:
|
||||
hash_keys = new_hash.split(",") if isinstance(new_hash, str) else new_hash
|
||||
rehash_streams.delay(hash_keys)
|
||||
|
||||
# If DVR settings changed and pre/post offsets are different, reschedule upcoming recordings
|
||||
if instance.key == DVR_SETTINGS_KEY:
|
||||
new_value = request.data.get("value", {})
|
||||
if isinstance(new_value, dict) and isinstance(old_value, dict):
|
||||
old_pre = old_value.get("pre_offset_minutes")
|
||||
new_pre = new_value.get("pre_offset_minutes")
|
||||
old_post = old_value.get("post_offset_minutes")
|
||||
new_post = new_value.get("post_offset_minutes")
|
||||
if old_pre != new_pre or old_post != new_post:
|
||||
try:
|
||||
# Prefer async task if Celery is available
|
||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
|
||||
reschedule_upcoming_recordings_for_offset_change.delay()
|
||||
except Exception:
|
||||
# Fallback to synchronous implementation
|
||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
|
||||
reschedule_upcoming_recordings_for_offset_change_impl()
|
||||
|
||||
return response
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
response = super().create(request, *args, **kwargs)
|
||||
# If creating DVR settings with offset values, reschedule upcoming recordings
|
||||
try:
|
||||
key = request.data.get("key")
|
||||
if key == DVR_SETTINGS_KEY:
|
||||
value = request.data.get("value", {})
|
||||
if isinstance(value, dict) and ("pre_offset_minutes" in value or "post_offset_minutes" in value):
|
||||
try:
|
||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
|
||||
reschedule_upcoming_recordings_for_offset_change.delay()
|
||||
except Exception:
|
||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
|
||||
reschedule_upcoming_recordings_for_offset_change_impl()
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
@action(detail=False, methods=["post"], url_path="check")
|
||||
def check(self, request, *args, **kwargs):
|
||||
data = request.data
|
||||
|
||||
if data.get("key") == NETWORK_ACCESS_KEY:
|
||||
client_ip = ipaddress.ip_address(get_client_ip(request))
|
||||
|
||||
in_network = {}
|
||||
invalid = []
|
||||
|
||||
value = data.get("value", {})
|
||||
for key, val in value.items():
|
||||
in_network[key] = []
|
||||
cidrs = val.split(",")
|
||||
for cidr in cidrs:
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr)
|
||||
|
||||
if client_ip in network:
|
||||
in_network[key] = []
|
||||
break
|
||||
|
||||
in_network[key].append(cidr)
|
||||
except:
|
||||
invalid.append(cidr)
|
||||
|
||||
if len(invalid) > 0:
|
||||
return Response(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Invalid CIDR(s)",
|
||||
"data": invalid,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
response_data = {
|
||||
**in_network,
|
||||
"client_ip": str(client_ip)
|
||||
}
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
return Response({}, status=status.HTTP_200_OK)
|
||||
|
||||
class ProxySettingsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
API endpoint for proxy settings stored as JSON in CoreSettings.
|
||||
"""
|
||||
serializer_class = ProxySettingsSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ('list', 'retrieve'):
|
||||
return [IsStandardUser()]
|
||||
return [IsAdmin()]
|
||||
|
||||
def _get_or_create_settings(self):
|
||||
"""Get or create the proxy settings CoreSettings entry"""
|
||||
try:
|
||||
settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY)
|
||||
settings_data = settings_obj.value
|
||||
except CoreSettings.DoesNotExist:
|
||||
# Create default settings
|
||||
settings_data = {
|
||||
"buffering_timeout": 15,
|
||||
"buffering_speed": 1.0,
|
||||
"redis_chunk_ttl": 60,
|
||||
"channel_shutdown_delay": 0,
|
||||
"channel_init_grace_period": 5,
|
||||
"new_client_behind_seconds": 5,
|
||||
}
|
||||
settings_obj, created = CoreSettings.objects.get_or_create(
|
||||
key=PROXY_SETTINGS_KEY,
|
||||
defaults={
|
||||
"name": "Proxy Settings",
|
||||
"value": settings_data
|
||||
}
|
||||
)
|
||||
return settings_obj, settings_data
|
||||
|
||||
def list(self, request):
|
||||
"""Return proxy settings"""
|
||||
settings_obj, settings_data = self._get_or_create_settings()
|
||||
return Response(settings_data)
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Return proxy settings regardless of ID"""
|
||||
settings_obj, settings_data = self._get_or_create_settings()
|
||||
return Response(settings_data)
|
||||
|
||||
def update(self, request, pk=None):
|
||||
"""Update proxy settings"""
|
||||
settings_obj, current_data = self._get_or_create_settings()
|
||||
|
||||
serializer = ProxySettingsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Update the JSON data - store as dict directly
|
||||
settings_obj.value = serializer.validated_data
|
||||
settings_obj.save()
|
||||
|
||||
return Response(serializer.validated_data)
|
||||
|
||||
def partial_update(self, request, pk=None):
|
||||
"""Partially update proxy settings"""
|
||||
settings_obj, current_data = self._get_or_create_settings()
|
||||
|
||||
# Merge current data with new data
|
||||
updated_data = {**current_data, **request.data}
|
||||
|
||||
serializer = ProxySettingsSerializer(data=updated_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Update the JSON data - store as dict directly
|
||||
settings_obj.value = serializer.validated_data
|
||||
settings_obj.save()
|
||||
|
||||
return Response(serializer.validated_data)
|
||||
|
||||
@action(detail=False, methods=['get', 'patch'])
|
||||
def settings(self, request):
|
||||
"""Get or update the proxy settings."""
|
||||
if request.method == 'GET':
|
||||
return self.list(request)
|
||||
elif request.method == 'PATCH':
|
||||
return self.partial_update(request)
|
||||
|
||||
|
||||
|
||||
@extend_schema(
|
||||
description="Endpoint for environment details",
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([Authenticated])
|
||||
def environment(request):
|
||||
public_ip = None
|
||||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
# 1) Get the public IP from ipify.org API
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException as e:
|
||||
public_ip = f"Error: {e}"
|
||||
|
||||
# 2) Get the local IP by connecting to a public DNS server
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# connect to a "public" address so the OS can determine our local interface
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception as e:
|
||||
local_ip = f"Error: {e}"
|
||||
|
||||
# 3) Get geolocation data from ipapi.co or ip-api.com
|
||||
if public_ip and "Error" not in public_ip:
|
||||
try:
|
||||
# Attempt to get geo information from ipapi.co first
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code") # e.g. "US"
|
||||
country_name = geo.get("country_name") # e.g. "United States"
|
||||
|
||||
else:
|
||||
# If ipapi.co fails, fallback to ip-api.com
|
||||
# only supports http requests for free tier
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode") # e.g. "US"
|
||||
country_name = geo.get("country") # e.g. "United States"
|
||||
|
||||
else:
|
||||
raise Exception("Geo lookup failed with both services")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
# 4) Get environment mode and TLS status from settings
|
||||
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"authenticated": True,
|
||||
"public_ip": public_ip,
|
||||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
|
||||
"redis_tls": {
|
||||
"enabled": getattr(django_settings, "REDIS_SSL", False),
|
||||
"verify": getattr(django_settings, "REDIS_SSL_VERIFY", True),
|
||||
"mtls": bool(getattr(django_settings, "REDIS_SSL_CERT", "") and getattr(django_settings, "REDIS_SSL_KEY", "")),
|
||||
},
|
||||
"postgres_tls": {
|
||||
"enabled": postgres_ssl,
|
||||
"ssl_mode": getattr(django_settings, "POSTGRES_SSL_MODE", "verify-full") if postgres_ssl else None,
|
||||
"mtls": bool(getattr(django_settings, "POSTGRES_SSL_CERT", "") and getattr(django_settings, "POSTGRES_SSL_KEY", "")),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
description="Get application version information",
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def version(request):
|
||||
# Import version information
|
||||
from version import __version__, __timestamp__
|
||||
|
||||
return Response(
|
||||
{
|
||||
"version": __version__,
|
||||
"timestamp": __timestamp__,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
description="Trigger rehashing of all streams",
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([Authenticated])
|
||||
def rehash_streams_endpoint(request):
|
||||
"""Trigger the rehash streams task"""
|
||||
try:
|
||||
# Get the current hash keys from settings
|
||||
hash_key = CoreSettings.get_m3u_hash_key()
|
||||
hash_keys = hash_key.split(",") if isinstance(hash_key, str) else hash_key
|
||||
|
||||
# Queue the rehash task
|
||||
task = rehash_streams.delay(hash_keys)
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"message": "Stream rehashing task has been queued",
|
||||
"task_id": task.id
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({
|
||||
"success": False,
|
||||
"message": f"Error triggering rehash: {str(e)}"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering rehash streams: {e}")
|
||||
return Response({
|
||||
"success": False,
|
||||
"message": "Failed to trigger rehash task"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Timezone List API
|
||||
# ─────────────────────────────
|
||||
class TimezoneListView(APIView):
|
||||
"""
|
||||
API endpoint that returns all available timezones supported by pytz.
|
||||
Returns a list of timezone names grouped by region for easy selection.
|
||||
This is a general utility endpoint that can be used throughout the application.
|
||||
"""
|
||||
|
||||
def get_permissions(self):
|
||||
return [Authenticated()]
|
||||
|
||||
@extend_schema(
|
||||
description="Get list of all supported timezones",
|
||||
)
|
||||
def get(self, request):
|
||||
import pytz
|
||||
|
||||
# Get all common timezones (excludes deprecated ones)
|
||||
all_timezones = sorted(pytz.common_timezones)
|
||||
|
||||
# Group by region for better UX
|
||||
grouped = {}
|
||||
for tz in all_timezones:
|
||||
if '/' in tz:
|
||||
region = tz.split('/')[0]
|
||||
if region not in grouped:
|
||||
grouped[region] = []
|
||||
grouped[region].append(tz)
|
||||
else:
|
||||
# Handle special zones like UTC, GMT, etc.
|
||||
if 'Other' not in grouped:
|
||||
grouped['Other'] = []
|
||||
grouped['Other'].append(tz)
|
||||
|
||||
return Response({
|
||||
'timezones': all_timezones,
|
||||
'grouped': grouped,
|
||||
'count': len(all_timezones)
|
||||
})
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# System Events API
|
||||
# ─────────────────────────────
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_system_events(request):
|
||||
"""
|
||||
Get recent system events (channel start/stop, buffering, client connections, etc.)
|
||||
|
||||
Query Parameters:
|
||||
limit: Number of events to return per page (default: 100, max: 1000)
|
||||
offset: Number of events to skip (for pagination, default: 0)
|
||||
event_type: Filter by specific event type (optional)
|
||||
"""
|
||||
from core.models import SystemEvent
|
||||
|
||||
try:
|
||||
# Get pagination params
|
||||
limit = min(int(request.GET.get('limit', 100)), 1000)
|
||||
offset = int(request.GET.get('offset', 0))
|
||||
|
||||
# Start with all events
|
||||
events = SystemEvent.objects.all()
|
||||
|
||||
# Filter by event_type if provided
|
||||
event_type = request.GET.get('event_type')
|
||||
if event_type:
|
||||
events = events.filter(event_type=event_type)
|
||||
|
||||
# Get total count before applying pagination
|
||||
total_count = events.count()
|
||||
|
||||
# Apply offset and limit for pagination
|
||||
events = events[offset:offset + limit]
|
||||
|
||||
# Serialize the data
|
||||
events_data = [{
|
||||
'id': event.id,
|
||||
'event_type': event.event_type,
|
||||
'event_type_display': event.get_event_type_display(),
|
||||
'timestamp': event.timestamp.isoformat(),
|
||||
'channel_id': str(event.channel_id) if event.channel_id else None,
|
||||
'channel_name': event.channel_name,
|
||||
'details': event.details
|
||||
} for event in events]
|
||||
|
||||
return Response({
|
||||
'events': events_data,
|
||||
'count': len(events_data),
|
||||
'total': total_count,
|
||||
'offset': offset,
|
||||
'limit': limit
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching system events: {e}")
|
||||
return Response({
|
||||
'error': 'Failed to fetch system events'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# System Notifications API
|
||||
# ─────────────────────────────
|
||||
from .models import SystemNotification, NotificationDismissal
|
||||
from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer
|
||||
from django.utils import timezone as dj_timezone
|
||||
|
||||
|
||||
class SystemNotificationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for system notifications.
|
||||
Users can view active notifications and dismiss them.
|
||||
Admins can create and manage notifications.
|
||||
"""
|
||||
serializer_class = SystemNotificationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return notifications based on user permissions.
|
||||
Filter out expired and dismissed notifications for regular users.
|
||||
Evaluate conditions for developer notifications.
|
||||
"""
|
||||
from core.developer_notifications import evaluate_conditions
|
||||
from django.core.cache import cache
|
||||
|
||||
user = self.request.user
|
||||
now = dj_timezone.now()
|
||||
|
||||
queryset = SystemNotification.objects.filter(is_active=True)
|
||||
|
||||
# Filter out expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
|
||||
)
|
||||
|
||||
# Filter admin-only notifications for non-admins
|
||||
if getattr(user, 'user_level', 0) < 10:
|
||||
queryset = queryset.filter(admin_only=False)
|
||||
|
||||
# For developer notifications, evaluate conditions
|
||||
# Cache the evaluation per notification to avoid repeated condition checks
|
||||
notifications_to_exclude = []
|
||||
developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER)
|
||||
|
||||
for notification in developer_notifications:
|
||||
action_data = notification.action_data or {}
|
||||
conditions = action_data.get('condition', [])
|
||||
|
||||
if not conditions:
|
||||
continue
|
||||
|
||||
# Cache key based on notification ID and current settings
|
||||
# Cache for 5 minutes to balance freshness with performance
|
||||
cache_key = f'dev_notif_condition_{notification.id}_{user.id}'
|
||||
should_show = cache.get(cache_key)
|
||||
|
||||
if should_show is None:
|
||||
should_show = evaluate_conditions(conditions, user)
|
||||
cache.set(cache_key, should_show, timeout=300) # 5 minutes
|
||||
|
||||
if not should_show:
|
||||
notifications_to_exclude.append(notification.id)
|
||||
|
||||
if notifications_to_exclude:
|
||||
queryset = queryset.exclude(id__in=notifications_to_exclude)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
return context
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
List all active notifications for the current user.
|
||||
Optionally filter by dismissed status.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Optional: filter out already dismissed notifications
|
||||
include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true'
|
||||
if not include_dismissed:
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
queryset = queryset.exclude(id__in=dismissed_ids)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response({
|
||||
'notifications': serializer.data,
|
||||
'count': len(serializer.data),
|
||||
'unread_count': queryset.count()
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='dismiss')
|
||||
def dismiss(self, request, pk=None):
|
||||
"""Dismiss a notification for the current user."""
|
||||
notification = self.get_object()
|
||||
action_taken = request.data.get('action_taken', None)
|
||||
|
||||
dismissal, created = NotificationDismissal.objects.get_or_create(
|
||||
user=request.user,
|
||||
notification=notification,
|
||||
defaults={'action_taken': action_taken}
|
||||
)
|
||||
|
||||
if not created and action_taken:
|
||||
dismissal.action_taken = action_taken
|
||||
dismissal.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Notification dismissed',
|
||||
'notification_key': notification.notification_key
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='dismiss-all')
|
||||
def dismiss_all(self, request):
|
||||
"""Dismiss all notifications for the current user."""
|
||||
notifications = self.get_queryset()
|
||||
|
||||
# Get notifications not yet dismissed
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
to_dismiss = notifications.exclude(id__in=dismissed_ids)
|
||||
|
||||
# Create dismissals for all
|
||||
dismissals = [
|
||||
NotificationDismissal(user=request.user, notification=n)
|
||||
for n in to_dismiss
|
||||
]
|
||||
NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'dismissed_count': len(dismissals)
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='count')
|
||||
def unread_count(self, request):
|
||||
"""Get count of unread notifications."""
|
||||
queryset = self.get_queryset()
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
unread_count = queryset.exclude(id__in=dismissed_ids).count()
|
||||
|
||||
return Response({
|
||||
'unread_count': unread_count
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
# Define TRACE level (5 is below DEBUG which is 10)
|
||||
TRACE = 5
|
||||
logging.addLevelName(TRACE, "TRACE")
|
||||
|
||||
# Add trace method to the Logger class
|
||||
def trace(self, message, *args, **kwargs):
|
||||
"""Log a message with TRACE level (more detailed than DEBUG)"""
|
||||
if self.isEnabledFor(TRACE):
|
||||
self._log(TRACE, message, args, **kwargs)
|
||||
|
||||
# Add the trace method to the Logger class
|
||||
logging.Logger.trace = trace
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
|
||||
def ready(self):
|
||||
# Import signals to ensure they get registered
|
||||
import core.signals
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
|
||||
# Sync developer notifications and check for version updates on startup
|
||||
# Only run in the main process (not in management commands, migrations, or workers)
|
||||
if should_skip_initialization():
|
||||
return
|
||||
|
||||
self._sync_developer_notifications()
|
||||
|
||||
def _sync_developer_notifications(self):
|
||||
"""Sync developer notifications from JSON file to database."""
|
||||
from django.db import connection
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
try:
|
||||
from core.developer_notifications import sync_developer_notifications
|
||||
sync_developer_notifications()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync developer notifications on startup: {e}")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
def is_management_command(excluded_commands=None):
|
||||
"""
|
||||
Detect if we're running a Django management command like migrate, collectstatic, etc.
|
||||
|
||||
Args:
|
||||
excluded_commands: List of commands that should still use Redis (e.g. runserver)
|
||||
|
||||
Returns:
|
||||
bool: True if we're running a management command
|
||||
"""
|
||||
# First check if we're in build mode
|
||||
if os.environ.get("DISPATCHARR_BUILD") == "1":
|
||||
return True
|
||||
|
||||
if excluded_commands is None:
|
||||
excluded_commands = ['runserver', 'runworker', 'daphne']
|
||||
|
||||
# Check if we're running via manage.py
|
||||
if not ('manage.py' in sys.argv[0]):
|
||||
return False
|
||||
|
||||
# Check if we have a command argument
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1]
|
||||
# Return False if command is in excluded list - these commands DO need Redis
|
||||
if command in excluded_commands:
|
||||
return False
|
||||
# Otherwise it's a command that should work without Redis
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Developer Notification Sync Service
|
||||
|
||||
Handles syncing developer-defined notifications from the JSON file to the database.
|
||||
This ensures users receive important notifications from the development team
|
||||
about recommended settings, security updates, and other announcements.
|
||||
|
||||
JSON Schema (see fixtures/developer_notifications.json):
|
||||
{
|
||||
"id": str, # REQUIRED - Unique identifier (notification_key)
|
||||
"title": str, # REQUIRED - Notification heading
|
||||
"message": str, # REQUIRED - Notification body text
|
||||
|
||||
"notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info')
|
||||
"priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal')
|
||||
"min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null)
|
||||
"max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null)
|
||||
"created_at": str, # OPTIONAL - ISO timestamp for tracking
|
||||
"expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null)
|
||||
"condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: [])
|
||||
"user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all')
|
||||
"action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access")
|
||||
"action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set)
|
||||
}
|
||||
|
||||
Condition Checks:
|
||||
Conditions are function names from CONDITION_CHECKS registry that evaluate
|
||||
whether a notification should be shown. All conditions must pass (AND logic).
|
||||
|
||||
Available conditions:
|
||||
- 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere
|
||||
|
||||
To add new conditions:
|
||||
1. Define a function: check_your_condition(user) -> bool
|
||||
2. Add to CONDITION_CHECKS registry
|
||||
3. Reference in JSON: "condition": ["your_condition"]
|
||||
|
||||
Sync Behavior:
|
||||
- Runs on startup (see apps.py)
|
||||
- Runs when relevant settings change (see signals.py)
|
||||
- Adds new notifications if in version range and not expired
|
||||
- Updates existing notifications with latest data
|
||||
- Removes notifications that are:
|
||||
* No longer in JSON file
|
||||
* Out of current version range
|
||||
* Past expiration date
|
||||
- Sends websocket event to refresh frontend
|
||||
- Cache invalidated when triggering settings change
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
from version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to developer notifications JSON file
|
||||
NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json'
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Condition Checks
|
||||
# ─────────────────────────────
|
||||
# Each condition function receives (user) and returns True if the notification should show
|
||||
|
||||
def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool:
|
||||
"""
|
||||
Check if network access settings for a specific endpoint are insecure (allow all).
|
||||
|
||||
Args:
|
||||
user: The user object (unused but required for condition check signature)
|
||||
endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API')
|
||||
|
||||
Returns:
|
||||
True if the notification should show (insecure settings detected)
|
||||
"""
|
||||
from core.models import CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
try:
|
||||
network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {})
|
||||
|
||||
# Empty settings are secure (defaults to local network only)
|
||||
if not network_settings:
|
||||
return False
|
||||
|
||||
# Get the specific endpoint's allowed networks (stored as comma-separated string)
|
||||
allowed_networks_str = network_settings.get(endpoint, '')
|
||||
if not allowed_networks_str:
|
||||
return False
|
||||
|
||||
# Parse comma-separated network addresses
|
||||
allowed_networks = [net.strip() for net in allowed_networks_str.split(',')]
|
||||
|
||||
# Check if settings allow access from anywhere (insecure)
|
||||
if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks:
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Registry of all available condition checks
|
||||
CONDITION_CHECKS: dict[str, Callable] = {
|
||||
'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'),
|
||||
# Add more conditions here as needed
|
||||
# 'transcode_not_configured': check_transcode_not_configured,
|
||||
# 'no_backup_configured': check_no_backup_configured,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Version Utilities
|
||||
# ─────────────────────────────
|
||||
|
||||
def parse_version(version_str: str | None) -> version.Version | None:
|
||||
"""Parse a version string, returning None if invalid or empty."""
|
||||
if not version_str:
|
||||
return None
|
||||
try:
|
||||
return version.parse(version_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_version_in_range(
|
||||
current_version: str,
|
||||
min_version: str | None,
|
||||
max_version: str | None
|
||||
) -> bool:
|
||||
"""Check if current version is within the specified range."""
|
||||
current = parse_version(current_version)
|
||||
if not current:
|
||||
return True # If we can't parse version, show notification
|
||||
|
||||
min_ver = parse_version(min_version)
|
||||
max_ver = parse_version(max_version)
|
||||
|
||||
if min_ver and current < min_ver:
|
||||
return False
|
||||
if max_ver and current > max_ver:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Notification Evaluation
|
||||
# ─────────────────────────────
|
||||
|
||||
def evaluate_conditions(conditions: list[str] | str | None, user) -> bool:
|
||||
"""
|
||||
Evaluate notification conditions for a user.
|
||||
All conditions must pass (AND logic).
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
# Normalize to list
|
||||
if isinstance(conditions, str):
|
||||
conditions = [conditions]
|
||||
|
||||
for condition in conditions:
|
||||
if condition not in CONDITION_CHECKS:
|
||||
logger.warning(f"Unknown condition: {condition}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if not CONDITION_CHECKS[condition](user):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error evaluating condition {condition}: {e}")
|
||||
# On error, skip this condition (fail open)
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_show_notification(notification_data: dict, user) -> bool:
|
||||
"""
|
||||
Determine if a notification should be shown to a specific user.
|
||||
Checks version range, user level, and conditions.
|
||||
"""
|
||||
# Check version range
|
||||
if not is_version_in_range(
|
||||
__version__,
|
||||
notification_data.get('min_version'),
|
||||
notification_data.get('max_version')
|
||||
):
|
||||
return False
|
||||
|
||||
# Check user level
|
||||
user_level = notification_data.get('user_level', 'all')
|
||||
if user_level == 'admin' and getattr(user, 'user_level', 0) < 10:
|
||||
return False
|
||||
|
||||
# Check conditions
|
||||
conditions = notification_data.get('condition', [])
|
||||
if not evaluate_conditions(conditions, user):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Sync Service
|
||||
# ─────────────────────────────
|
||||
|
||||
def load_developer_notifications() -> list[dict]:
|
||||
"""Load notifications from the JSON file."""
|
||||
if not NOTIFICATIONS_FILE.exists():
|
||||
logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get('notifications', [])
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error parsing developer notifications JSON: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading developer notifications: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def sync_developer_notifications() -> dict[str, int]:
|
||||
"""
|
||||
Sync developer notifications from JSON file to database.
|
||||
|
||||
- Adds new notifications that don't exist in the DB
|
||||
- Removes DB notifications that are no longer in the JSON file
|
||||
- Updates existing notifications if they've changed
|
||||
|
||||
Returns a dict with counts of added, updated, and removed notifications.
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
|
||||
results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0}
|
||||
|
||||
notifications = load_developer_notifications()
|
||||
json_notification_keys = set()
|
||||
notifications_to_remove = set() # Track notifications to remove (out of range or expired)
|
||||
|
||||
for notif_data in notifications:
|
||||
notification_id = notif_data.get('id')
|
||||
if not notification_id:
|
||||
logger.warning("Notification missing 'id' field, skipping")
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
|
||||
json_notification_keys.add(notification_id)
|
||||
|
||||
# Check version constraints (only add if current version is in range)
|
||||
if not is_version_in_range(
|
||||
__version__,
|
||||
notif_data.get('min_version'),
|
||||
notif_data.get('max_version')
|
||||
):
|
||||
logger.debug(f"Notification {notification_id} not in version range, marking for removal")
|
||||
results['skipped'] += 1
|
||||
notifications_to_remove.add(notification_id)
|
||||
continue
|
||||
|
||||
# Parse expires_at if provided
|
||||
expires_at = None
|
||||
if notif_data.get('expires_at'):
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(
|
||||
notif_data['expires_at'].replace('Z', '+00:00')
|
||||
)
|
||||
# Skip if already expired and mark for removal
|
||||
if expires_at < timezone.now():
|
||||
logger.debug(f"Notification {notification_id} has expired, marking for removal")
|
||||
results['skipped'] += 1
|
||||
notifications_to_remove.add(notification_id)
|
||||
continue
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid expires_at for {notification_id}: {e}")
|
||||
|
||||
# Map notification_type from JSON to model choices
|
||||
type_mapping = {
|
||||
'version_update': SystemNotification.NotificationType.VERSION_UPDATE,
|
||||
'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION,
|
||||
'announcement': SystemNotification.NotificationType.ANNOUNCEMENT,
|
||||
'warning': SystemNotification.NotificationType.WARNING,
|
||||
'info': SystemNotification.NotificationType.INFO,
|
||||
}
|
||||
notification_type = type_mapping.get(
|
||||
notif_data.get('notification_type', 'info'),
|
||||
SystemNotification.NotificationType.INFO
|
||||
)
|
||||
|
||||
# Map priority
|
||||
priority_mapping = {
|
||||
'low': SystemNotification.Priority.LOW,
|
||||
'normal': SystemNotification.Priority.NORMAL,
|
||||
'high': SystemNotification.Priority.HIGH,
|
||||
'critical': SystemNotification.Priority.CRITICAL,
|
||||
}
|
||||
priority = priority_mapping.get(
|
||||
notif_data.get('priority', 'normal'),
|
||||
SystemNotification.Priority.NORMAL
|
||||
)
|
||||
|
||||
# Prepare action_data
|
||||
action_data = {
|
||||
'action_url': notif_data.get('action_url'),
|
||||
'action_text': notif_data.get('action_text'),
|
||||
'condition': notif_data.get('condition', []),
|
||||
'min_version': notif_data.get('min_version'),
|
||||
'max_version': notif_data.get('max_version'),
|
||||
'user_level': notif_data.get('user_level', 'all'),
|
||||
}
|
||||
|
||||
# Determine if admin-only based on user_level
|
||||
admin_only = notif_data.get('user_level', 'all') == 'admin'
|
||||
|
||||
# Create or update the notification
|
||||
notification, created = SystemNotification.objects.update_or_create(
|
||||
notification_key=notification_id,
|
||||
defaults={
|
||||
'notification_type': notification_type,
|
||||
'priority': priority,
|
||||
'source': SystemNotification.Source.DEVELOPER,
|
||||
'title': notif_data.get('title', 'Notification'),
|
||||
'message': notif_data.get('message', ''),
|
||||
'action_data': action_data,
|
||||
'is_active': True,
|
||||
'admin_only': admin_only,
|
||||
'expires_at': expires_at,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.info(f"Added developer notification: {notification_id}")
|
||||
results['added'] += 1
|
||||
else:
|
||||
logger.debug(f"Updated developer notification: {notification_id}")
|
||||
results['updated'] += 1
|
||||
|
||||
# Remove developer notifications that are:
|
||||
# - No longer in the JSON file, OR
|
||||
# - Out of version range for the current version, OR
|
||||
# - Expired
|
||||
removed_count, _ = SystemNotification.objects.filter(
|
||||
source=SystemNotification.Source.DEVELOPER
|
||||
).filter(
|
||||
models.Q(notification_key__in=notifications_to_remove) |
|
||||
~models.Q(notification_key__in=json_notification_keys)
|
||||
).delete()
|
||||
|
||||
if removed_count:
|
||||
logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)")
|
||||
results['removed'] = removed_count
|
||||
|
||||
logger.info(
|
||||
f"Developer notification sync complete: "
|
||||
f"{results['added']} added, {results['updated']} updated, "
|
||||
f"{results['removed']} removed, {results['skipped']} skipped"
|
||||
)
|
||||
|
||||
# Send websocket notification to frontend to refresh notifications
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
'type': 'notifications_cleared',
|
||||
})
|
||||
logger.debug("Sent websocket notification for notifications refresh")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send websocket update: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_user_developer_notifications(user) -> list:
|
||||
"""
|
||||
Get all developer notifications that should be shown to a specific user.
|
||||
Evaluates conditions and user_level for each notification.
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
|
||||
# Get all active developer notifications
|
||||
notifications = SystemNotification.objects.filter(
|
||||
source=SystemNotification.Source.DEVELOPER,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Filter by admin_only based on user
|
||||
if getattr(user, 'user_level', 0) < 10:
|
||||
notifications = notifications.filter(admin_only=False)
|
||||
|
||||
# Filter by conditions
|
||||
result = []
|
||||
for notification in notifications:
|
||||
action_data = notification.action_data or {}
|
||||
|
||||
# Evaluate conditions
|
||||
conditions = action_data.get('condition', [])
|
||||
if evaluate_conditions(conditions, user):
|
||||
result.append(notification)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"_schema_documentation": {
|
||||
"description": "Developer notification definitions. Each notification is evaluated at sync time.",
|
||||
"fields": {
|
||||
"id": "[REQUIRED] Unique identifier (notification_key in database).",
|
||||
"notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'",
|
||||
"priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'",
|
||||
"title": "[REQUIRED] Notification title/heading.",
|
||||
"message": "[REQUIRED] Detailed notification message body.",
|
||||
"min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'",
|
||||
"max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'",
|
||||
"created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.",
|
||||
"expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'",
|
||||
"condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']",
|
||||
"user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'",
|
||||
"action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'",
|
||||
"action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'"
|
||||
},
|
||||
"notes": [
|
||||
"Notifications are synced from this file to database on startup and when relevant settings change",
|
||||
"Out-of-range versions are automatically removed from database",
|
||||
"Expired notifications are automatically removed from database",
|
||||
"Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)"
|
||||
]
|
||||
},
|
||||
"notifications": [
|
||||
{
|
||||
"id": "network_security_m3u_epg",
|
||||
"notification_type": "warning",
|
||||
"priority": "high",
|
||||
"title": "Network Access Security Warning",
|
||||
"message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.",
|
||||
"min_version": null,
|
||||
"max_version": null,
|
||||
"created_at": "2026-02-02T00:00:00Z",
|
||||
"expires_at": null,
|
||||
"condition": ["m3u_epg_network_insecure"],
|
||||
"user_level": "admin",
|
||||
"action_url": "/settings#network-access",
|
||||
"action_text": "Review Settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"model": "core.useragent",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "TiviMate",
|
||||
"user_agent": "TiviMate/5.1.6 (Android 12)",
|
||||
"description": "",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.useragent",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "VLC",
|
||||
"user_agent": "VLC/3.0.21 LibVLC 3.0.21",
|
||||
"description": "",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.streamprofile",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "FFmpeg",
|
||||
"command": "ffmpeg",
|
||||
"parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
|
||||
"is_active": true,
|
||||
"user_agent": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.streamprofile",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Streamlink",
|
||||
"command": "streamlink",
|
||||
"parameters": "{streamUrl} best --stdout",
|
||||
"is_active": true,
|
||||
"user_agent": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.streamprofile",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "VLC",
|
||||
"command": "cvlc",
|
||||
"parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}",
|
||||
"is_active": true,
|
||||
"user_agent": "1"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Drop the entire database (schema and data) and recreate it. (PostgreSQL only)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db_settings = settings.DATABASES['default']
|
||||
db_name = db_settings['NAME']
|
||||
user = db_settings['USER']
|
||||
password = db_settings['PASSWORD']
|
||||
host = db_settings.get('HOST', 'localhost')
|
||||
port = db_settings.get('PORT', 5432)
|
||||
|
||||
# Read TLS parameters from Django OPTIONS (populated when POSTGRES_SSL=true)
|
||||
db_options = db_settings.get('OPTIONS', {})
|
||||
ssl_kwargs = {}
|
||||
for key in ('sslmode', 'sslrootcert', 'sslcert', 'sslkey'):
|
||||
if key in db_options:
|
||||
ssl_kwargs[key] = db_options[key]
|
||||
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"WARNING: This will irreversibly drop the entire database '{db_name}'!"
|
||||
))
|
||||
confirm = input("Type 'yes' to proceed: ")
|
||||
if confirm.lower() != 'yes':
|
||||
self.stdout.write("Aborted. No changes made.")
|
||||
return
|
||||
|
||||
# Close Django's current connection to the target DB
|
||||
connection.close()
|
||||
|
||||
# For PostgreSQL, we need to connect to a different database (e.g. the maintenance database "postgres")
|
||||
maintenance_db = 'postgres'
|
||||
try:
|
||||
self.stdout.write("Connecting to maintenance database...")
|
||||
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, **ssl_kwargs)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
self.stdout.write(f"Dropping database '{db_name}'...")
|
||||
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))
|
||||
self.stdout.write(f"Creating database '{db_name}'...")
|
||||
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name)))
|
||||
cur.close()
|
||||
conn.close()
|
||||
self.stdout.write(self.style.SUCCESS(f"Database '{db_name}' has been dropped and recreated."))
|
||||
self.stdout.write("Now run 'python manage.py migrate' to reapply your migrations.")
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f"Error dropping/creating database: {e}"))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,27 @@
|
||||
# core/management/commands/kill_processes.py
|
||||
|
||||
import psutil
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Kills all processes with 'ffmpeg' or 'streamlink' in their name or command line."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
kill_count = 0
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
||||
try:
|
||||
name = proc.info.get('name') or ''
|
||||
cmdline = ' '.join(proc.info.get('cmdline') or [])
|
||||
lower_name = name.lower()
|
||||
lower_cmdline = cmdline.lower()
|
||||
|
||||
if ('ffmpeg' in lower_name or 'ffmpeg' in lower_cmdline or
|
||||
'streamlink' in lower_name or 'streamlink' in lower_cmdline):
|
||||
self.stdout.write(f"Killing PID {proc.pid}: {name} {cmdline}")
|
||||
proc.kill()
|
||||
kill_count += 1
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Killed {kill_count} processes."))
|
||||
@@ -0,0 +1,13 @@
|
||||
# your_app/management/commands/update_column.py
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from core.models import CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reset network access settings"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
setting = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY)
|
||||
setting.value = {}
|
||||
setting.save()
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 22:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CoreSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('value', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StreamProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('profile_name', models.CharField(help_text='Name of the stream profile', max_length=255)),
|
||||
('command', models.CharField(help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", max_length=255)),
|
||||
('parameters', models.TextField(help_text='Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this profile is active')),
|
||||
('user_agent', models.CharField(blank=True, help_text='Optional user agent to use. If not set, you can fall back to a default.', max_length=512, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserAgent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_agent_name', models.CharField(help_text='The User-Agent name.', max_length=512, unique=True)),
|
||||
('user_agent', models.CharField(help_text='The complete User-Agent string sent by the client.', max_length=512, unique=True)),
|
||||
('description', models.CharField(blank=True, help_text='An optional description of the client or device type.', max_length=255)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user agent is currently allowed/recognized.')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def preload_user_agent(apps, schema_editor):
|
||||
UserAgent = apps.get_model("core", "UserAgent")
|
||||
UserAgent.objects.create(
|
||||
user_agent_name="TiviMate",
|
||||
user_agent="TiviMate/5.16 (Android 12)",
|
||||
description="",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
UserAgent.objects.create(
|
||||
user_agent_name="VLC",
|
||||
user_agent="VLC/3.0.21 LibVLC/3.0.21",
|
||||
description="",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
UserAgent.objects.create(
|
||||
user_agent_name="Chrome",
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.3",
|
||||
description="",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_user_agent),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def preload_stream_profiles(apps, schema_editor):
|
||||
StreamProfile = apps.get_model("core", "StreamProfile")
|
||||
StreamProfile.objects.create(
|
||||
profile_name="ffmpeg",
|
||||
command="ffmpeg",
|
||||
parameters="-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
|
||||
is_active=True,
|
||||
user_agent="1",
|
||||
)
|
||||
|
||||
StreamProfile.objects.create(
|
||||
profile_name="streamlink",
|
||||
command="streamlink",
|
||||
parameters="{streamUrl} best --stdout",
|
||||
is_active=True,
|
||||
user_agent="1",
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_preload_user_agents'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_stream_profiles),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
def preload_core_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("Default Stream Profile"),
|
||||
name="Default Stream Profile",
|
||||
value=1,
|
||||
)
|
||||
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("Default User-Agent"),
|
||||
name="Default User-Agent",
|
||||
value=1,
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_preload_stream_profiles'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_core_settings),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-14 18:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_preload_core_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='streamprofile',
|
||||
name='locked',
|
||||
field=models.BooleanField(default=False, help_text="Protected - can't be deleted or modified"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='streamprofile',
|
||||
name='command',
|
||||
field=models.CharField(blank=True, help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='streamprofile',
|
||||
name='parameters',
|
||||
field=models.TextField(blank=True, help_text='Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='streamprofile',
|
||||
name='user_agent',
|
||||
field=models.ForeignKey(blank=True, help_text='Optional user agent to use. If not set, you can fall back to a default.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.useragent'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-14 17:46
|
||||
|
||||
from django.db import migrations, models
|
||||
from core.models import CoreSettings, StreamProfile
|
||||
|
||||
def lock_or_create_profiles(apps, schema_editor):
|
||||
StreamProfile = apps.get_model("core", "StreamProfile")
|
||||
UserAgent = apps.get_model("core", "UserAgent")
|
||||
|
||||
# Define the system profiles that should exist
|
||||
system_profiles = [
|
||||
{
|
||||
"name": "ffmpeg",
|
||||
"command": "ffmpeg",
|
||||
"parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
|
||||
"new_parameters": "-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1",
|
||||
},
|
||||
{
|
||||
"name": "streamlink",
|
||||
"command": "streamlink",
|
||||
"parameters": "{streamUrl} best --stdout",
|
||||
"new_parameters": "{streamUrl} --http-header {userAgent} best --stdout",
|
||||
|
||||
},
|
||||
]
|
||||
|
||||
for profile_data in system_profiles:
|
||||
existing_profile = StreamProfile.objects.filter(
|
||||
profile_name=profile_data["name"],
|
||||
command=profile_data["command"],
|
||||
parameters=profile_data["parameters"],
|
||||
).first()
|
||||
|
||||
if existing_profile:
|
||||
# Lock existing profile
|
||||
existing_profile.locked = True
|
||||
existing_profile.parameters = profile_data["new_parameters"]
|
||||
existing_profile.save()
|
||||
else:
|
||||
# Create a new locked profile
|
||||
new_profile = StreamProfile.objects.create(
|
||||
profile_name=profile_data["name"],
|
||||
command=profile_data["command"],
|
||||
parameters=profile_data["new_parameters"],
|
||||
locked=True,
|
||||
)
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
# No need to reverse changes
|
||||
pass
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0005_streamprofile_locked_alter_streamprofile_command_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(lock_or_create_profiles, reverse_code=reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-14 17:16
|
||||
|
||||
from django.db import migrations
|
||||
from core.models import CoreSettings
|
||||
|
||||
def create_proxy_stream_profile(apps, schema_editor):
|
||||
default_user_agent_id = CoreSettings.get_default_user_agent_id()
|
||||
|
||||
StreamProfile = apps.get_model("core", "StreamProfile")
|
||||
StreamProfile.objects.create(
|
||||
profile_name="Proxy",
|
||||
command="",
|
||||
parameters="",
|
||||
locked=True,
|
||||
is_active=True,
|
||||
user_agent_id=default_user_agent_id,
|
||||
)
|
||||
|
||||
StreamProfile.objects.create(
|
||||
profile_name="Redirect",
|
||||
command="",
|
||||
parameters="",
|
||||
locked=True,
|
||||
is_active=True,
|
||||
user_agent_id=default_user_agent_id,
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_set_locked_stream_profiles'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_proxy_stream_profile)
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-16 12:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_create_proxy_and_redirect_stream_profiles'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='streamprofile',
|
||||
old_name='profile_name',
|
||||
new_name='name',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='useragent',
|
||||
old_name='user_agent_name',
|
||||
new_name='name',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
def preload_core_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("M3U Hash Key"),
|
||||
name="M3U Hash Key",
|
||||
value="url",
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_rename_profile_name_streamprofile_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_core_settings),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
def preload_core_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("Preferred Region"),
|
||||
name="Preferred Region",
|
||||
value="us",
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_m3u_hash_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_core_settings),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.6 on 2025-04-04
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def fix_stream_profiles_and_user_agents(apps, schema_editor):
|
||||
# Get the model
|
||||
StreamProfile = apps.get_model("core", "StreamProfile")
|
||||
|
||||
streamlink_profile = StreamProfile.objects.get(name="streamlink", locked=True)
|
||||
streamlink_profile.parameters = "{streamUrl} --http-header User-Agent={userAgent} best --stdout"
|
||||
streamlink_profile.save()
|
||||
|
||||
UserAgent = apps.get_model("core", "UserAgent")
|
||||
tivimate = UserAgent.objects.get(name="TiviMate", user_agent="TiviMate/5.16 (Android 12)")
|
||||
if tivimate:
|
||||
tivimate.user_agent = "TiviMate/5.1.6 (Android 12)"
|
||||
tivimate.save()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_reload_additional_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_stream_profiles_and_user_agents),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
def preload_core_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("Auto-Import Mapped Files"),
|
||||
name="Auto-Import Mapped Files",
|
||||
value=True,
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_fix_stream_profiles_and_user_agents'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_core_settings),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def preload_network_access_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("Network Access"),
|
||||
name="Network Access",
|
||||
value="{}",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0012_default_active_m3u_accounts"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_network_access_settings),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
import json
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def preload_proxy_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
# Default proxy settings
|
||||
default_proxy_settings = {
|
||||
"buffering_timeout": 15,
|
||||
"buffering_speed": 1.0,
|
||||
"redis_chunk_ttl": 60,
|
||||
"channel_shutdown_delay": 0,
|
||||
"channel_init_grace_period": 5,
|
||||
}
|
||||
|
||||
CoreSettings.objects.create(
|
||||
key=slugify("Proxy Settings"),
|
||||
name="Proxy Settings",
|
||||
value=json.dumps(default_proxy_settings),
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0013_default_network_access_settings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_proxy_settings),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:10
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def add_dvr_defaults(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
defaults = [
|
||||
(slugify("DVR TV Template"), "DVR TV Template", "Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
|
||||
(slugify("DVR Movie Template"), "DVR Movie Template", "Recordings/Movies/{title} ({year}).mkv"),
|
||||
(slugify("DVR TV Fallback Template"), "DVR TV Fallback Template", "Recordings/TV_Shows/{show}/{start}.mkv"),
|
||||
(slugify("DVR Movie Fallback Template"), "DVR Movie Fallback Template", "Recordings/Movies/{start}.mkv"),
|
||||
(slugify("DVR Comskip Enabled"), "DVR Comskip Enabled", "false"),
|
||||
]
|
||||
|
||||
for key, name, value in defaults:
|
||||
CoreSettings.objects.get_or_create(key=key, defaults={"name": name, "value": value})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0014_default_proxy_settings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_dvr_defaults),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
# Generated manually to update DVR template paths
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def update_dvr_template_paths(apps, schema_editor):
|
||||
"""Remove 'Recordings/' prefix from DVR template paths"""
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
# Define the updates needed
|
||||
updates = [
|
||||
(slugify("DVR TV Template"), "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
|
||||
(slugify("DVR Movie Template"), "Movies/{title} ({year}).mkv"),
|
||||
(slugify("DVR TV Fallback Template"), "TV_Shows/{show}/{start}.mkv"),
|
||||
(slugify("DVR Movie Fallback Template"), "Movies/{start}.mkv"),
|
||||
]
|
||||
|
||||
# Update each setting
|
||||
for key, new_value in updates:
|
||||
try:
|
||||
setting = CoreSettings.objects.get(key=key)
|
||||
setting.value = new_value
|
||||
setting.save()
|
||||
print(f"Updated {setting.name}: {new_value}")
|
||||
except CoreSettings.DoesNotExist:
|
||||
print(f"Setting with key '{key}' not found - skipping")
|
||||
|
||||
|
||||
def reverse_dvr_template_paths(apps, schema_editor):
|
||||
"""Add back 'Recordings/' prefix to DVR template paths"""
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
# Define the reverse updates (add back Recordings/ prefix)
|
||||
updates = [
|
||||
(slugify("DVR TV Template"), "Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
|
||||
(slugify("DVR Movie Template"), "Recordings/Movies/{title} ({year}).mkv"),
|
||||
(slugify("DVR TV Fallback Template"), "Recordings/TV_Shows/{show}/{start}.mkv"),
|
||||
(slugify("DVR Movie Fallback Template"), "Recordings/Movies/{start}.mkv"),
|
||||
]
|
||||
|
||||
# Update each setting back to original
|
||||
for key, original_value in updates:
|
||||
try:
|
||||
setting = CoreSettings.objects.get(key=key)
|
||||
setting.value = original_value
|
||||
setting.save()
|
||||
print(f"Reverted {setting.name}: {original_value}")
|
||||
except CoreSettings.DoesNotExist:
|
||||
print(f"Setting with key '{key}' not found - skipping")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0015_dvr_templates"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_dvr_template_paths, reverse_dvr_template_paths),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.4 on 2025-11-20 20:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_update_dvr_template_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event_type', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded')], db_index=True, max_length=50)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('channel_id', models.UUIDField(blank=True, db_index=True, null=True)),
|
||||
('channel_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('details', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['-timestamp'], name='core_system_timesta_c6c3d1_idx'), models.Index(fields=['event_type', '-timestamp'], name='core_system_event_t_4267d9_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-11-21 15:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0017_systemevent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='systemevent',
|
||||
name='event_type',
|
||||
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked')], db_index=True, max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated migration to add VLC stream profile
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def add_vlc_profile(apps, schema_editor):
|
||||
StreamProfile = apps.get_model("core", "StreamProfile")
|
||||
UserAgent = apps.get_model("core", "UserAgent")
|
||||
|
||||
# Check if VLC profile already exists
|
||||
if not StreamProfile.objects.filter(name="VLC").exists():
|
||||
# Get the TiviMate user agent (should be pk=1)
|
||||
try:
|
||||
tivimate_ua = UserAgent.objects.get(pk=1)
|
||||
except UserAgent.DoesNotExist:
|
||||
# Fallback: get first available user agent
|
||||
tivimate_ua = UserAgent.objects.first()
|
||||
if not tivimate_ua:
|
||||
# No user agents exist, skip creating profile
|
||||
return
|
||||
|
||||
StreamProfile.objects.create(
|
||||
name="VLC",
|
||||
command="cvlc",
|
||||
parameters="-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}",
|
||||
is_active=True,
|
||||
user_agent=tivimate_ua,
|
||||
locked=True, # Make it read-only like ffmpeg/streamlink
|
||||
)
|
||||
|
||||
def remove_vlc_profile(apps, schema_editor):
|
||||
StreamProfile = apps.get_model("core", "StreamProfile")
|
||||
StreamProfile.objects.filter(name="VLC").delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0018_alter_systemevent_event_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_vlc_profile, remove_vlc_profile),
|
||||
]
|
||||
@@ -0,0 +1,267 @@
|
||||
# Generated migration to change CoreSettings value field to JSONField and consolidate settings
|
||||
|
||||
import json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def convert_string_to_json(apps, schema_editor):
|
||||
"""Convert existing string values to appropriate JSON types before changing column type"""
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
for setting in CoreSettings.objects.all():
|
||||
value = setting.value
|
||||
|
||||
if not value:
|
||||
# Empty strings become empty string in JSON
|
||||
setting.value = json.dumps("")
|
||||
setting.save(update_fields=['value'])
|
||||
continue
|
||||
|
||||
# Try to parse as JSON if it looks like JSON (objects/arrays)
|
||||
if value.startswith('{') or value.startswith('['):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
# Store as JSON string temporarily (column is still CharField)
|
||||
setting.value = json.dumps(parsed)
|
||||
setting.save(update_fields=['value'])
|
||||
continue
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Try to parse as number
|
||||
try:
|
||||
# Check if it's an integer
|
||||
if '.' not in value and value.lstrip('-').isdigit():
|
||||
setting.value = json.dumps(int(value))
|
||||
setting.save(update_fields=['value'])
|
||||
continue
|
||||
# Check if it's a float
|
||||
float_val = float(value)
|
||||
setting.value = json.dumps(float_val)
|
||||
setting.save(update_fields=['value'])
|
||||
continue
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Check for booleans
|
||||
if value.lower() in ('true', 'false', '1', '0', 'yes', 'no', 'on', 'off'):
|
||||
bool_val = value.lower() in ('true', '1', 'yes', 'on')
|
||||
setting.value = json.dumps(bool_val)
|
||||
setting.save(update_fields=['value'])
|
||||
continue
|
||||
|
||||
# Default: store as JSON string
|
||||
setting.value = json.dumps(value)
|
||||
setting.save(update_fields=['value'])
|
||||
|
||||
|
||||
def consolidate_settings(apps, schema_editor):
|
||||
"""Consolidate individual setting rows into grouped JSON objects."""
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
# Helper to get setting value
|
||||
def get_value(key, default=None):
|
||||
try:
|
||||
obj = CoreSettings.objects.get(key=key)
|
||||
return obj.value if obj.value is not None else default
|
||||
except CoreSettings.DoesNotExist:
|
||||
return default
|
||||
|
||||
# STREAM SETTINGS
|
||||
stream_settings = {
|
||||
"default_user_agent": get_value("default-user-agent"),
|
||||
"default_stream_profile": get_value("default-stream-profile"),
|
||||
"m3u_hash_key": get_value("m3u-hash-key", ""),
|
||||
"preferred_region": get_value("preferred-region"),
|
||||
"auto_import_mapped_files": get_value("auto-import-mapped-files"),
|
||||
}
|
||||
CoreSettings.objects.update_or_create(
|
||||
key="stream_settings",
|
||||
defaults={"name": "Stream Settings", "value": stream_settings}
|
||||
)
|
||||
|
||||
# DVR SETTINGS
|
||||
dvr_settings = {
|
||||
"tv_template": get_value("dvr-tv-template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
|
||||
"movie_template": get_value("dvr-movie-template", "Movies/{title} ({year}).mkv"),
|
||||
"tv_fallback_dir": get_value("dvr-tv-fallback-dir", "TV_Shows"),
|
||||
"tv_fallback_template": get_value("dvr-tv-fallback-template", "TV_Shows/{show}/{start}.mkv"),
|
||||
"movie_fallback_template": get_value("dvr-movie-fallback-template", "Movies/{start}.mkv"),
|
||||
"comskip_enabled": bool(get_value("dvr-comskip-enabled", False)),
|
||||
"comskip_custom_path": get_value("dvr-comskip-custom-path", ""),
|
||||
"pre_offset_minutes": int(get_value("dvr-pre-offset-minutes", 0) or 0),
|
||||
"post_offset_minutes": int(get_value("dvr-post-offset-minutes", 0) or 0),
|
||||
"series_rules": get_value("dvr-series-rules", []),
|
||||
}
|
||||
CoreSettings.objects.update_or_create(
|
||||
key="dvr_settings",
|
||||
defaults={"name": "DVR Settings", "value": dvr_settings}
|
||||
)
|
||||
|
||||
# BACKUP SETTINGS - using underscore keys (not dashes)
|
||||
backup_settings = {
|
||||
"schedule_enabled": get_value("backup_schedule_enabled") if get_value("backup_schedule_enabled") is not None else True,
|
||||
"schedule_frequency": get_value("backup_schedule_frequency") or "daily",
|
||||
"schedule_time": get_value("backup_schedule_time") or "03:00",
|
||||
"schedule_day_of_week": get_value("backup_schedule_day_of_week") if get_value("backup_schedule_day_of_week") is not None else 0,
|
||||
"retention_count": get_value("backup_retention_count") if get_value("backup_retention_count") is not None else 3,
|
||||
"schedule_cron_expression": get_value("backup_schedule_cron_expression") or "",
|
||||
}
|
||||
CoreSettings.objects.update_or_create(
|
||||
key="backup_settings",
|
||||
defaults={"name": "Backup Settings", "value": backup_settings}
|
||||
)
|
||||
|
||||
# SYSTEM SETTINGS
|
||||
system_settings = {
|
||||
"time_zone": get_value("system-time-zone", "UTC"),
|
||||
"max_system_events": int(get_value("max-system-events", 100) or 100),
|
||||
}
|
||||
CoreSettings.objects.update_or_create(
|
||||
key="system_settings",
|
||||
defaults={"name": "System Settings", "value": system_settings}
|
||||
)
|
||||
|
||||
# Rename proxy-settings to proxy_settings (if it exists with old name)
|
||||
try:
|
||||
old_proxy = CoreSettings.objects.get(key="proxy-settings")
|
||||
old_proxy.key = "proxy_settings"
|
||||
old_proxy.save()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Ensure proxy_settings exists with defaults if not present
|
||||
proxy_obj, proxy_created = CoreSettings.objects.get_or_create(
|
||||
key="proxy_settings",
|
||||
defaults={
|
||||
"name": "Proxy Settings",
|
||||
"value": {
|
||||
"buffering_timeout": 15,
|
||||
"buffering_speed": 1.0,
|
||||
"redis_chunk_ttl": 60,
|
||||
"channel_shutdown_delay": 0,
|
||||
"channel_init_grace_period": 5,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Rename network-access to network_access (if it exists with old name)
|
||||
try:
|
||||
old_network = CoreSettings.objects.get(key="network-access")
|
||||
old_network.key = "network_access"
|
||||
old_network.save()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Ensure network_access exists with defaults if not present
|
||||
network_obj, network_created = CoreSettings.objects.get_or_create(
|
||||
key="network_access",
|
||||
defaults={
|
||||
"name": "Network Access",
|
||||
"value": {}
|
||||
}
|
||||
)
|
||||
# Delete old individual setting rows (keep only the new grouped settings)
|
||||
grouped_keys = ["stream_settings", "dvr_settings", "backup_settings", "system_settings", "proxy_settings", "network_access"]
|
||||
CoreSettings.objects.exclude(key__in=grouped_keys).delete()
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Reverse migration: split grouped settings and convert JSON back to strings"""
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
|
||||
# Helper to create individual setting
|
||||
def create_setting(key, name, value):
|
||||
# Convert value back to string representation for CharField
|
||||
if isinstance(value, str):
|
||||
str_value = value
|
||||
elif isinstance(value, bool):
|
||||
str_value = "true" if value else "false"
|
||||
elif isinstance(value, (int, float)):
|
||||
str_value = str(value)
|
||||
elif isinstance(value, (dict, list)):
|
||||
str_value = json.dumps(value)
|
||||
elif value is None:
|
||||
str_value = ""
|
||||
else:
|
||||
str_value = str(value)
|
||||
|
||||
CoreSettings.objects.update_or_create(
|
||||
key=key,
|
||||
defaults={"name": name, "value": str_value}
|
||||
)
|
||||
|
||||
# Split stream_settings
|
||||
try:
|
||||
stream = CoreSettings.objects.get(key="stream_settings")
|
||||
if isinstance(stream.value, dict):
|
||||
create_setting("default_user_agent", "Default User Agent", stream.value.get("default_user_agent"))
|
||||
create_setting("default_stream_profile", "Default Stream Profile", stream.value.get("default_stream_profile"))
|
||||
create_setting("stream_hash_key", "Stream Hash Key", stream.value.get("m3u_hash_key", ""))
|
||||
create_setting("preferred_region", "Preferred Region", stream.value.get("preferred_region"))
|
||||
create_setting("auto_import_mapped_files", "Auto Import Mapped Files", stream.value.get("auto_import_mapped_files"))
|
||||
stream.delete()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Split dvr_settings
|
||||
try:
|
||||
dvr = CoreSettings.objects.get(key="dvr_settings")
|
||||
if isinstance(dvr.value, dict):
|
||||
create_setting("dvr_tv_template", "DVR TV Template", dvr.value.get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"))
|
||||
create_setting("dvr_movie_template", "DVR Movie Template", dvr.value.get("movie_template", "Movies/{title} ({year}).mkv"))
|
||||
create_setting("dvr_tv_fallback_dir", "DVR TV Fallback Dir", dvr.value.get("tv_fallback_dir", "TV_Shows"))
|
||||
create_setting("dvr_tv_fallback_template", "DVR TV Fallback Template", dvr.value.get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv"))
|
||||
create_setting("dvr_movie_fallback_template", "DVR Movie Fallback Template", dvr.value.get("movie_fallback_template", "Movies/{start}.mkv"))
|
||||
create_setting("dvr_comskip_enabled", "DVR Comskip Enabled", dvr.value.get("comskip_enabled", False))
|
||||
create_setting("dvr_comskip_custom_path", "DVR Comskip Custom Path", dvr.value.get("comskip_custom_path", ""))
|
||||
create_setting("dvr_pre_offset_minutes", "DVR Pre Offset Minutes", dvr.value.get("pre_offset_minutes", 0))
|
||||
create_setting("dvr_post_offset_minutes", "DVR Post Offset Minutes", dvr.value.get("post_offset_minutes", 0))
|
||||
create_setting("dvr_series_rules", "DVR Series Rules", dvr.value.get("series_rules", []))
|
||||
dvr.delete()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Split backup_settings
|
||||
try:
|
||||
backup = CoreSettings.objects.get(key="backup_settings")
|
||||
if isinstance(backup.value, dict):
|
||||
create_setting("backup_schedule_enabled", "Backup Schedule Enabled", backup.value.get("schedule_enabled", False))
|
||||
create_setting("backup_schedule_frequency", "Backup Schedule Frequency", backup.value.get("schedule_frequency", "weekly"))
|
||||
create_setting("backup_schedule_time", "Backup Schedule Time", backup.value.get("schedule_time", "02:00"))
|
||||
create_setting("backup_schedule_day_of_week", "Backup Schedule Day of Week", backup.value.get("schedule_day_of_week", 0))
|
||||
create_setting("backup_retention_count", "Backup Retention Count", backup.value.get("retention_count", 7))
|
||||
create_setting("backup_schedule_cron_expression", "Backup Schedule Cron Expression", backup.value.get("schedule_cron_expression", ""))
|
||||
backup.delete()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Split system_settings
|
||||
try:
|
||||
system = CoreSettings.objects.get(key="system_settings")
|
||||
if isinstance(system.value, dict):
|
||||
create_setting("system_time_zone", "System Time Zone", system.value.get("time_zone", "UTC"))
|
||||
create_setting("max_system_events", "Max System Events", system.value.get("max_system_events", 100))
|
||||
system.delete()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0019_add_vlc_stream_profile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# First, convert all data to valid JSON strings while column is still CharField
|
||||
migrations.RunPython(convert_string_to_json, migrations.RunPython.noop),
|
||||
# Then change the field type to JSONField
|
||||
migrations.AlterField(
|
||||
model_name='coresettings',
|
||||
name='value',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
# Finally, consolidate individual settings into grouped JSON objects
|
||||
migrations.RunPython(consolidate_settings, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.2.9 on 2026-02-02 20:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_change_coresettings_value_to_jsonfield'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemNotification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('notification_key', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)),
|
||||
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)),
|
||||
('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('action_data', models.JSONField(blank=True, default=dict)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('admin_only', models.BooleanField(default=False)),
|
||||
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-priority', '-created_at'],
|
||||
'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationDismissal',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('dismissed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('action_taken', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)),
|
||||
('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')],
|
||||
'unique_together': {('user', 'notification')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def preload_user_limit_settings(apps, schema_editor):
|
||||
CoreSettings = apps.get_model("core", "CoreSettings")
|
||||
CoreSettings.objects.create(
|
||||
key="user_limit_settings",
|
||||
name="User Limit Settings",
|
||||
value={},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0021_systemnotification_notificationdismissal"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(preload_user_limit_settings),
|
||||
]
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
# core/models.py
|
||||
|
||||
from shlex import split as shlex_split
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class UserAgent(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=512, unique=True, help_text="The User-Agent name."
|
||||
)
|
||||
user_agent = models.CharField(
|
||||
max_length=512,
|
||||
unique=True,
|
||||
help_text="The complete User-Agent string sent by the client.",
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="An optional description of the client or device type.",
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this user agent is currently allowed/recognized.",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
PROXY_PROFILE_NAME = "Proxy"
|
||||
REDIRECT_PROFILE_NAME = "Redirect"
|
||||
|
||||
|
||||
class StreamProfile(models.Model):
|
||||
name = models.CharField(max_length=255, help_text="Name of the stream profile")
|
||||
command = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')",
|
||||
blank=True,
|
||||
)
|
||||
parameters = models.TextField(
|
||||
help_text="Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.",
|
||||
blank=True,
|
||||
)
|
||||
locked = models.BooleanField(
|
||||
default=False, help_text="Protected - can't be deleted or modified"
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True, help_text="Whether this profile is active"
|
||||
)
|
||||
user_agent = models.ForeignKey(
|
||||
"UserAgent",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Optional user agent to use. If not set, you can fall back to a default.",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pk: # Only check existing records
|
||||
orig = StreamProfile.objects.get(pk=self.pk)
|
||||
if orig.locked:
|
||||
allowed_fields = {"user_agent_id"} # Only allow this field to change
|
||||
for field in self._meta.fields:
|
||||
field_name = field.name
|
||||
|
||||
# Convert user_agent to user_agent_id for comparison
|
||||
orig_value = getattr(orig, field_name)
|
||||
new_value = getattr(self, field_name)
|
||||
|
||||
# Ensure that ForeignKey fields compare their ID values
|
||||
if isinstance(orig_value, models.Model):
|
||||
orig_value = orig_value.pk
|
||||
if isinstance(new_value, models.Model):
|
||||
new_value = new_value.pk
|
||||
|
||||
if field_name not in allowed_fields and orig_value != new_value:
|
||||
raise ValidationError(
|
||||
f"Cannot modify {field_name} on a protected profile."
|
||||
)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def update(cls, pk, **kwargs):
|
||||
instance = cls.objects.get(pk=pk)
|
||||
|
||||
if instance.locked:
|
||||
allowed_fields = {"user_agent_id"} # Only allow updating this field
|
||||
|
||||
for field_name, new_value in kwargs.items():
|
||||
if field_name not in allowed_fields:
|
||||
raise ValidationError(
|
||||
f"Cannot modify {field_name} on a protected profile."
|
||||
)
|
||||
|
||||
# Ensure user_agent ForeignKey updates correctly
|
||||
if field_name == "user_agent" and isinstance(
|
||||
new_value, cls._meta.get_field("user_agent").related_model
|
||||
):
|
||||
new_value = new_value.pk # Convert object to ID if needed
|
||||
|
||||
setattr(instance, field_name, new_value)
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def is_proxy(self):
|
||||
if self.locked and self.name == PROXY_PROFILE_NAME:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_redirect(self):
|
||||
if self.locked and self.name == REDIRECT_PROFILE_NAME:
|
||||
return True
|
||||
return False
|
||||
|
||||
def build_command(self, stream_url, user_agent):
|
||||
if self.is_proxy():
|
||||
return []
|
||||
|
||||
replacements = {
|
||||
"{streamUrl}": stream_url,
|
||||
"{userAgent}": user_agent,
|
||||
}
|
||||
|
||||
# Split the command and iterate through each part to apply replacements
|
||||
cmd = [self.command] + [
|
||||
self._replace_in_part(part, replacements)
|
||||
for part in shlex_split(self.parameters) # use shlex to handle quoted strings
|
||||
]
|
||||
|
||||
return cmd
|
||||
|
||||
def _replace_in_part(self, part, replacements):
|
||||
# Iterate through the replacements and replace each part of the string
|
||||
for key, value in replacements.items():
|
||||
part = part.replace(key, value)
|
||||
return part
|
||||
|
||||
|
||||
# Setting group keys
|
||||
STREAM_SETTINGS_KEY = "stream_settings"
|
||||
DVR_SETTINGS_KEY = "dvr_settings"
|
||||
BACKUP_SETTINGS_KEY = "backup_settings"
|
||||
PROXY_SETTINGS_KEY = "proxy_settings"
|
||||
NETWORK_ACCESS_KEY = "network_access"
|
||||
SYSTEM_SETTINGS_KEY = "system_settings"
|
||||
EPG_SETTINGS_KEY = "epg_settings"
|
||||
USER_LIMITS_SETTINGS_KEY = "user_limit_settings"
|
||||
|
||||
|
||||
class CoreSettings(models.Model):
|
||||
key = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
value = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "Core Settings"
|
||||
|
||||
# Helper methods to get/set grouped settings
|
||||
@classmethod
|
||||
def _get_group(cls, key, defaults=None):
|
||||
"""Get a settings group, returning defaults if not found."""
|
||||
try:
|
||||
return cls.objects.get(key=key).value or (defaults or {})
|
||||
except cls.DoesNotExist:
|
||||
return defaults or {}
|
||||
|
||||
@classmethod
|
||||
def _update_group(cls, key, name, updates):
|
||||
"""Update specific fields in a settings group."""
|
||||
obj, created = cls.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={"name": name, "value": {}}
|
||||
)
|
||||
current = obj.value if isinstance(obj.value, dict) else {}
|
||||
current.update(updates)
|
||||
obj.value = current
|
||||
obj.save()
|
||||
return current
|
||||
|
||||
# Stream Settings
|
||||
@classmethod
|
||||
def get_stream_settings(cls):
|
||||
"""Get all stream-related settings."""
|
||||
return cls._get_group(STREAM_SETTINGS_KEY, {
|
||||
"default_user_agent": None,
|
||||
"default_stream_profile": None,
|
||||
"m3u_hash_key": "",
|
||||
"preferred_region": None,
|
||||
"auto_import_mapped_files": None,
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_default_user_agent_id(cls):
|
||||
return cls.get_stream_settings().get("default_user_agent")
|
||||
|
||||
@classmethod
|
||||
def get_default_stream_profile_id(cls):
|
||||
return cls.get_stream_settings().get("default_stream_profile")
|
||||
|
||||
@classmethod
|
||||
def get_m3u_hash_key(cls):
|
||||
return cls.get_stream_settings().get("m3u_hash_key", "")
|
||||
|
||||
@classmethod
|
||||
def get_preferred_region(cls):
|
||||
return cls.get_stream_settings().get("preferred_region")
|
||||
|
||||
@classmethod
|
||||
def get_auto_import_mapped_files(cls):
|
||||
return cls.get_stream_settings().get("auto_import_mapped_files")
|
||||
|
||||
# EPG Settings
|
||||
@classmethod
|
||||
def get_epg_settings(cls):
|
||||
"""Get all EPG-related settings."""
|
||||
return cls._get_group(EPG_SETTINGS_KEY, {
|
||||
"epg_match_mode": "default",
|
||||
"epg_match_ignore_prefixes": [],
|
||||
"epg_match_ignore_suffixes": [],
|
||||
"epg_match_ignore_custom": [],
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _safe_string_list(cls, value):
|
||||
"""Return a list of strings, filtering out non-list or non-string values."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [v for v in value if isinstance(v, str)]
|
||||
|
||||
@classmethod
|
||||
def get_epg_match_ignore_prefixes(cls):
|
||||
return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_prefixes", []))
|
||||
|
||||
@classmethod
|
||||
def get_epg_match_ignore_suffixes(cls):
|
||||
return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_suffixes", []))
|
||||
|
||||
@classmethod
|
||||
def get_epg_match_ignore_custom(cls):
|
||||
return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_custom", []))
|
||||
|
||||
# DVR Settings
|
||||
@classmethod
|
||||
def get_dvr_settings(cls):
|
||||
"""Get all DVR-related settings."""
|
||||
return cls._get_group(DVR_SETTINGS_KEY, {
|
||||
"tv_template": "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv",
|
||||
"movie_template": "Movies/{title} ({year}).mkv",
|
||||
"tv_fallback_dir": "TV_Shows",
|
||||
"tv_fallback_template": "TV_Shows/{show}/{start}.mkv",
|
||||
"movie_fallback_template": "Movies/{start}.mkv",
|
||||
"comskip_enabled": False,
|
||||
"comskip_custom_path": "",
|
||||
"pre_offset_minutes": 0,
|
||||
"post_offset_minutes": 0,
|
||||
"series_rules": [],
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_dvr_tv_template(cls):
|
||||
return cls.get_dvr_settings().get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||
|
||||
@classmethod
|
||||
def get_dvr_movie_template(cls):
|
||||
return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv")
|
||||
|
||||
@classmethod
|
||||
def get_dvr_tv_fallback_dir(cls):
|
||||
return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows")
|
||||
|
||||
@classmethod
|
||||
def get_dvr_tv_fallback_template(cls):
|
||||
return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv")
|
||||
|
||||
@classmethod
|
||||
def get_dvr_movie_fallback_template(cls):
|
||||
return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv")
|
||||
|
||||
@classmethod
|
||||
def get_dvr_comskip_enabled(cls):
|
||||
return bool(cls.get_dvr_settings().get("comskip_enabled", False))
|
||||
|
||||
@classmethod
|
||||
def get_dvr_comskip_custom_path(cls):
|
||||
return cls.get_dvr_settings().get("comskip_custom_path", "")
|
||||
|
||||
@classmethod
|
||||
def set_dvr_comskip_custom_path(cls, path: str | None):
|
||||
value = (path or "").strip()
|
||||
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value})
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_dvr_pre_offset_minutes(cls):
|
||||
return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 0)
|
||||
|
||||
@classmethod
|
||||
def get_dvr_post_offset_minutes(cls):
|
||||
return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 0)
|
||||
|
||||
@classmethod
|
||||
def get_dvr_series_rules(cls):
|
||||
rules = cls.get_dvr_settings().get("series_rules", [])
|
||||
if not isinstance(rules, list):
|
||||
return []
|
||||
return [r for r in rules if isinstance(r, dict)]
|
||||
|
||||
@classmethod
|
||||
def set_dvr_series_rules(cls, rules):
|
||||
clean = [r for r in rules if isinstance(r, dict)] if isinstance(rules, list) else []
|
||||
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": clean})
|
||||
return clean
|
||||
|
||||
# Proxy Settings
|
||||
@classmethod
|
||||
def get_proxy_settings(cls):
|
||||
"""Get proxy settings."""
|
||||
return cls._get_group(PROXY_SETTINGS_KEY, {
|
||||
"buffering_timeout": 15,
|
||||
"buffering_speed": 1.0,
|
||||
"redis_chunk_ttl": 60,
|
||||
"channel_shutdown_delay": 0,
|
||||
"channel_init_grace_period": 5,
|
||||
"new_client_behind_seconds": 5,
|
||||
})
|
||||
|
||||
# System Settings
|
||||
@classmethod
|
||||
def get_system_settings(cls):
|
||||
"""Get all system-related settings."""
|
||||
return cls._get_group(SYSTEM_SETTINGS_KEY, {
|
||||
"time_zone": getattr(settings, "TIME_ZONE", "UTC") or "UTC",
|
||||
"max_system_events": 100,
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_system_time_zone(cls):
|
||||
return cls.get_system_settings().get("time_zone") or getattr(settings, "TIME_ZONE", "UTC") or "UTC"
|
||||
|
||||
@classmethod
|
||||
def set_system_time_zone(cls, tz_name: str | None):
|
||||
value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC"
|
||||
cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value})
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_user_limits_settings(cls):
|
||||
return cls._get_group(USER_LIMITS_SETTINGS_KEY, {
|
||||
"terminate_on_limit_exceeded": True,
|
||||
"prioritize_single_client_channels": True,
|
||||
"ignore_same_channel_connections": False,
|
||||
"terminate_oldest": True,
|
||||
})
|
||||
|
||||
|
||||
class SystemEvent(models.Model):
|
||||
"""
|
||||
Tracks system events like channel start/stop, buffering, failover, client connections.
|
||||
Maintains a rolling history based on max_system_events setting.
|
||||
"""
|
||||
EVENT_TYPES = [
|
||||
('channel_start', 'Channel Started'),
|
||||
('channel_stop', 'Channel Stopped'),
|
||||
('channel_buffering', 'Channel Buffering'),
|
||||
('channel_failover', 'Channel Failover'),
|
||||
('channel_reconnect', 'Channel Reconnected'),
|
||||
('channel_error', 'Channel Error'),
|
||||
('client_connect', 'Client Connected'),
|
||||
('client_disconnect', 'Client Disconnected'),
|
||||
('recording_start', 'Recording Started'),
|
||||
('recording_end', 'Recording Ended'),
|
||||
('stream_switch', 'Stream Switched'),
|
||||
('m3u_refresh', 'M3U Refreshed'),
|
||||
('m3u_download', 'M3U Downloaded'),
|
||||
('epg_refresh', 'EPG Refreshed'),
|
||||
('epg_download', 'EPG Downloaded'),
|
||||
('login_success', 'Login Successful'),
|
||||
('login_failed', 'Login Failed'),
|
||||
('logout', 'User Logged Out'),
|
||||
('m3u_blocked', 'M3U Download Blocked'),
|
||||
('epg_blocked', 'EPG Download Blocked'),
|
||||
]
|
||||
|
||||
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
||||
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
channel_id = models.UUIDField(null=True, blank=True, db_index=True)
|
||||
channel_name = models.CharField(max_length=255, null=True, blank=True)
|
||||
details = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['-timestamp']),
|
||||
models.Index(fields=['event_type', '-timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
|
||||
|
||||
|
||||
class SystemNotification(models.Model):
|
||||
"""
|
||||
Stores system notifications that users can view and dismiss.
|
||||
Used for version updates, recommended settings, announcements, etc.
|
||||
"""
|
||||
class NotificationType(models.TextChoices):
|
||||
VERSION_UPDATE = 'version_update', 'Version Update Available'
|
||||
SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change'
|
||||
ANNOUNCEMENT = 'announcement', 'System Announcement'
|
||||
WARNING = 'warning', 'Warning'
|
||||
INFO = 'info', 'Information'
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = 'low', 'Low'
|
||||
NORMAL = 'normal', 'Normal'
|
||||
HIGH = 'high', 'High'
|
||||
CRITICAL = 'critical', 'Critical'
|
||||
|
||||
class Source(models.TextChoices):
|
||||
SYSTEM = 'system', 'System Generated'
|
||||
DEVELOPER = 'developer', 'Developer Notification'
|
||||
|
||||
# Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer')
|
||||
# This allows deduplication and targeted dismissals
|
||||
notification_key = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=NotificationType.choices,
|
||||
default=NotificationType.INFO,
|
||||
db_index=True
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
choices=Priority.choices,
|
||||
default=Priority.NORMAL
|
||||
)
|
||||
|
||||
# Source of the notification (system-generated vs developer-defined)
|
||||
source = models.CharField(
|
||||
max_length=20,
|
||||
choices=Source.choices,
|
||||
default=Source.SYSTEM,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional action data (e.g., setting key/value for recommendations, release URL for versions)
|
||||
action_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Whether this notification is currently active
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
|
||||
# Admin-only notifications require admin privileges to view
|
||||
admin_only = models.BooleanField(default=False)
|
||||
|
||||
# Auto-expire after this date (null = never expires)
|
||||
expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-priority', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['is_active', '-created_at']),
|
||||
models.Index(fields=['notification_type', 'is_active']),
|
||||
models.Index(fields=['source', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.notification_type}] {self.title}"
|
||||
|
||||
@classmethod
|
||||
def create_version_notification(cls, version, release_url=None, release_notes=None):
|
||||
"""Create or update a version update notification. Returns (notification, created) tuple."""
|
||||
key = f"version-{version}"
|
||||
notification, created = cls.objects.update_or_create(
|
||||
notification_key=key,
|
||||
defaults={
|
||||
'notification_type': cls.NotificationType.VERSION_UPDATE,
|
||||
'priority': cls.Priority.HIGH,
|
||||
'title': f'Version {version} Available',
|
||||
'message': f'A new version of Dispatcharr ({version}) is available.',
|
||||
'action_data': {
|
||||
'version': version,
|
||||
'release_url': release_url,
|
||||
'release_notes': release_notes,
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
return notification, created
|
||||
|
||||
@classmethod
|
||||
def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None):
|
||||
"""Create a setting recommendation notification. Returns (notification, created) tuple."""
|
||||
key = f"setting-{setting_key}"
|
||||
notification, created = cls.objects.update_or_create(
|
||||
notification_key=key,
|
||||
defaults={
|
||||
'notification_type': cls.NotificationType.SETTING_RECOMMENDATION,
|
||||
'priority': cls.Priority.NORMAL,
|
||||
'title': f'Recommended Setting: {setting_key}',
|
||||
'message': reason,
|
||||
'action_data': {
|
||||
'setting_key': setting_key,
|
||||
'recommended_value': recommended_value,
|
||||
'current_value': current_value,
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
return notification, created
|
||||
|
||||
|
||||
class NotificationDismissal(models.Model):
|
||||
"""
|
||||
Tracks which users have dismissed which notifications.
|
||||
Allows users to dismiss notifications once without seeing them again.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dismissed_notifications'
|
||||
)
|
||||
notification = models.ForeignKey(
|
||||
SystemNotification,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dismissals'
|
||||
)
|
||||
dismissed_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Optional: track if user accepted/applied the recommendation
|
||||
action_taken = models.CharField(max_length=50, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'notification']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'notification']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} dismissed {self.notification.notification_key}"
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Redis PubSub utilities for maintaining long-lived connections.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
from redis import Redis
|
||||
from redis.exceptions import ConnectionError, TimeoutError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DummyPubSub:
|
||||
"""Dummy PubSub implementation when Redis isn't available"""
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def subscribe(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def psubscribe(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_message(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
class RedisPubSubManager:
|
||||
"""
|
||||
A robust Redis PubSub manager that handles disconnections and reconnections.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client=None, auto_reconnect=True):
|
||||
"""
|
||||
Initialize the PubSub manager.
|
||||
|
||||
Args:
|
||||
redis_client: An existing Redis client to use
|
||||
auto_reconnect: Whether to automatically reconnect on failure
|
||||
"""
|
||||
self.redis_client = redis_client
|
||||
self.pubsub = None
|
||||
self.subscriptions = set()
|
||||
self.pattern_subscriptions = set()
|
||||
self.auto_reconnect = auto_reconnect
|
||||
self.running = True
|
||||
self.lock = threading.RLock()
|
||||
self.message_handlers = {} # Map of channels to handler functions
|
||||
self.message_thread = None
|
||||
self.is_dummy = redis_client is None
|
||||
|
||||
def subscribe(self, channel, handler=None):
|
||||
"""
|
||||
Subscribe to a channel.
|
||||
|
||||
Args:
|
||||
channel: The channel to subscribe to
|
||||
handler: Optional function to call when messages are received
|
||||
"""
|
||||
if self.is_dummy:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
self.subscriptions.add(channel)
|
||||
if handler:
|
||||
self.message_handlers[channel] = handler
|
||||
|
||||
if self.pubsub:
|
||||
self.pubsub.subscribe(channel)
|
||||
logger.info(f"Subscribed to channel: {channel}")
|
||||
|
||||
def psubscribe(self, pattern, handler=None):
|
||||
"""
|
||||
Subscribe to a channel pattern.
|
||||
|
||||
Args:
|
||||
pattern: The pattern to subscribe to
|
||||
handler: Optional function to call when messages are received
|
||||
"""
|
||||
if self.is_dummy:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
self.pattern_subscriptions.add(pattern)
|
||||
if handler:
|
||||
self.message_handlers[pattern] = handler
|
||||
|
||||
if self.pubsub:
|
||||
self.pubsub.psubscribe(pattern)
|
||||
logger.info(f"Subscribed to pattern: {pattern}")
|
||||
|
||||
def publish(self, channel, message):
|
||||
"""
|
||||
Publish a message to a channel.
|
||||
|
||||
Args:
|
||||
channel: The channel to publish to
|
||||
message: The message to publish (will be JSON-encoded if not a string)
|
||||
|
||||
Returns:
|
||||
Number of clients that received the message
|
||||
"""
|
||||
if self.is_dummy:
|
||||
return 0
|
||||
|
||||
try:
|
||||
if not isinstance(message, str):
|
||||
message = json.dumps(message)
|
||||
return self.redis_client.publish(channel, message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error publishing to {channel}: {e}")
|
||||
return 0
|
||||
|
||||
def start_listening(self):
|
||||
"""
|
||||
Start listening for messages in a background thread.
|
||||
"""
|
||||
if self.is_dummy:
|
||||
logger.debug("Running with dummy Redis client - not starting listener")
|
||||
return
|
||||
|
||||
if not self.message_thread:
|
||||
self._connect()
|
||||
self.message_thread = threading.Thread(
|
||||
target=self._listen_for_messages,
|
||||
daemon=True,
|
||||
name="redis-pubsub-listener"
|
||||
)
|
||||
self.message_thread.start()
|
||||
logger.info("Started Redis PubSub listener thread")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop listening and clean up resources.
|
||||
"""
|
||||
if self.is_dummy:
|
||||
return
|
||||
|
||||
self.running = False
|
||||
if self.pubsub:
|
||||
try:
|
||||
self.pubsub.close()
|
||||
except:
|
||||
pass
|
||||
self.pubsub = None
|
||||
|
||||
def _connect(self):
|
||||
"""
|
||||
Establish a new PubSub connection and subscribe to all channels.
|
||||
"""
|
||||
if self.is_dummy:
|
||||
self.pubsub = DummyPubSub()
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
# Close any existing connection
|
||||
if self.pubsub:
|
||||
try:
|
||||
self.pubsub.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Create a new PubSub instance - critical: no timeout for subscribe operations
|
||||
# This prevents the connection from timing out while waiting for messages
|
||||
self.pubsub = self.redis_client.pubsub()
|
||||
|
||||
# Resubscribe to all channels
|
||||
if self.subscriptions:
|
||||
self.pubsub.subscribe(*self.subscriptions)
|
||||
logger.info(f"Resubscribed to channels: {self.subscriptions}")
|
||||
|
||||
# Resubscribe to all patterns
|
||||
if self.pattern_subscriptions:
|
||||
self.pubsub.psubscribe(*self.pattern_subscriptions)
|
||||
logger.info(f"Resubscribed to patterns: {self.pattern_subscriptions}")
|
||||
|
||||
def _listen_for_messages(self):
|
||||
"""
|
||||
Background thread that listens for messages and handles reconnections.
|
||||
"""
|
||||
if self.is_dummy:
|
||||
return
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Check if we need to connect
|
||||
if not self.pubsub:
|
||||
self._connect()
|
||||
|
||||
# Listen for messages with NO timeout - this is critical!
|
||||
message = self.pubsub.get_message(timeout=None)
|
||||
|
||||
if message:
|
||||
# Don't process subscription confirmation messages
|
||||
if message['type'] in ('subscribe', 'psubscribe'):
|
||||
continue
|
||||
|
||||
channel = message.get('channel')
|
||||
if channel:
|
||||
# Find and call the appropriate handler
|
||||
handler = self.message_handlers.get(channel)
|
||||
if handler:
|
||||
try:
|
||||
handler(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in message handler for {channel}: {e}")
|
||||
|
||||
# Reset error counter on success
|
||||
consecutive_errors = 0
|
||||
|
||||
# Small sleep to prevent excessive CPU usage
|
||||
time.sleep(0.01)
|
||||
|
||||
except (ConnectionError, TimeoutError) as e:
|
||||
consecutive_errors += 1
|
||||
|
||||
if not self.auto_reconnect:
|
||||
logger.error(f"PubSub connection error and auto_reconnect is disabled: {e}")
|
||||
break
|
||||
|
||||
# Exponential backoff for reconnection attempts
|
||||
backoff = min(consecutive_errors * 0.5, 5)
|
||||
logger.warning(f"PubSub connection error, reconnecting in {backoff} seconds: {e}")
|
||||
time.sleep(backoff)
|
||||
|
||||
# Reconnect
|
||||
self._connect()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in PubSub listener: {e}")
|
||||
time.sleep(1) # Prevent tight loop in case of persistent errors
|
||||
|
||||
logger.info("PubSub listener thread stopping")
|
||||
|
||||
# Create a singleton instance
|
||||
pubsub_manager = None
|
||||
|
||||
def get_pubsub_manager(redis_client=None):
|
||||
"""
|
||||
Get or create the PubSub manager singleton.
|
||||
|
||||
Args:
|
||||
redis_client: Optional Redis client to use
|
||||
|
||||
Returns:
|
||||
The PubSub manager instance
|
||||
"""
|
||||
global pubsub_manager
|
||||
|
||||
if pubsub_manager is None:
|
||||
pubsub_manager = RedisPubSubManager(redis_client)
|
||||
# Only start if redis_client is not None
|
||||
if redis_client is not None:
|
||||
try:
|
||||
pubsub_manager.start_listening()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start PubSub listener: {e}")
|
||||
|
||||
return pubsub_manager
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Reusable scheduling utilities for creating/updating/deleting
|
||||
Celery Beat periodic tasks with interval or cron-based schedules.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask
|
||||
|
||||
from core.models import CoreSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_cron_expression(cron_expression):
|
||||
"""
|
||||
Parse a 5-part cron expression into its components.
|
||||
|
||||
Args:
|
||||
cron_expression: A string like "0 3 * * *"
|
||||
|
||||
Returns:
|
||||
dict with keys: minute, hour, day_of_month, month_of_year, day_of_week
|
||||
|
||||
Raises:
|
||||
ValueError: If the expression is not valid 5-part cron.
|
||||
"""
|
||||
parts = cron_expression.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(
|
||||
"Cron expression must have 5 parts: minute hour day month weekday"
|
||||
)
|
||||
return {
|
||||
"minute": parts[0],
|
||||
"hour": parts[1],
|
||||
"day_of_month": parts[2],
|
||||
"month_of_year": parts[3],
|
||||
"day_of_week": parts[4],
|
||||
}
|
||||
|
||||
|
||||
def create_or_update_periodic_task(
|
||||
task_name,
|
||||
celery_task_path,
|
||||
kwargs=None,
|
||||
interval_hours=0,
|
||||
cron_expression="",
|
||||
enabled=True,
|
||||
):
|
||||
"""
|
||||
Create or update a Celery Beat PeriodicTask. Supports both interval
|
||||
(hours) and cron-based scheduling.
|
||||
|
||||
When *cron_expression* is provided and non-empty it takes precedence
|
||||
over *interval_hours*. An interval_hours of 0 (with no cron) means
|
||||
the task is disabled.
|
||||
|
||||
Args:
|
||||
task_name: Unique PeriodicTask name.
|
||||
celery_task_path: Dotted path to the Celery task function.
|
||||
kwargs: dict of keyword arguments passed to the task.
|
||||
interval_hours: Interval in hours (0 = disabled when no cron).
|
||||
cron_expression: 5-part cron string (empty = use interval).
|
||||
enabled: Whether the task should be enabled.
|
||||
|
||||
Returns:
|
||||
The PeriodicTask instance (created or updated).
|
||||
"""
|
||||
task_kwargs = json.dumps(kwargs or {})
|
||||
|
||||
# Determine effective enabled state
|
||||
use_cron = bool(cron_expression and cron_expression.strip())
|
||||
should_be_enabled = enabled and (use_cron or interval_hours > 0)
|
||||
|
||||
# Retrieve existing task (if any) to track old schedule objects
|
||||
old_interval = None
|
||||
old_crontab = None
|
||||
try:
|
||||
existing = PeriodicTask.objects.get(name=task_name)
|
||||
old_interval = existing.interval
|
||||
old_crontab = existing.crontab
|
||||
except PeriodicTask.DoesNotExist:
|
||||
existing = None
|
||||
|
||||
if use_cron:
|
||||
# ---- Cron-based schedule ----
|
||||
cron_parts = parse_cron_expression(cron_expression)
|
||||
system_tz = CoreSettings.get_system_time_zone()
|
||||
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=cron_parts["minute"],
|
||||
hour=cron_parts["hour"],
|
||||
day_of_week=cron_parts["day_of_week"],
|
||||
day_of_month=cron_parts["day_of_month"],
|
||||
month_of_year=cron_parts["month_of_year"],
|
||||
timezone=system_tz,
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"task": celery_task_path,
|
||||
"crontab": crontab,
|
||||
"interval": None,
|
||||
"enabled": should_be_enabled,
|
||||
"kwargs": task_kwargs,
|
||||
}
|
||||
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name, defaults=defaults
|
||||
)
|
||||
|
||||
# Clean up old interval if we switched from interval → cron
|
||||
if old_interval:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
# Clean up old crontab if it changed
|
||||
if old_crontab and old_crontab.id != crontab.id:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
else:
|
||||
# ---- Interval-based schedule ----
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=max(int(interval_hours), 1) if interval_hours else 1,
|
||||
period=IntervalSchedule.HOURS,
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"task": celery_task_path,
|
||||
"interval": interval,
|
||||
"crontab": None,
|
||||
"enabled": should_be_enabled,
|
||||
"kwargs": task_kwargs,
|
||||
}
|
||||
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name, defaults=defaults
|
||||
)
|
||||
|
||||
# Clean up old crontab if we switched from cron → interval
|
||||
if old_crontab:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
# Clean up old interval if it changed
|
||||
if old_interval and old_interval.id != interval.id:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
mode = "cron" if use_cron else "interval"
|
||||
logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})")
|
||||
return task
|
||||
|
||||
|
||||
def delete_periodic_task(task_name):
|
||||
"""
|
||||
Delete a PeriodicTask by name and clean up orphaned schedules.
|
||||
|
||||
Args:
|
||||
task_name: The unique name of the PeriodicTask.
|
||||
|
||||
Returns:
|
||||
True if a task was found and deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
except PeriodicTask.DoesNotExist:
|
||||
logger.warning(f"No PeriodicTask found with name '{task_name}'")
|
||||
return False
|
||||
|
||||
old_interval = task.interval
|
||||
old_crontab = task.crontab
|
||||
task_id = task.id
|
||||
|
||||
task.delete()
|
||||
logger.info(f"Deleted periodic task '{task_name}' (id={task_id})")
|
||||
|
||||
if old_interval:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
if old_crontab:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cleanup_orphaned_interval(interval_schedule):
|
||||
"""Delete an IntervalSchedule if no PeriodicTasks reference it."""
|
||||
if interval_schedule is None:
|
||||
return
|
||||
if PeriodicTask.objects.filter(interval=interval_schedule).exists():
|
||||
return
|
||||
logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}")
|
||||
interval_schedule.delete()
|
||||
|
||||
|
||||
def _cleanup_orphaned_crontab(crontab_schedule):
|
||||
"""Delete a CrontabSchedule if no PeriodicTasks reference it."""
|
||||
if crontab_schedule is None:
|
||||
return
|
||||
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
|
||||
return
|
||||
logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}")
|
||||
crontab_schedule.delete()
|
||||
@@ -0,0 +1,166 @@
|
||||
# core/serializers.py
|
||||
import json
|
||||
import ipaddress
|
||||
|
||||
from rest_framework import serializers
|
||||
from .models import CoreSettings, UserAgent, StreamProfile, DVR_SETTINGS_KEY, NETWORK_ACCESS_KEY
|
||||
|
||||
|
||||
class UserAgentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserAgent
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"user_agent",
|
||||
"description",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class StreamProfileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = StreamProfile
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"command",
|
||||
"parameters",
|
||||
"is_active",
|
||||
"user_agent",
|
||||
"locked",
|
||||
]
|
||||
|
||||
|
||||
class CoreSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CoreSettings
|
||||
fields = "__all__"
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if instance.key == NETWORK_ACCESS_KEY:
|
||||
errors = False
|
||||
invalid = {}
|
||||
value = validated_data.get("value")
|
||||
for key, val in value.items():
|
||||
cidrs = val.split(",")
|
||||
for cidr in cidrs:
|
||||
try:
|
||||
ipaddress.ip_network(cidr)
|
||||
except:
|
||||
errors = True
|
||||
if key not in invalid:
|
||||
invalid[key] = []
|
||||
invalid[key].append(cidr)
|
||||
|
||||
if errors:
|
||||
# Perform CIDR validation
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"message": "Invalid CIDRs",
|
||||
"value": invalid,
|
||||
}
|
||||
)
|
||||
|
||||
# Sanitize series_rules when DVR settings are saved through the
|
||||
# generic settings API (e.g. Settings page round-trip) to prevent
|
||||
# corrupted non-dict entries from persisting.
|
||||
if instance.key == DVR_SETTINGS_KEY:
|
||||
value = validated_data.get("value")
|
||||
if isinstance(value, dict) and "series_rules" in value:
|
||||
rules = value["series_rules"]
|
||||
value["series_rules"] = (
|
||||
[r for r in rules if isinstance(r, dict)]
|
||||
if isinstance(rules, list)
|
||||
else []
|
||||
)
|
||||
|
||||
result = super().update(instance, validated_data)
|
||||
|
||||
# Note: Cache invalidation and notification sync is handled by post_save signal
|
||||
# in core/signals.py to ensure it happens even if settings are updated elsewhere
|
||||
|
||||
return result
|
||||
|
||||
class ProxySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for proxy settings stored as JSON in CoreSettings"""
|
||||
buffering_timeout = serializers.IntegerField(min_value=0, max_value=300)
|
||||
buffering_speed = serializers.FloatField(min_value=0.1, max_value=10.0)
|
||||
redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600)
|
||||
channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300)
|
||||
channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60)
|
||||
new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5)
|
||||
|
||||
def validate_buffering_timeout(self, value):
|
||||
if value < 0 or value > 300:
|
||||
raise serializers.ValidationError("Buffering timeout must be between 0 and 300 seconds")
|
||||
return value
|
||||
|
||||
def validate_buffering_speed(self, value):
|
||||
if value < 0.1 or value > 10.0:
|
||||
raise serializers.ValidationError("Buffering speed must be between 0.1 and 10.0")
|
||||
return value
|
||||
|
||||
def validate_redis_chunk_ttl(self, value):
|
||||
if value < 10 or value > 3600:
|
||||
raise serializers.ValidationError("Redis chunk TTL must be between 10 and 3600 seconds")
|
||||
return value
|
||||
|
||||
def validate_channel_shutdown_delay(self, value):
|
||||
if value < 0 or value > 300:
|
||||
raise serializers.ValidationError("Channel shutdown delay must be between 0 and 300 seconds")
|
||||
return value
|
||||
|
||||
def validate_channel_init_grace_period(self, value):
|
||||
if value < 0 or value > 60:
|
||||
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
|
||||
return value
|
||||
|
||||
def validate_new_client_behind_seconds(self, value):
|
||||
if value < 0 or value > 120:
|
||||
raise serializers.ValidationError("New client buffer must be between 0 and 120 seconds")
|
||||
return value
|
||||
|
||||
|
||||
class SystemNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for system notifications."""
|
||||
is_dismissed = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
from .models import SystemNotification
|
||||
model = SystemNotification
|
||||
fields = [
|
||||
'id',
|
||||
'notification_key',
|
||||
'notification_type',
|
||||
'priority',
|
||||
'title',
|
||||
'message',
|
||||
'action_data',
|
||||
'is_active',
|
||||
'admin_only',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'is_dismissed',
|
||||
'source',
|
||||
]
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
def get_is_dismissed(self, obj):
|
||||
"""Check if the current user has dismissed this notification."""
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
return obj.dismissals.filter(user=request.user).exists()
|
||||
return False
|
||||
|
||||
|
||||
class NotificationDismissalSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification dismissals."""
|
||||
|
||||
class Meta:
|
||||
from .models import NotificationDismissal
|
||||
model = NotificationDismissal
|
||||
fields = ['id', 'notification', 'dismissed_at', 'action_taken']
|
||||
read_only_fields = ['dismissed_at']
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
@receiver(pre_delete, sender=StreamProfile)
|
||||
def prevent_deletion_if_locked(sender, instance, **kwargs):
|
||||
if instance.locked:
|
||||
raise ValidationError("This profile is locked and cannot be deleted.")
|
||||
|
||||
@receiver(post_save, sender=CoreSettings)
|
||||
def handle_network_access_update(sender, instance, **kwargs):
|
||||
"""Invalidate cache and sync notifications when network access settings change."""
|
||||
if instance.key == NETWORK_ACCESS_KEY:
|
||||
from django.core.cache import cache
|
||||
from core.developer_notifications import sync_developer_notifications
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Invalidate all notification condition caches
|
||||
try:
|
||||
cache.delete_pattern('dev_notif_condition_*')
|
||||
logger.info("Invalidated notification condition cache due to network access settings update")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete cache pattern: {e}")
|
||||
# Fallback: try to clear entire cache (if delete_pattern not supported)
|
||||
try:
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-sync developer notifications to re-evaluate conditions
|
||||
# (websocket notification is sent by sync_developer_notifications)
|
||||
try:
|
||||
sync_developer_notifications()
|
||||
logger.info("Re-synced developer notifications after network access settings update")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync developer notifications: {e}")
|
||||
+992
@@ -0,0 +1,992 @@
|
||||
from celery import shared_task
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
from core.utils import RedisClient, send_websocket_update, acquire_task_lock, release_task_lock
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.epg.models import EPGSource
|
||||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
from apps.epg.tasks import refresh_epg_data
|
||||
from .models import CoreSettings
|
||||
from apps.channels.models import ChannelStream
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EPG_WATCH_DIR = '/data/epgs'
|
||||
M3U_WATCH_DIR = '/data/m3us'
|
||||
LOGO_WATCH_DIR = '/data/logos'
|
||||
MIN_AGE_SECONDS = 6
|
||||
STARTUP_SKIP_AGE = 30
|
||||
REDIS_PREFIX = "processed_file:"
|
||||
REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional)
|
||||
SUPPORTED_LOGO_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
|
||||
|
||||
# Store the last known value to compare with new data
|
||||
last_known_data = {}
|
||||
# Store when we last logged certain recurring messages
|
||||
_last_log_times = {}
|
||||
# Don't repeat similar log messages more often than this (in seconds)
|
||||
LOG_THROTTLE_SECONDS = 300 # 5 minutes
|
||||
# Track if this is the first scan since startup
|
||||
_first_scan_completed = False
|
||||
|
||||
def throttled_log(logger_method, message, key=None, *args, **kwargs):
|
||||
"""Only log messages with the same key once per throttle period"""
|
||||
if key is None:
|
||||
# Use message as key if no explicit key provided
|
||||
key = message
|
||||
|
||||
now = time.time()
|
||||
if key not in _last_log_times or (now - _last_log_times[key]) >= LOG_THROTTLE_SECONDS:
|
||||
logger_method(message, *args, **kwargs)
|
||||
_last_log_times[key] = now
|
||||
|
||||
@shared_task
|
||||
def beat_periodic_task():
|
||||
fetch_channel_stats()
|
||||
scan_and_process_files()
|
||||
|
||||
@shared_task
|
||||
def scan_and_process_files():
|
||||
global _first_scan_completed
|
||||
redis_client = RedisClient.get_client()
|
||||
now = time.time()
|
||||
|
||||
# Check if directories exist
|
||||
dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR, LOGO_WATCH_DIR])
|
||||
if not dirs_exist:
|
||||
throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)}), LOGO ({os.path.exists(LOGO_WATCH_DIR)})", "watch_dirs_missing")
|
||||
|
||||
# Process M3U files
|
||||
m3u_files = [f for f in os.listdir(M3U_WATCH_DIR)
|
||||
if os.path.isfile(os.path.join(M3U_WATCH_DIR, f)) and
|
||||
(f.endswith('.m3u') or f.endswith('.m3u8'))]
|
||||
|
||||
m3u_processed = 0
|
||||
m3u_skipped = 0
|
||||
|
||||
for filename in m3u_files:
|
||||
filepath = os.path.join(M3U_WATCH_DIR, filename)
|
||||
mtime = os.path.getmtime(filepath)
|
||||
age = now - mtime
|
||||
redis_key = REDIS_PREFIX + filepath
|
||||
stored_mtime = redis_client.get(redis_key)
|
||||
|
||||
# Instead of assuming old files were processed, check if they exist in the database
|
||||
if not stored_mtime and age > STARTUP_SKIP_AGE:
|
||||
# Check if this file is already in the database
|
||||
existing_m3u = M3UAccount.objects.filter(file_path=filepath).exists()
|
||||
if existing_m3u:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already exists in database")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already exists in database")
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
m3u_skipped += 1
|
||||
continue
|
||||
else:
|
||||
logger.debug(f"Processing {filename} despite age: Not found in database")
|
||||
# Continue processing this file even though it's old
|
||||
|
||||
# File too new — probably still being written
|
||||
if age < MIN_AGE_SECONDS:
|
||||
logger.debug(f"Skipping {filename}: Too new (age={age}s)")
|
||||
m3u_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip if we've already processed this mtime
|
||||
if stored_mtime and float(stored_mtime) >= mtime:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already processed this version")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already processed this version")
|
||||
m3u_skipped += 1
|
||||
continue
|
||||
|
||||
m3u_account, created = M3UAccount.objects.get_or_create(file_path=filepath, defaults={
|
||||
"name": filename,
|
||||
"is_active": CoreSettings.get_auto_import_mapped_files() in [True, "true", "True"],
|
||||
})
|
||||
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
|
||||
# More descriptive creation logging that includes active status
|
||||
if created:
|
||||
if m3u_account.is_active:
|
||||
logger.info(f"Created new M3U account '{filename}' (active)")
|
||||
else:
|
||||
logger.info(f"Created new M3U account '{filename}' (inactive due to auto-import setting)")
|
||||
|
||||
if not m3u_account.is_active:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: M3U account is inactive")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: M3U account is inactive")
|
||||
m3u_skipped += 1
|
||||
continue
|
||||
|
||||
# Log update for existing files (we've already logged creation above)
|
||||
if not created:
|
||||
logger.info(f"Detected update to existing M3U file: {filename}")
|
||||
|
||||
logger.info(f"Queueing refresh for M3U file: {filename}")
|
||||
refresh_single_m3u_account.delay(m3u_account.id)
|
||||
m3u_processed += 1
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"updates",
|
||||
{
|
||||
"type": "update",
|
||||
"data": {"success": True, "type": "m3u_file", "filename": filename}
|
||||
},
|
||||
)
|
||||
|
||||
logger.trace(f"M3U processing complete: {m3u_processed} processed, {m3u_skipped} skipped, {len(m3u_files)} total")
|
||||
|
||||
# Process EPG files
|
||||
try:
|
||||
epg_files = os.listdir(EPG_WATCH_DIR)
|
||||
logger.trace(f"Found {len(epg_files)} files in EPG directory")
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing EPG directory: {e}")
|
||||
epg_files = []
|
||||
|
||||
epg_processed = 0
|
||||
epg_skipped = 0
|
||||
epg_errors = 0
|
||||
|
||||
for filename in epg_files:
|
||||
filepath = os.path.join(EPG_WATCH_DIR, filename)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Not a file")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Not a file")
|
||||
epg_skipped += 1
|
||||
continue
|
||||
|
||||
if not filename.endswith('.xml') and not filename.endswith('.gz') and not filename.endswith('.zip'):
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Not an XML, GZ or zip file")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Not an XML, GZ or zip file")
|
||||
epg_skipped += 1
|
||||
continue
|
||||
|
||||
mtime = os.path.getmtime(filepath)
|
||||
age = now - mtime
|
||||
redis_key = REDIS_PREFIX + filepath
|
||||
stored_mtime = redis_client.get(redis_key)
|
||||
|
||||
# Instead of assuming old files were processed, check if they exist in the database
|
||||
if not stored_mtime and age > STARTUP_SKIP_AGE:
|
||||
# Check if this file is already in the database
|
||||
existing_epg = EPGSource.objects.filter(file_path=filepath).exists()
|
||||
if existing_epg:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already exists in database")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already exists in database")
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
epg_skipped += 1
|
||||
continue
|
||||
else:
|
||||
logger.debug(f"Processing {filename} despite age: Not found in database")
|
||||
# Continue processing this file even though it's old
|
||||
|
||||
# File too new — probably still being written
|
||||
if age < MIN_AGE_SECONDS:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
|
||||
epg_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip if we've already processed this mtime
|
||||
if stored_mtime and float(stored_mtime) >= mtime:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already processed this version")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already processed this version")
|
||||
epg_skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
epg_source, created = EPGSource.objects.get_or_create(file_path=filepath, defaults={
|
||||
"name": filename,
|
||||
"source_type": "xmltv",
|
||||
"is_active": CoreSettings.get_auto_import_mapped_files() in [True, "true", "True"],
|
||||
})
|
||||
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
|
||||
# More descriptive creation logging that includes active status
|
||||
if created:
|
||||
if epg_source.is_active:
|
||||
logger.info(f"Created new EPG source '{filename}' (active)")
|
||||
else:
|
||||
logger.info(f"Created new EPG source '{filename}' (inactive due to auto-import setting)")
|
||||
|
||||
if not epg_source.is_active:
|
||||
# Use trace level if not first scan
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: EPG source is marked as inactive")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: EPG source is marked as inactive")
|
||||
epg_skipped += 1
|
||||
continue
|
||||
|
||||
# Log update for existing files (we've already logged creation above)
|
||||
if not created:
|
||||
logger.info(f"Detected update to existing EPG file: {filename}")
|
||||
|
||||
logger.info(f"Queueing refresh for EPG file: {filename}")
|
||||
refresh_epg_data.delay(epg_source.id) # Trigger Celery task
|
||||
epg_processed += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing EPG file {filename}: {str(e)}", exc_info=True)
|
||||
epg_errors += 1
|
||||
continue
|
||||
|
||||
logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors")
|
||||
|
||||
# Process Logo files (including subdirectories)
|
||||
try:
|
||||
logo_files = []
|
||||
if os.path.exists(LOGO_WATCH_DIR):
|
||||
for root, dirs, files in os.walk(LOGO_WATCH_DIR):
|
||||
for filename in files:
|
||||
logo_files.append(os.path.join(root, filename))
|
||||
logger.trace(f"Found {len(logo_files)} files in LOGO directory (including subdirectories)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing LOGO directory: {e}")
|
||||
logo_files = []
|
||||
|
||||
logo_processed = 0
|
||||
logo_skipped = 0
|
||||
logo_errors = 0
|
||||
|
||||
for filepath in logo_files:
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Not a file")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Not a file")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
# Check if file has supported logo extension
|
||||
file_ext = os.path.splitext(filename)[1].lower()
|
||||
if file_ext not in SUPPORTED_LOGO_FORMATS:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Not a supported logo format")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Not a supported logo format")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
mtime = os.path.getmtime(filepath)
|
||||
age = now - mtime
|
||||
redis_key = REDIS_PREFIX + filepath
|
||||
stored_mtime = redis_client.get(redis_key)
|
||||
|
||||
# Check if logo already exists in database
|
||||
if not stored_mtime and age > STARTUP_SKIP_AGE:
|
||||
from apps.channels.models import Logo
|
||||
existing_logo = Logo.objects.filter(url=filepath).exists()
|
||||
if existing_logo:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already exists in database")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already exists in database")
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
logo_skipped += 1
|
||||
continue
|
||||
else:
|
||||
logger.debug(f"Processing {filename} despite age: Not found in database")
|
||||
|
||||
# File too new — probably still being written
|
||||
if age < MIN_AGE_SECONDS:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip if we've already processed this mtime
|
||||
if stored_mtime and float(stored_mtime) >= mtime:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already processed this version")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already processed this version")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
from apps.channels.models import Logo
|
||||
|
||||
# Create logo entry with just the filename (without extension) as name
|
||||
logo_name = os.path.splitext(filename)[0]
|
||||
|
||||
logo, created = Logo.objects.get_or_create(
|
||||
url=filepath,
|
||||
defaults={
|
||||
"name": logo_name,
|
||||
}
|
||||
)
|
||||
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
|
||||
if created:
|
||||
logger.info(f"Created new logo entry: {logo_name}")
|
||||
else:
|
||||
logger.debug(f"Logo entry already exists: {logo_name}")
|
||||
|
||||
logo_processed += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True)
|
||||
logo_errors += 1
|
||||
continue
|
||||
|
||||
logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors")
|
||||
|
||||
# Send summary websocket update for logo processing
|
||||
if logo_processed > 0 or logo_errors > 0:
|
||||
send_websocket_update(
|
||||
"updates",
|
||||
"update",
|
||||
{
|
||||
"success": True,
|
||||
"type": "logo_processing_summary",
|
||||
"processed": logo_processed,
|
||||
"skipped": logo_skipped,
|
||||
"errors": logo_errors,
|
||||
"total_files": len(logo_files),
|
||||
"message": f"Logo processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors"
|
||||
}
|
||||
)
|
||||
|
||||
# Mark that the first scan is complete
|
||||
_first_scan_completed = True
|
||||
|
||||
def fetch_channel_stats():
|
||||
redis_client = RedisClient.get_client()
|
||||
|
||||
try:
|
||||
# Basic info for all channels
|
||||
channel_pattern = "ts_proxy:channel:*:metadata"
|
||||
all_channels = []
|
||||
|
||||
# Extract channel IDs from keys
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = redis_client.scan(cursor, match=channel_pattern)
|
||||
for key in keys:
|
||||
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key)
|
||||
if channel_id_match:
|
||||
ch_id = channel_id_match.group(1)
|
||||
channel_info = ChannelStatus.get_basic_channel_info(ch_id)
|
||||
if channel_info:
|
||||
all_channels.append(channel_info)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
send_websocket_update(
|
||||
"updates",
|
||||
"update",
|
||||
{
|
||||
"success": True,
|
||||
"type": "channel_stats",
|
||||
"stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
|
||||
},
|
||||
collect_garbage=True
|
||||
)
|
||||
|
||||
# Explicitly clean up large data structures
|
||||
all_channels = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in channel_status: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
@shared_task
|
||||
def rehash_streams(keys):
|
||||
"""
|
||||
Regenerate stream hashes for all streams based on current hash key configuration.
|
||||
This task checks for and blocks M3U refresh tasks to prevent conflicts.
|
||||
"""
|
||||
from apps.channels.models import Stream
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
logger.info("Starting stream rehash process")
|
||||
|
||||
# Get all M3U account IDs for locking
|
||||
m3u_account_ids = list(M3UAccount.objects.filter(is_active=True).values_list('id', flat=True))
|
||||
|
||||
# Check if any M3U refresh tasks are currently running
|
||||
blocked_accounts = []
|
||||
for account_id in m3u_account_ids:
|
||||
if not acquire_task_lock('refresh_single_m3u_account', account_id):
|
||||
blocked_accounts.append(account_id)
|
||||
|
||||
if blocked_accounts:
|
||||
# Release any locks we did acquire
|
||||
for account_id in m3u_account_ids:
|
||||
if account_id not in blocked_accounts:
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
|
||||
logger.warning(f"Rehash blocked: M3U refresh tasks running for accounts: {blocked_accounts}")
|
||||
|
||||
# Send WebSocket notification to inform user
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": False,
|
||||
"type": "stream_rehash",
|
||||
"action": "blocked",
|
||||
"blocked_accounts": len(blocked_accounts),
|
||||
"total_accounts": len(m3u_account_ids),
|
||||
"message": f"Stream rehash blocked: M3U refresh tasks are currently running for {len(blocked_accounts)} accounts. Please try again later."
|
||||
}
|
||||
)
|
||||
|
||||
return f"Rehash blocked: M3U refresh tasks running for {len(blocked_accounts)} accounts"
|
||||
|
||||
acquired_locks = m3u_account_ids.copy()
|
||||
|
||||
try:
|
||||
batch_size = 1000
|
||||
|
||||
# Track statistics
|
||||
total_processed = 0
|
||||
duplicates_merged = 0
|
||||
# hash_keys maps new_hash -> stream_id for streams we've already processed
|
||||
hash_keys = {}
|
||||
# Track IDs of streams that have been deleted to avoid stale references
|
||||
deleted_stream_ids = set()
|
||||
|
||||
# Get initial count for progress reporting
|
||||
initial_total_records = Stream.objects.count()
|
||||
logger.info(f"Starting rehash of {initial_total_records} streams with keys: {keys}")
|
||||
|
||||
# Send initial WebSocket update
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": True,
|
||||
"type": "stream_rehash",
|
||||
"action": "starting",
|
||||
"progress": 0,
|
||||
"total_records": initial_total_records,
|
||||
"message": f"Starting rehash of {initial_total_records} streams"
|
||||
}
|
||||
)
|
||||
|
||||
# Use ID-based pagination to handle deletions correctly
|
||||
# This ensures we don't skip records when items are deleted
|
||||
last_processed_id = 0
|
||||
batch_number = 0
|
||||
|
||||
while True:
|
||||
batch_number += 1
|
||||
batch_processed = 0
|
||||
batch_duplicates = 0
|
||||
|
||||
with transaction.atomic():
|
||||
# Fetch batch by ID ordering, using select_for_update to lock records
|
||||
# This prevents race conditions and ensures we process each record exactly once
|
||||
batch = list(
|
||||
Stream.objects.filter(id__gt=last_processed_id)
|
||||
.select_for_update(skip_locked=True, of=('self',))
|
||||
.select_related('channel_group', 'm3u_account')
|
||||
.order_by('id')[:batch_size]
|
||||
)
|
||||
|
||||
if not batch:
|
||||
# No more records to process
|
||||
break
|
||||
|
||||
for obj in batch:
|
||||
# Update the last processed ID for next batch
|
||||
last_processed_id = obj.id
|
||||
|
||||
# Generate new hash - handle XC accounts differently
|
||||
group_name = obj.channel_group.name if obj.channel_group else None
|
||||
account_type = obj.m3u_account.account_type if obj.m3u_account else None
|
||||
stream_id_val = obj.stream_id if hasattr(obj, 'stream_id') else None
|
||||
|
||||
new_hash = Stream.generate_hash_key(
|
||||
obj.name, obj.url, obj.tvg_id, keys,
|
||||
m3u_id=obj.m3u_account_id, group=group_name,
|
||||
account_type=account_type, stream_id=stream_id_val
|
||||
)
|
||||
|
||||
# Check if this hash already exists in our tracking dict
|
||||
if new_hash in hash_keys:
|
||||
existing_stream_id = hash_keys[new_hash]
|
||||
|
||||
# Verify the target stream still exists and hasn't been deleted
|
||||
if existing_stream_id in deleted_stream_ids:
|
||||
# The target was deleted, so this stream becomes the new canonical one
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
batch_processed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
existing_stream = Stream.objects.get(id=existing_stream_id)
|
||||
except Stream.DoesNotExist:
|
||||
# Target stream was deleted externally, make this the canonical one
|
||||
deleted_stream_ids.add(existing_stream_id)
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
batch_processed += 1
|
||||
continue
|
||||
|
||||
# Determine which stream to keep based on channel ordering
|
||||
stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj)
|
||||
|
||||
# Move channel relationships from the stream being deleted to the one being kept
|
||||
_merge_stream_relationships(stream_to_delete, stream_to_keep)
|
||||
|
||||
# Delete the duplicate FIRST to free up the unique hash constraint
|
||||
deleted_stream_ids.add(stream_to_delete.id)
|
||||
stream_to_delete.delete()
|
||||
batch_duplicates += 1
|
||||
|
||||
# Now safely set the hash on the kept stream (after deletion freed it up)
|
||||
if stream_to_keep.stream_hash != new_hash:
|
||||
stream_to_keep.stream_hash = new_hash
|
||||
stream_to_keep.save(update_fields=['stream_hash'])
|
||||
|
||||
# Update hash_keys to point to the kept stream
|
||||
hash_keys[new_hash] = stream_to_keep.id
|
||||
else:
|
||||
# Check if hash already exists in database (from streams not yet processed)
|
||||
existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first()
|
||||
if existing_stream:
|
||||
# Found duplicate in database - determine which to keep based on channel ordering
|
||||
stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj)
|
||||
|
||||
# Move channel relationships from the stream being deleted to the one being kept
|
||||
_merge_stream_relationships(stream_to_delete, stream_to_keep)
|
||||
|
||||
# Delete the duplicate FIRST to free up the unique hash constraint
|
||||
deleted_stream_ids.add(stream_to_delete.id)
|
||||
stream_to_delete.delete()
|
||||
batch_duplicates += 1
|
||||
|
||||
# Now safely set the hash on the kept stream (after deletion freed it up)
|
||||
if stream_to_keep.stream_hash != new_hash:
|
||||
stream_to_keep.stream_hash = new_hash
|
||||
stream_to_keep.save(update_fields=['stream_hash'])
|
||||
|
||||
hash_keys[new_hash] = stream_to_keep.id
|
||||
else:
|
||||
# No duplicate - update hash for this stream
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
|
||||
batch_processed += 1
|
||||
|
||||
total_processed += batch_processed
|
||||
duplicates_merged += batch_duplicates
|
||||
|
||||
# Calculate progress percentage based on initial count
|
||||
# Cap at 99% until we're actually done to avoid showing 100% prematurely
|
||||
progress_percent = min(99, int((total_processed / max(initial_total_records, 1)) * 100))
|
||||
|
||||
# Send progress update via WebSocket
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": True,
|
||||
"type": "stream_rehash",
|
||||
"action": "processing",
|
||||
"progress": progress_percent,
|
||||
"batch": batch_number,
|
||||
"processed": total_processed,
|
||||
"duplicates_merged": duplicates_merged,
|
||||
"message": f"Processed batch {batch_number}: {batch_processed} streams, {batch_duplicates} duplicates merged"
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Rehashed batch {batch_number}: "
|
||||
f"{batch_processed} processed, {batch_duplicates} duplicates merged")
|
||||
|
||||
logger.info(f"Rehashing complete: {total_processed} streams processed, "
|
||||
f"{duplicates_merged} duplicates merged")
|
||||
|
||||
# Send completion update via WebSocket
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": True,
|
||||
"type": "stream_rehash",
|
||||
"action": "completed",
|
||||
"progress": 100,
|
||||
"total_processed": total_processed,
|
||||
"duplicates_merged": duplicates_merged,
|
||||
"final_count": total_processed - duplicates_merged,
|
||||
"message": f"Rehashing complete: {total_processed} streams processed, {duplicates_merged} duplicates merged"
|
||||
},
|
||||
collect_garbage=True # Force garbage collection after completion
|
||||
)
|
||||
|
||||
logger.info("Stream rehash completed successfully")
|
||||
return f"Successfully rehashed {total_processed} streams"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during stream rehash: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
# Always release all acquired M3U locks
|
||||
for account_id in acquired_locks:
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts")
|
||||
|
||||
|
||||
def _merge_stream_relationships(source_stream, target_stream):
|
||||
"""
|
||||
Move channel relationships from source_stream to target_stream.
|
||||
Handles unique constraint violations by preserving existing relationships.
|
||||
Preserves the best ordering when merging relationships.
|
||||
"""
|
||||
for channel_stream in ChannelStream.objects.filter(stream_id=source_stream.id):
|
||||
# Check if this channel already has a relationship with the target stream
|
||||
existing_relationship = ChannelStream.objects.filter(
|
||||
channel_id=channel_stream.channel_id,
|
||||
stream_id=target_stream.id
|
||||
).first()
|
||||
|
||||
if existing_relationship:
|
||||
# Relationship already exists - keep the one with better ordering (lower order value)
|
||||
if channel_stream.order < existing_relationship.order:
|
||||
existing_relationship.order = channel_stream.order
|
||||
existing_relationship.save(update_fields=['order'])
|
||||
# Delete the duplicate relationship
|
||||
channel_stream.delete()
|
||||
else:
|
||||
# Safe to update the relationship
|
||||
channel_stream.stream_id = target_stream.id
|
||||
channel_stream.save()
|
||||
|
||||
|
||||
def _get_best_channel_order(stream):
|
||||
"""
|
||||
Get the best (lowest) channel order for a stream.
|
||||
Returns None if stream has no channel relationships.
|
||||
Lower order value = better/higher position in the channel list.
|
||||
"""
|
||||
best_order = ChannelStream.objects.filter(stream_id=stream.id).order_by('order').values_list('order', flat=True).first()
|
||||
return best_order
|
||||
|
||||
|
||||
def _determine_stream_to_keep(stream_a, stream_b):
|
||||
"""
|
||||
Determine which stream should be kept when merging duplicates.
|
||||
|
||||
Priority:
|
||||
1. Stream with better (lower) channel order wins
|
||||
2. If both have same order or neither has channel relationships,
|
||||
keep the one with more recent updated_at
|
||||
3. If still tied, keep the one with the lower ID (more stable)
|
||||
|
||||
Returns: (stream_to_keep, stream_to_delete)
|
||||
"""
|
||||
order_a = _get_best_channel_order(stream_a)
|
||||
order_b = _get_best_channel_order(stream_b)
|
||||
|
||||
# If one has channel relationships and the other doesn't, keep the one with relationships
|
||||
if order_a is not None and order_b is None:
|
||||
return (stream_a, stream_b)
|
||||
if order_b is not None and order_a is None:
|
||||
return (stream_b, stream_a)
|
||||
|
||||
# If both have channel relationships, keep the one with better (lower) order
|
||||
if order_a is not None and order_b is not None:
|
||||
if order_a < order_b:
|
||||
return (stream_a, stream_b)
|
||||
elif order_b < order_a:
|
||||
return (stream_b, stream_a)
|
||||
# Same order, fall through to other criteria
|
||||
|
||||
# Neither has relationships, or same order - use updated_at
|
||||
if stream_a.updated_at > stream_b.updated_at:
|
||||
return (stream_a, stream_b)
|
||||
elif stream_b.updated_at > stream_a.updated_at:
|
||||
return (stream_b, stream_a)
|
||||
|
||||
# Same updated_at - keep lower ID for stability
|
||||
if stream_a.id < stream_b.id:
|
||||
return (stream_a, stream_b)
|
||||
return (stream_b, stream_a)
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_for_version_update():
|
||||
"""
|
||||
Check for new Dispatcharr versions on GitHub and create a notification if available.
|
||||
This task should be run periodically (e.g., daily) via Celery Beat.
|
||||
|
||||
For dev builds (identified by __timestamp__), checks for stable releases only.
|
||||
For production builds, checks for stable releases.
|
||||
|
||||
Note: Dev builds are container images from the dev branch and don't have GitHub releases.
|
||||
This checks if a stable release is available so dev users know when to upgrade.
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
from packaging import version as pkg_version
|
||||
from version import __version__, __timestamp__
|
||||
from core.models import SystemNotification
|
||||
from core.utils import send_websocket_notification
|
||||
|
||||
try:
|
||||
is_dev_build = __timestamp__ is not None
|
||||
DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'}
|
||||
|
||||
if is_dev_build:
|
||||
# Check Docker Hub for newer dev builds
|
||||
docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev"
|
||||
|
||||
response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}")
|
||||
return
|
||||
|
||||
dev_tag_data = response.json()
|
||||
docker_last_updated = dev_tag_data.get("last_updated")
|
||||
|
||||
if not docker_last_updated:
|
||||
logger.warning("No last_updated timestamp found in Docker Hub response")
|
||||
return
|
||||
|
||||
# Parse timestamps for comparison
|
||||
local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
|
||||
docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00'))
|
||||
|
||||
# Calculate difference in minutes
|
||||
diff_minutes = (docker_dt - local_dt).total_seconds() / 60
|
||||
|
||||
# Threshold to account for build/push time differences
|
||||
THRESHOLD_MINUTES = 10
|
||||
|
||||
if diff_minutes > THRESHOLD_MINUTES:
|
||||
logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)")
|
||||
|
||||
# Delete any old version update notifications (both dev and stable, in case user switched)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleted {deleted_count} old dev build notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
# Create notification for new dev build
|
||||
notification, created = SystemNotification.objects.get_or_create(
|
||||
notification_key=f'version-dev-{docker_last_updated}',
|
||||
defaults={
|
||||
'notification_type': 'version_update',
|
||||
'title': 'New Dev Build Available',
|
||||
'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)',
|
||||
'priority': 'medium',
|
||||
'action_data': {
|
||||
'current_version': __version__,
|
||||
'current_timestamp': __timestamp__,
|
||||
'docker_updated': docker_last_updated,
|
||||
'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags'
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
# Only send WebSocket for newly created notifications
|
||||
send_websocket_notification(notification)
|
||||
logger.info(f"New dev build notification created and sent via WebSocket")
|
||||
else:
|
||||
logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})")
|
||||
|
||||
# Delete all version update notifications when up to date (both dev and stable)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted {deleted_count} outdated dev build notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Production build - check GitHub for stable releases.
|
||||
# Delete any stale notification for the currently running version upfront;
|
||||
# a "vX is available" notification is meaningless once the user is already on vX.
|
||||
# Notify the frontend immediately so the badge clears without waiting for the API call.
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_key=f"version-{__version__}",
|
||||
notification_type='version_update',
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{'success': True, 'type': 'notifications_cleared', 'count': deleted_count}
|
||||
)
|
||||
|
||||
github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
|
||||
headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS}
|
||||
response = requests.get(
|
||||
github_api_url,
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Failed to check for updates: HTTP {response.status_code}")
|
||||
return
|
||||
|
||||
release_data = response.json()
|
||||
latest_version = release_data.get("tag_name", "").lstrip("v")
|
||||
release_url = release_data.get("html_url", "")
|
||||
|
||||
if not latest_version:
|
||||
logger.warning("No version tag found in GitHub release")
|
||||
return
|
||||
|
||||
# Compare versions
|
||||
current = pkg_version.parse(__version__)
|
||||
latest = pkg_version.parse(latest_version)
|
||||
if latest > current:
|
||||
logger.info(f"New stable version available: {latest_version} (current: {__version__})")
|
||||
|
||||
# Delete any old version update notifications (superseded by this one)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).exclude(
|
||||
notification_key=f"version-{latest_version}"
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleted {deleted_count} old version notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
# Create or update the notification for the new version
|
||||
notification, created = SystemNotification.create_version_notification(
|
||||
version=latest_version,
|
||||
release_url=release_url,
|
||||
)
|
||||
|
||||
if created:
|
||||
# Only send WebSocket for newly created notifications
|
||||
send_websocket_notification(notification)
|
||||
logger.info(f"New version notification created and sent via WebSocket")
|
||||
else:
|
||||
logger.debug(f"Dispatcharr is up to date (v{__version__})")
|
||||
|
||||
# Delete ALL version update notifications when up to date (no longer needed)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted {deleted_count} outdated version notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Network error checking for updates: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for version updates: {e}")
|
||||
|
||||
|
||||
def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None):
|
||||
"""
|
||||
Create a setting recommendation notification.
|
||||
This is a helper function that can be called from anywhere in the codebase.
|
||||
|
||||
Args:
|
||||
setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout')
|
||||
recommended_value: The recommended value for the setting
|
||||
reason: Why this setting is recommended
|
||||
current_value: The current value (optional)
|
||||
|
||||
Returns:
|
||||
The created SystemNotification instance
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
from core.utils import send_websocket_notification
|
||||
|
||||
notification, created = SystemNotification.create_setting_recommendation(
|
||||
setting_key=setting_key,
|
||||
recommended_value=recommended_value,
|
||||
reason=reason,
|
||||
current_value=current_value
|
||||
)
|
||||
|
||||
# Only send via WebSocket for newly created notifications
|
||||
if created:
|
||||
send_websocket_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY
|
||||
|
||||
|
||||
class GetDvrSeriesRulesTest(TestCase):
|
||||
"""Verify get_dvr_series_rules handles corrupted stored data."""
|
||||
|
||||
def _set_series_rules_raw(self, raw_value):
|
||||
"""Write a raw series_rules value into the DB, bypassing set_dvr_series_rules."""
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=DVR_SETTINGS_KEY,
|
||||
defaults={"name": "DVR Settings", "value": {}},
|
||||
)
|
||||
current = obj.value if isinstance(obj.value, dict) else {}
|
||||
current["series_rules"] = raw_value
|
||||
obj.value = current
|
||||
obj.save()
|
||||
|
||||
def test_valid_rules_returned_as_is(self):
|
||||
rules = [{"tvg_id": "abc", "mode": "all", "title": "Show"}]
|
||||
self._set_series_rules_raw(rules)
|
||||
result = CoreSettings.get_dvr_series_rules()
|
||||
self.assertEqual(result, rules)
|
||||
|
||||
def test_non_dict_elements_filtered(self):
|
||||
"""Strings in the list cause 'str' has no attribute 'get'."""
|
||||
self._set_series_rules_raw(["bad_string", {"tvg_id": "abc", "mode": "all", "title": ""}])
|
||||
result = CoreSettings.get_dvr_series_rules()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["tvg_id"], "abc")
|
||||
|
||||
def test_non_list_value_returns_empty(self):
|
||||
"""If series_rules is a JSON string instead of a list, return empty."""
|
||||
self._set_series_rules_raw("[]")
|
||||
result = CoreSettings.get_dvr_series_rules()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_none_value_returns_empty(self):
|
||||
self._set_series_rules_raw(None)
|
||||
result = CoreSettings.get_dvr_series_rules()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_mixed_corrupt_elements(self):
|
||||
self._set_series_rules_raw([42, None, True, {"tvg_id": "x", "mode": "new", "title": "T"}])
|
||||
result = CoreSettings.get_dvr_series_rules()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["tvg_id"], "x")
|
||||
|
||||
|
||||
class SetDvrSeriesRulesTest(TestCase):
|
||||
"""Verify set_dvr_series_rules sanitizes input before persisting."""
|
||||
|
||||
def test_valid_rules_persisted(self):
|
||||
rules = [{"tvg_id": "abc", "mode": "all", "title": "Show"}]
|
||||
result = CoreSettings.set_dvr_series_rules(rules)
|
||||
self.assertEqual(result, rules)
|
||||
self.assertEqual(CoreSettings.get_dvr_series_rules(), rules)
|
||||
|
||||
def test_non_dict_elements_stripped_on_write(self):
|
||||
dirty = ["bad", 42, {"tvg_id": "abc", "mode": "all", "title": ""}]
|
||||
result = CoreSettings.set_dvr_series_rules(dirty)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["tvg_id"], "abc")
|
||||
self.assertEqual(CoreSettings.get_dvr_series_rules(), result)
|
||||
|
||||
def test_non_list_input_stores_empty(self):
|
||||
result = CoreSettings.set_dvr_series_rules("not a list")
|
||||
self.assertEqual(result, [])
|
||||
self.assertEqual(CoreSettings.get_dvr_series_rules(), [])
|
||||
|
||||
|
||||
class CoreSettingsSerializerDvrTest(TestCase):
|
||||
"""Verify the generic settings API sanitizes series_rules on save."""
|
||||
|
||||
def test_serializer_strips_corrupt_series_rules(self):
|
||||
"""Settings page round-trip must not persist corrupt series_rules."""
|
||||
from core.serializers import CoreSettingsSerializer
|
||||
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=DVR_SETTINGS_KEY,
|
||||
defaults={"name": "DVR Settings", "value": {"series_rules": []}},
|
||||
)
|
||||
dirty_value = {
|
||||
**obj.value,
|
||||
"series_rules": ["bad", {"tvg_id": "ok", "mode": "all", "title": ""}],
|
||||
}
|
||||
serializer = CoreSettingsSerializer(obj, data={"value": dirty_value}, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
obj.refresh_from_db()
|
||||
rules = obj.value.get("series_rules", [])
|
||||
self.assertEqual(len(rules), 1)
|
||||
self.assertEqual(rules[0]["tvg_id"], "ok")
|
||||
|
||||
def test_serializer_handles_non_list_series_rules(self):
|
||||
from core.serializers import CoreSettingsSerializer
|
||||
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=DVR_SETTINGS_KEY,
|
||||
defaults={"name": "DVR Settings", "value": {"series_rules": []}},
|
||||
)
|
||||
dirty_value = {**obj.value, "series_rules": "not a list"}
|
||||
serializer = CoreSettingsSerializer(obj, data={"value": dirty_value}, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
obj.refresh_from_db()
|
||||
self.assertEqual(obj.value.get("series_rules"), [])
|
||||
|
||||
|
||||
class EpgIgnoreListsTest(TestCase):
|
||||
"""Verify EPG ignore list getters handle corrupted stored data."""
|
||||
|
||||
def _set_epg_field_raw(self, field, raw_value):
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=EPG_SETTINGS_KEY,
|
||||
defaults={"name": "EPG Settings", "value": {}},
|
||||
)
|
||||
current = obj.value if isinstance(obj.value, dict) else {}
|
||||
current[field] = raw_value
|
||||
obj.value = current
|
||||
obj.save()
|
||||
|
||||
def test_valid_string_lists_returned(self):
|
||||
for field, getter in [
|
||||
("epg_match_ignore_prefixes", CoreSettings.get_epg_match_ignore_prefixes),
|
||||
("epg_match_ignore_suffixes", CoreSettings.get_epg_match_ignore_suffixes),
|
||||
("epg_match_ignore_custom", CoreSettings.get_epg_match_ignore_custom),
|
||||
]:
|
||||
self._set_epg_field_raw(field, ["HD", "SD"])
|
||||
self.assertEqual(getter(), ["HD", "SD"])
|
||||
|
||||
def test_non_string_elements_filtered(self):
|
||||
for field, getter in [
|
||||
("epg_match_ignore_prefixes", CoreSettings.get_epg_match_ignore_prefixes),
|
||||
("epg_match_ignore_suffixes", CoreSettings.get_epg_match_ignore_suffixes),
|
||||
("epg_match_ignore_custom", CoreSettings.get_epg_match_ignore_custom),
|
||||
]:
|
||||
self._set_epg_field_raw(field, [42, None, "HD", True, "SD"])
|
||||
result = getter()
|
||||
self.assertEqual(result, ["HD", "SD"])
|
||||
|
||||
def test_non_list_value_returns_empty(self):
|
||||
for field, getter in [
|
||||
("epg_match_ignore_prefixes", CoreSettings.get_epg_match_ignore_prefixes),
|
||||
("epg_match_ignore_suffixes", CoreSettings.get_epg_match_ignore_suffixes),
|
||||
("epg_match_ignore_custom", CoreSettings.get_epg_match_ignore_custom),
|
||||
]:
|
||||
self._set_epg_field_raw(field, "not a list")
|
||||
self.assertEqual(getter(), [])
|
||||
|
||||
|
||||
class DropDBCommandTlsTest(TestCase):
|
||||
"""Verify dropdb management command passes TLS parameters to psycopg2."""
|
||||
databases = []
|
||||
|
||||
_DB_WITH_TLS = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'testdb',
|
||||
'USER': 'testuser',
|
||||
'PASSWORD': 'testpass',
|
||||
'HOST': 'localhost',
|
||||
'PORT': 5432,
|
||||
'OPTIONS': {
|
||||
'sslmode': 'verify-full',
|
||||
'sslrootcert': '/certs/ca.crt',
|
||||
'sslcert': '/certs/client.crt',
|
||||
'sslkey': '/certs/client.key',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_DB_NO_TLS = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'testdb',
|
||||
'USER': 'testuser',
|
||||
'PASSWORD': 'testpass',
|
||||
'HOST': 'localhost',
|
||||
'PORT': 5432,
|
||||
}
|
||||
}
|
||||
|
||||
@patch('core.management.commands.dropdb.psycopg2.connect')
|
||||
@patch('core.management.commands.dropdb.connection')
|
||||
@patch('builtins.input', return_value='yes')
|
||||
def test_dropdb_passes_ssl_kwargs_when_tls_enabled(self, _inp, _conn, mock_connect):
|
||||
mock_pg = MagicMock()
|
||||
mock_connect.return_value = mock_pg
|
||||
mock_pg.cursor.return_value = MagicMock()
|
||||
|
||||
with self.settings(DATABASES=self._DB_WITH_TLS):
|
||||
from django.core.management import call_command
|
||||
call_command('dropdb')
|
||||
|
||||
mock_connect.assert_called_once_with(
|
||||
dbname='postgres', user='testuser', password='testpass',
|
||||
host='localhost', port=5432,
|
||||
sslmode='verify-full',
|
||||
sslrootcert='/certs/ca.crt',
|
||||
sslcert='/certs/client.crt',
|
||||
sslkey='/certs/client.key',
|
||||
)
|
||||
|
||||
@patch('core.management.commands.dropdb.psycopg2.connect')
|
||||
@patch('core.management.commands.dropdb.connection')
|
||||
@patch('builtins.input', return_value='yes')
|
||||
def test_dropdb_no_ssl_kwargs_when_tls_disabled(self, _inp, _conn, mock_connect):
|
||||
mock_pg = MagicMock()
|
||||
mock_connect.return_value = mock_pg
|
||||
mock_pg.cursor.return_value = MagicMock()
|
||||
|
||||
with self.settings(DATABASES=self._DB_NO_TLS):
|
||||
from django.core.management import call_command
|
||||
call_command('dropdb')
|
||||
|
||||
mock_connect.assert_called_once_with(
|
||||
dbname='postgres', user='testuser', password='testpass',
|
||||
host='localhost', port=5432,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
from .views import settings_view
|
||||
|
||||
urlpatterns = [
|
||||
path('', settings_view, name='settings'),
|
||||
]
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
import redis
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import re
|
||||
from django.conf import settings
|
||||
from redis.exceptions import ConnectionError, TimeoutError
|
||||
from django.core.cache import cache
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
import gc
|
||||
|
||||
_REDIS_TLS_HINT = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import the command detector
|
||||
from .command_utils import is_management_command
|
||||
|
||||
def natural_sort_key(text):
|
||||
"""
|
||||
Convert a string into a list of string and number chunks for natural sorting.
|
||||
"PPV 10" becomes ['PPV ', 10] so it sorts correctly with "PPV 2".
|
||||
|
||||
This function enables natural/alphanumeric sorting where numbers within strings
|
||||
are treated as actual numbers rather than strings.
|
||||
|
||||
Args:
|
||||
text (str): The text to convert for sorting
|
||||
|
||||
Returns:
|
||||
list: A list of strings and integers for proper sorting
|
||||
|
||||
Example:
|
||||
>>> sorted(['PPV 1', 'PPV 10', 'PPV 2'], key=natural_sort_key)
|
||||
['PPV 1', 'PPV 2', 'PPV 10']
|
||||
"""
|
||||
def convert(chunk):
|
||||
return int(chunk) if chunk.isdigit() else chunk.lower()
|
||||
|
||||
return [convert(c) for c in re.split('([0-9]+)', text)]
|
||||
|
||||
class RedisClient:
|
||||
_client = None
|
||||
_buffer = None
|
||||
_pubsub_client = None
|
||||
|
||||
@classmethod
|
||||
def _init_client(cls, decode_responses=True, max_retries=5, retry_interval=1):
|
||||
retry_count = 0
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# Get connection parameters from settings or environment
|
||||
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
|
||||
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
|
||||
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
|
||||
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
|
||||
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
|
||||
|
||||
# Use standardized settings
|
||||
socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5)
|
||||
socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5)
|
||||
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
|
||||
socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True)
|
||||
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
|
||||
|
||||
# TLS params from settings (empty dict when TLS is disabled)
|
||||
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
|
||||
|
||||
# Create Redis client with better defaults
|
||||
client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=redis_port,
|
||||
db=redis_db,
|
||||
password=redis_password if redis_password else None,
|
||||
username=redis_user if redis_user else None,
|
||||
socket_timeout=socket_timeout,
|
||||
socket_connect_timeout=socket_connect_timeout,
|
||||
socket_keepalive=socket_keepalive,
|
||||
health_check_interval=health_check_interval,
|
||||
retry_on_timeout=retry_on_timeout,
|
||||
decode_responses=decode_responses,
|
||||
**ssl_params
|
||||
)
|
||||
|
||||
# Validate connection with ping
|
||||
client.ping()
|
||||
|
||||
# Disable persistence on first connection - improves performance
|
||||
# Only try to disable if not in a read-only environment
|
||||
try:
|
||||
client.config_set('save', '') # Disable RDB snapshots
|
||||
client.config_set('appendonly', 'no') # Disable AOF logging
|
||||
|
||||
# Disable protected mode when in debug mode
|
||||
if os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true':
|
||||
client.config_set('protected-mode', 'no') # Disable protected mode in debug
|
||||
logger.warning("Redis protected mode disabled for debug environment")
|
||||
|
||||
logger.trace("Redis persistence disabled for better performance")
|
||||
except redis.exceptions.ResponseError as e:
|
||||
# Improve error handling for Redis configuration errors
|
||||
if "OOM" in str(e):
|
||||
logger.error(f"Redis OOM during configuration: {e}")
|
||||
# Try to increase maxmemory as an emergency measure
|
||||
try:
|
||||
client.config_set('maxmemory', '768mb')
|
||||
logger.warning("Applied emergency Redis memory increase to 768MB")
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
logger.error(f"Redis configuration error: {e}")
|
||||
|
||||
logger.info(f"Connected to Redis at {redis_host}:{redis_port}/{redis_db}")
|
||||
|
||||
return client
|
||||
|
||||
except (ConnectionError, TimeoutError) as e:
|
||||
retry_count += 1
|
||||
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
||||
if retry_count >= max_retries:
|
||||
logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}")
|
||||
return None
|
||||
else:
|
||||
# Use exponential backoff for retries
|
||||
wait_time = retry_interval * (2 ** (retry_count - 1))
|
||||
logger.warning(f"Redis connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})")
|
||||
time.sleep(wait_time)
|
||||
|
||||
except Exception as e:
|
||||
_tls_hint = ""
|
||||
try:
|
||||
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
||||
except NameError:
|
||||
pass
|
||||
logger.error(f"Unexpected error connecting to Redis: {e}{_tls_hint}")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_client(cls, max_retries=5, retry_interval=1):
|
||||
"""Get Redis client optimized for non-binary data (decoded responses)"""
|
||||
if cls._client is None:
|
||||
cls._client = cls._init_client(decode_responses=True, max_retries=max_retries, retry_interval=retry_interval)
|
||||
return cls._client
|
||||
|
||||
@classmethod
|
||||
def get_buffer(cls, max_retries=5, retry_interval=1):
|
||||
"""Get Redis client optimized for binary data (no decoding)"""
|
||||
if cls._buffer is None:
|
||||
cls._buffer = cls._init_client(decode_responses=False, max_retries=max_retries, retry_interval=retry_interval)
|
||||
return cls._buffer
|
||||
|
||||
@classmethod
|
||||
def get_pubsub_client(cls, max_retries=5, retry_interval=1):
|
||||
"""Get Redis client optimized for PubSub operations"""
|
||||
if cls._pubsub_client is None:
|
||||
retry_count = 0
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# Get connection parameters from settings or environment
|
||||
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
|
||||
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
|
||||
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
|
||||
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
|
||||
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
|
||||
|
||||
# Use standardized settings but without socket timeouts for PubSub
|
||||
# Important: socket_timeout is None for PubSub operations
|
||||
socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5)
|
||||
socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True)
|
||||
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
|
||||
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
|
||||
|
||||
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
|
||||
|
||||
# Create Redis client with PubSub-optimized settings - no timeout
|
||||
client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=redis_port,
|
||||
db=redis_db,
|
||||
password=redis_password if redis_password else None,
|
||||
username=redis_user if redis_user else None,
|
||||
socket_timeout=None, # Critical: No timeout for PubSub operations
|
||||
socket_connect_timeout=socket_connect_timeout,
|
||||
socket_keepalive=socket_keepalive,
|
||||
health_check_interval=health_check_interval,
|
||||
retry_on_timeout=retry_on_timeout,
|
||||
decode_responses=True,
|
||||
**ssl_params
|
||||
)
|
||||
|
||||
# Validate connection with ping
|
||||
client.ping()
|
||||
logger.info(f"Connected to Redis for PubSub at {redis_host}:{redis_port}/{redis_db}")
|
||||
|
||||
# We don't need the keepalive thread anymore since we're using proper PubSub handling
|
||||
cls._pubsub_client = client
|
||||
break
|
||||
|
||||
except (ConnectionError, TimeoutError) as e:
|
||||
retry_count += 1
|
||||
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
||||
if retry_count >= max_retries:
|
||||
logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}{_tls_hint}")
|
||||
return None
|
||||
else:
|
||||
# Use exponential backoff for retries
|
||||
wait_time = retry_interval * (2 ** (retry_count - 1))
|
||||
logger.warning(f"Redis PubSub connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})")
|
||||
time.sleep(wait_time)
|
||||
|
||||
except Exception as e:
|
||||
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
||||
logger.error(f"Unexpected error connecting to Redis for PubSub: {e}{_tls_hint}")
|
||||
return None
|
||||
|
||||
return cls._pubsub_client
|
||||
|
||||
def acquire_task_lock(task_name, id):
|
||||
"""Acquire a lock to prevent concurrent task execution."""
|
||||
redis_client = RedisClient.get_client()
|
||||
lock_id = f"task_lock_{task_name}_{id}"
|
||||
|
||||
# Use the Redis SET command with NX (only set if not exists) and EX (set expiration)
|
||||
lock_acquired = redis_client.set(lock_id, "locked", ex=300, nx=True)
|
||||
|
||||
if not lock_acquired:
|
||||
logger.warning(f"Lock for {task_name} and id={id} already acquired. Task will not proceed.")
|
||||
|
||||
return lock_acquired
|
||||
|
||||
def release_task_lock(task_name, id):
|
||||
"""Release the lock after task execution."""
|
||||
redis_client = RedisClient.get_client()
|
||||
lock_id = f"task_lock_{task_name}_{id}"
|
||||
|
||||
# Remove the lock
|
||||
redis_client.delete(lock_id)
|
||||
|
||||
|
||||
class TaskLockRenewer:
|
||||
"""Periodically renews a Redis task lock to prevent expiry during long-running tasks.
|
||||
|
||||
Use as a context manager after acquiring a lock:
|
||||
|
||||
if acquire_task_lock("my_task", task_id):
|
||||
with TaskLockRenewer("my_task", task_id):
|
||||
# ... long-running work ...
|
||||
release_task_lock("my_task", task_id)
|
||||
|
||||
A daemon thread extends the lock TTL at regular intervals so that
|
||||
slow downloads or large parsing jobs don't lose their lock mid-operation.
|
||||
"""
|
||||
|
||||
def __init__(self, task_name, id, ttl=300, renewal_interval=120):
|
||||
self.task_name = task_name
|
||||
self.id = id
|
||||
self.ttl = ttl
|
||||
self.renewal_interval = renewal_interval
|
||||
self.lock_id = f"task_lock_{task_name}_{id}"
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
def _renew_loop(self):
|
||||
"""Background loop that extends the lock TTL until stopped."""
|
||||
while not self._stop_event.wait(self.renewal_interval):
|
||||
try:
|
||||
redis_client = RedisClient.get_client()
|
||||
if redis_client.exists(self.lock_id):
|
||||
redis_client.expire(self.lock_id, self.ttl)
|
||||
logger.debug(
|
||||
f"Renewed lock {self.lock_id} TTL to {self.ttl}s"
|
||||
)
|
||||
else:
|
||||
# Lock was deleted externally (e.g. manual release) — stop renewing
|
||||
logger.warning(
|
||||
f"Lock {self.lock_id} no longer exists, stopping renewal"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error renewing lock {self.lock_id}: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the background renewal thread."""
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._renew_loop, daemon=True,
|
||||
name=f"lock-renew-{self.task_name}-{self.id}"
|
||||
)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
"""Stop the renewal thread."""
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
|
||||
def send_websocket_update(group_name, event_type, data, collect_garbage=False):
|
||||
"""
|
||||
Standardized function to send WebSocket updates with proper memory management.
|
||||
|
||||
In uWSGI + gevent deployments, async_to_sync creates an asyncio event loop
|
||||
whose native EpollSelector blocks the entire OS thread, freezing all gevent
|
||||
greenlets in the worker. To avoid this, the actual send is offloaded to a
|
||||
real OS thread from gevent's native threadpool when monkey-patching is active.
|
||||
"""
|
||||
channel_layer = get_channel_layer()
|
||||
message = {'type': event_type, 'data': data}
|
||||
|
||||
def _do_send():
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(group_name, message)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send WebSocket update: {e}")
|
||||
|
||||
try:
|
||||
import gevent.monkey
|
||||
if gevent.monkey.is_module_patched('threading'):
|
||||
from gevent import get_hub
|
||||
get_hub().threadpool.spawn(_do_send)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Not in a gevent-patched environment — call directly
|
||||
_do_send()
|
||||
|
||||
if collect_garbage:
|
||||
gc.collect()
|
||||
|
||||
def send_websocket_event(event, success, data):
|
||||
"""Acquire a lock to prevent concurrent task execution."""
|
||||
data_payload = {"success": success, "type": event}
|
||||
if data:
|
||||
# Make a copy to avoid modifying the original
|
||||
data_payload.update(data)
|
||||
|
||||
# Use the standardized function
|
||||
send_websocket_update('updates', 'update', data_payload)
|
||||
|
||||
# Help garbage collection by clearing references
|
||||
data_payload = None
|
||||
|
||||
# Add memory monitoring utilities
|
||||
def get_memory_usage():
|
||||
"""Returns current memory usage in MB"""
|
||||
import psutil
|
||||
process = psutil.Process(os.getpid())
|
||||
return process.memory_info().rss / (1024 * 1024)
|
||||
|
||||
def monitor_memory_usage(func):
|
||||
"""Decorator to monitor memory usage before and after function execution"""
|
||||
def wrapper(*args, **kwargs):
|
||||
import gc
|
||||
# Force garbage collection before measuring
|
||||
gc.collect()
|
||||
|
||||
# Get initial memory usage
|
||||
start_mem = get_memory_usage()
|
||||
logger.debug(f"Memory usage before {func.__name__}: {start_mem:.2f} MB")
|
||||
|
||||
# Call the original function
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Force garbage collection before measuring again
|
||||
gc.collect()
|
||||
|
||||
# Get final memory usage
|
||||
end_mem = get_memory_usage()
|
||||
logger.debug(f"Memory usage after {func.__name__}: {end_mem:.2f} MB (Change: {end_mem - start_mem:.2f} MB)")
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
def cleanup_memory(log_usage=False, force_collection=True):
|
||||
"""
|
||||
Comprehensive memory cleanup function to reduce memory footprint
|
||||
|
||||
Args:
|
||||
log_usage: Whether to log memory usage before and after cleanup
|
||||
force_collection: Whether to force garbage collection
|
||||
"""
|
||||
logger.trace("Starting memory cleanup django memory cleanup")
|
||||
# Skip logging if log level is not set to debug or more verbose (like trace)
|
||||
current_log_level = logger.getEffectiveLevel()
|
||||
if not current_log_level <= logging.DEBUG:
|
||||
log_usage = False
|
||||
if log_usage:
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
before_mem = process.memory_info().rss / (1024 * 1024)
|
||||
logger.debug(f"Memory before cleanup: {before_mem:.2f} MB")
|
||||
except (ImportError, Exception) as e:
|
||||
logger.debug(f"Error getting memory usage: {e}")
|
||||
|
||||
# Clear any object caches from Django ORM
|
||||
from django.db import connection, reset_queries
|
||||
reset_queries()
|
||||
|
||||
# Force garbage collection
|
||||
if force_collection:
|
||||
# Run full collection
|
||||
gc.collect(generation=2)
|
||||
# Clear cyclic references
|
||||
gc.collect(generation=0)
|
||||
|
||||
if log_usage:
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
after_mem = process.memory_info().rss / (1024 * 1024)
|
||||
logger.debug(f"Memory after cleanup: {after_mem:.2f} MB (change: {after_mem-before_mem:.2f} MB)")
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
logger.trace("Memory cleanup complete for django")
|
||||
|
||||
def safe_upload_path(filename: str, base_dir) -> str:
|
||||
"""Return a safe absolute path for an uploaded file within base_dir.
|
||||
|
||||
Strips all directory components from *filename* and verifies the resolved
|
||||
path stays inside *base_dir*. Raises ValueError on path traversal attempts.
|
||||
"""
|
||||
safe_name = Path(filename).name
|
||||
base = Path(base_dir).resolve()
|
||||
file_path = (base / safe_name).resolve()
|
||||
if not file_path.is_relative_to(base):
|
||||
raise ValueError("Invalid filename.")
|
||||
return str(file_path)
|
||||
|
||||
|
||||
def is_protected_path(file_path):
|
||||
"""
|
||||
Determine if a file path is in a protected directory that shouldn't be deleted.
|
||||
|
||||
Args:
|
||||
file_path (str): The file path to check
|
||||
|
||||
Returns:
|
||||
bool: True if the path is protected, False otherwise
|
||||
"""
|
||||
if not file_path:
|
||||
return False
|
||||
|
||||
# List of protected directory prefixes
|
||||
protected_dirs = [
|
||||
'/data/epgs', # EPG files mapped from host
|
||||
'/data/uploads', # User uploaded files
|
||||
'/data/m3us' # M3U files mapped from host
|
||||
]
|
||||
|
||||
# Check if the path starts with any protected directory
|
||||
for protected_dir in protected_dirs:
|
||||
if file_path.startswith(protected_dir):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def validate_flexible_url(value):
|
||||
"""
|
||||
Custom URL validator that accepts URLs with hostnames that aren't FQDNs.
|
||||
This allows URLs like "http://hostname/" which
|
||||
Django's standard URLValidator rejects.
|
||||
"""
|
||||
if not value:
|
||||
return # Allow empty values since the field is nullable
|
||||
|
||||
# Create a standard Django URL validator
|
||||
url_validator = URLValidator()
|
||||
|
||||
try:
|
||||
# First try the standard validation
|
||||
url_validator(value)
|
||||
except ValidationError as e:
|
||||
# If standard validation fails, check if it's a non-FQDN hostname
|
||||
import re
|
||||
|
||||
# More flexible pattern for non-FQDN hostnames with paths
|
||||
# Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234
|
||||
# Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value
|
||||
# Also supports authentication: rtsp://user:pass@hostname/path
|
||||
non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$'
|
||||
non_fqdn_match = re.match(non_fqdn_pattern, value)
|
||||
|
||||
if non_fqdn_match:
|
||||
return # Accept non-FQDN hostnames and rtsp/rtp/udp URLs with optional authentication
|
||||
|
||||
# If it doesn't match our flexible patterns, raise the original error
|
||||
raise ValidationError("Enter a valid URL.")
|
||||
|
||||
def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details):
|
||||
try:
|
||||
from apps.connect.utils import trigger_event
|
||||
from apps.channels.models import Channel, Stream
|
||||
from core.models import StreamProfile
|
||||
from core.utils import RedisClient
|
||||
|
||||
payload = dict(details)
|
||||
|
||||
channel_obj = None
|
||||
if channel_id:
|
||||
try:
|
||||
channel_obj = Channel.objects.get(uuid=channel_id)
|
||||
payload["channel_name"] = channel_obj.name
|
||||
except Exception:
|
||||
payload["channel_name"] = channel_name or None
|
||||
else:
|
||||
payload["channel_name"] = channel_name or None
|
||||
|
||||
# Resolve current stream info
|
||||
stream_id = details.get("stream_id")
|
||||
stream_obj = None
|
||||
if not stream_id and channel_obj:
|
||||
try:
|
||||
redis = RedisClient.get_client()
|
||||
sid = redis.get(f"channel_stream:{channel_obj.id}")
|
||||
if sid:
|
||||
stream_id = int(sid)
|
||||
except Exception:
|
||||
stream_id = None
|
||||
|
||||
if stream_id:
|
||||
try:
|
||||
stream_obj = Stream.objects.get(id=stream_id)
|
||||
except Exception:
|
||||
stream_obj = None
|
||||
|
||||
# Populate stream details
|
||||
payload["stream_name"] = getattr(stream_obj, "name", None)
|
||||
payload["stream_url"] = getattr(stream_obj, "url", None)
|
||||
|
||||
# Channel URL: use stream URL as best-effort
|
||||
payload["channel_url"] = payload.get("stream_url")
|
||||
|
||||
# Provider name from M3U account
|
||||
provider_name = None
|
||||
try:
|
||||
if stream_obj and stream_obj.m3u_account:
|
||||
provider_name = stream_obj.m3u_account.name
|
||||
except Exception:
|
||||
provider_name = None
|
||||
payload["provider_name"] = provider_name
|
||||
|
||||
# Profile used
|
||||
profile_used = None
|
||||
try:
|
||||
if stream_id:
|
||||
redis = RedisClient.get_client()
|
||||
pid = redis.get(f"stream_profile:{stream_id}")
|
||||
if pid:
|
||||
profile = StreamProfile.objects.filter(id=int(pid)).first()
|
||||
profile_used = profile.name if profile else None
|
||||
except Exception:
|
||||
profile_used = None
|
||||
|
||||
payload["profile_used"] = profile_used
|
||||
|
||||
# remove empty keys
|
||||
for k in list(payload.keys()):
|
||||
if not payload[k]:
|
||||
del payload[k]
|
||||
|
||||
trigger_event(event_type, payload)
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail main path if connect dispatch fails
|
||||
pass
|
||||
|
||||
def log_system_event(event_type, channel_id=None, channel_name=None, **details):
|
||||
"""
|
||||
Log a system event and maintain the configured max history.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., 'channel_start', 'client_connect')
|
||||
channel_id: Optional UUID of the channel
|
||||
channel_name: Optional name of the channel
|
||||
**details: Additional details to store in the event (stored as JSON)
|
||||
|
||||
Example:
|
||||
log_system_event('channel_start', channel_id=uuid, channel_name='CNN',
|
||||
stream_url='http://...', user='admin')
|
||||
"""
|
||||
from core.models import SystemEvent, CoreSettings
|
||||
|
||||
try:
|
||||
# Create the event
|
||||
SystemEvent.objects.create(
|
||||
event_type=event_type,
|
||||
channel_id=channel_id,
|
||||
channel_name=channel_name,
|
||||
details=details
|
||||
)
|
||||
|
||||
# Trigger connect integrations for specific events
|
||||
dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details)
|
||||
|
||||
# Get max events from settings (default 100)
|
||||
try:
|
||||
from .models import CoreSettings
|
||||
system_settings = CoreSettings.objects.filter(key='system_settings').first()
|
||||
if system_settings and isinstance(system_settings.value, dict):
|
||||
max_events = int(system_settings.value.get('max_system_events', 100))
|
||||
else:
|
||||
max_events = 100
|
||||
except Exception:
|
||||
max_events = 100
|
||||
|
||||
# Delete old events beyond the limit (keep it efficient with a single query)
|
||||
total_count = SystemEvent.objects.count()
|
||||
if total_count > max_events:
|
||||
# Get the ID of the event at the cutoff point
|
||||
cutoff_event = SystemEvent.objects.values_list('id', flat=True)[max_events]
|
||||
# Delete all events with ID less than cutoff (older events)
|
||||
SystemEvent.objects.filter(id__lt=cutoff_event).delete()
|
||||
|
||||
except Exception as e:
|
||||
# Don't let event logging break the main application
|
||||
logger.error(f"Failed to log system event {event_type}: {e}")
|
||||
|
||||
|
||||
def send_websocket_notification(notification):
|
||||
"""
|
||||
Send a system notification to all connected WebSocket clients.
|
||||
|
||||
Args:
|
||||
notification: A SystemNotification model instance or dict with notification data
|
||||
|
||||
Example:
|
||||
from core.models import SystemNotification
|
||||
notification = SystemNotification.create_version_notification('0.19.0', 'https://...')
|
||||
send_websocket_notification(notification)
|
||||
"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
# Convert model instance to dict if needed
|
||||
if hasattr(notification, 'id'):
|
||||
notification_data = {
|
||||
'id': notification.id,
|
||||
'notification_key': notification.notification_key,
|
||||
'notification_type': notification.notification_type,
|
||||
'priority': notification.priority,
|
||||
'title': notification.title,
|
||||
'message': notification.message,
|
||||
'action_data': notification.action_data,
|
||||
'is_active': notification.is_active,
|
||||
'admin_only': notification.admin_only,
|
||||
'created_at': notification.created_at.isoformat() if notification.created_at else None,
|
||||
}
|
||||
else:
|
||||
notification_data = notification
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
'type': 'system_notification',
|
||||
'notification': notification_data,
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send WebSocket notification: {e}")
|
||||
|
||||
|
||||
def send_notification_dismissed(notification_key):
|
||||
"""
|
||||
Notify all connected clients that a notification was dismissed.
|
||||
Useful for syncing dismissal state across multiple browser tabs/sessions.
|
||||
|
||||
Args:
|
||||
notification_key: The unique key of the dismissed notification
|
||||
"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
'type': 'notification_dismissed',
|
||||
'notification_key': notification_key,
|
||||
}
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification dismissed event: {e}")
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
# core/views.py
|
||||
import os
|
||||
from shlex import split as shlex_split
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import regex
|
||||
import redis
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse, HttpResponseServerError
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.channels.models import Channel, Stream
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
|
||||
# Import the persistent lock (the “real” lock)
|
||||
from dispatcharr.persistent_lock import PersistentLock
|
||||
|
||||
# Configure logging to output to the console.
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def settings_view(request):
|
||||
"""
|
||||
Renders the settings page.
|
||||
"""
|
||||
return render(request, 'settings.html')
|
||||
|
||||
|
||||
def stream_view(request, channel_uuid):
|
||||
"""
|
||||
Streams the first available stream for the given channel.
|
||||
It uses the channel’s assigned StreamProfile.
|
||||
A persistent Redis lock is used to prevent concurrent streaming on the same channel.
|
||||
"""
|
||||
try:
|
||||
redis_host = getattr(settings, "REDIS_HOST", "localhost")
|
||||
redis_port = int(getattr(settings, "REDIS_PORT", 6379))
|
||||
redis_db = int(getattr(settings, "REDIS_DB", "0"))
|
||||
redis_password = getattr(settings, "REDIS_PASSWORD", "")
|
||||
redis_user = getattr(settings, "REDIS_USER", "")
|
||||
ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {})
|
||||
redis_client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=redis_port,
|
||||
db=redis_db,
|
||||
password=redis_password if redis_password else None,
|
||||
username=redis_user if redis_user else None,
|
||||
**ssl_params
|
||||
)
|
||||
|
||||
# Retrieve the channel by the provided stream_id.
|
||||
channel = Channel.objects.get(uuid=channel_uuid)
|
||||
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name)
|
||||
|
||||
# Ensure the channel has at least one stream.
|
||||
if not channel.streams.exists():
|
||||
logger.error("No streams found for channel ID=%s", channel.id)
|
||||
return HttpResponseServerError("No stream found for this channel.")
|
||||
|
||||
active_stream = None
|
||||
m3u_account = None
|
||||
active_profile = None
|
||||
lock_key = None
|
||||
persistent_lock = None
|
||||
|
||||
streams = channel.streams.all().order_by('channelstream__order')
|
||||
logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}')
|
||||
for stream in streams:
|
||||
# Get the first available stream.
|
||||
logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name)
|
||||
|
||||
# Retrieve the M3U account associated with the stream.
|
||||
m3u_account = stream.m3u_account
|
||||
logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
|
||||
|
||||
# Use the custom URL if available; otherwise, use the standard URL.
|
||||
input_url = stream.url
|
||||
logger.debug("Input URL: %s", input_url)
|
||||
|
||||
# Determine which profile we can use.
|
||||
m3u_profiles = m3u_account.profiles.all()
|
||||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
profiles = [obj for obj in m3u_profiles if not obj.is_default]
|
||||
|
||||
# -- Loop through profiles and pick the first active one --
|
||||
for profile in [default_profile] + profiles:
|
||||
logger.debug(f'Checking profile {profile.name}...')
|
||||
if not profile.is_active:
|
||||
logger.debug('Profile is not active, skipping.')
|
||||
continue
|
||||
|
||||
logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available')
|
||||
stream_index = 0
|
||||
max_streams = profile.max_streams
|
||||
if max_streams == 0:
|
||||
max_streams = 999999 # maybe a better way than just hardcoding a high number...
|
||||
while stream_index < max_streams:
|
||||
stream_index += 1
|
||||
|
||||
lock_key = f"lock:{profile.id}:{stream_index}"
|
||||
persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120)
|
||||
logger.debug(f'Attempting to acquire lock: {lock_key}')
|
||||
|
||||
if not persistent_lock.acquire():
|
||||
logger.error(f"Could not acquire persistent lock for profile {profile.id} index {stream_index}, currently in use.")
|
||||
persistent_lock = None
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
if persistent_lock is not None:
|
||||
logger.debug(f'Successfully acquired lock: {lock_key}')
|
||||
active_profile = M3UAccountProfile.objects.get(id=profile.id)
|
||||
break
|
||||
|
||||
if active_profile is None or persistent_lock is None:
|
||||
logger.exception("No available profiles for the stream")
|
||||
continue
|
||||
|
||||
logger.debug(f"Found available stream profile: stream={stream.name}, profile={profile.name}")
|
||||
break
|
||||
|
||||
if not active_profile:
|
||||
logger.exception("No available streams for this channel")
|
||||
return HttpResponseServerError("No available streams for this channel")
|
||||
|
||||
logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)")
|
||||
# Prepare the pattern replacement.
|
||||
logger.debug("Executing the following pattern replacement:")
|
||||
logger.debug(f" search: {active_profile.search_pattern}")
|
||||
# Convert JS-style backreferences in replace: $<name> -> \g<name>, $1 -> \1
|
||||
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern)
|
||||
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
|
||||
logger.debug(f" replace: {active_profile.replace_pattern}")
|
||||
logger.debug(f" safe replace: {safe_replace_pattern}")
|
||||
# regex module accepts JS-style (?<name>...) named groups natively
|
||||
stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url)
|
||||
logger.debug(f"Generated stream url: {stream_url}")
|
||||
|
||||
# Get the stream profile set on the channel.
|
||||
stream_profile = channel.stream_profile
|
||||
if not stream_profile:
|
||||
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
|
||||
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
|
||||
|
||||
logger.debug("Stream profile used: %s", stream_profile.name)
|
||||
|
||||
# Determine the user agent to use.
|
||||
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
|
||||
logger.debug("User agent: %s", user_agent)
|
||||
|
||||
# Substitute placeholders in the parameters template.
|
||||
parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
|
||||
logger.debug("Formatted parameters: %s", parameters)
|
||||
|
||||
# Build the final command.
|
||||
cmd = [stream_profile.command] + shlex_split(parameters)
|
||||
logger.debug("Executing command: %s", cmd)
|
||||
|
||||
try:
|
||||
# Start the streaming process.
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except Exception as e:
|
||||
persistent_lock.release() # Ensure the lock is released on error.
|
||||
logger.exception("Error starting stream for channel ID=%s", stream_id)
|
||||
return HttpResponseServerError(f"Error starting stream: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error preparing stream for channel ID=%s", stream_id)
|
||||
return HttpResponseServerError(f"Error preparing stream: {e}")
|
||||
|
||||
def stream_generator(proc, s, persistent_lock):
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
logger.debug("Streaming process terminated for stream ID=%s", s.id)
|
||||
except Exception as e:
|
||||
logger.error("Error terminating process for stream ID=%s: %s", s.id, e)
|
||||
persistent_lock.release()
|
||||
logger.debug("Persistent lock released for channel ID=%s", channel.id)
|
||||
|
||||
return StreamingHttpResponse(
|
||||
stream_generator(process, stream, persistent_lock),
|
||||
content_type="video/MP2T"
|
||||
)
|
||||
@@ -0,0 +1,465 @@
|
||||
import requests
|
||||
import logging
|
||||
import traceback
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Client:
|
||||
"""Xtream Codes API Client with robust error handling"""
|
||||
|
||||
def __init__(self, server_url, username, password, user_agent=None):
|
||||
self.server_url = self._normalize_url(server_url)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.user_agent = user_agent
|
||||
|
||||
# Fix: Properly handle all possible user_agent input types
|
||||
if user_agent:
|
||||
if isinstance(user_agent, str):
|
||||
user_agent_string = user_agent
|
||||
elif hasattr(user_agent, 'user_agent'):
|
||||
user_agent_string = user_agent.user_agent
|
||||
else:
|
||||
logger.warning(f"Unexpected user_agent type: {type(user_agent)}, using default")
|
||||
user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
else:
|
||||
user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
# Create persistent session
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({'User-Agent': user_agent_string})
|
||||
|
||||
# Configure connection pooling
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
pool_connections=1,
|
||||
pool_maxsize=2,
|
||||
max_retries=3,
|
||||
pool_block=False
|
||||
)
|
||||
self.session.mount('http://', adapter)
|
||||
self.session.mount('https://', adapter)
|
||||
|
||||
self.server_info = None
|
||||
|
||||
def _normalize_url(self, url):
|
||||
"""Normalize server URL by removing trailing slashes and paths"""
|
||||
if not url:
|
||||
raise ValueError("Server URL cannot be empty")
|
||||
|
||||
url = url.rstrip('/')
|
||||
# Remove any path after domain - we'll construct proper API URLs
|
||||
# Split by protocol first to preserve it
|
||||
if '://' in url:
|
||||
protocol, rest = url.split('://', 1)
|
||||
domain = rest.split('/', 1)[0]
|
||||
return f"{protocol}://{domain}"
|
||||
return url
|
||||
|
||||
def _make_request(self, endpoint, params=None):
|
||||
"""Make request with detailed error handling"""
|
||||
try:
|
||||
url = f"{self.server_url}/{endpoint}"
|
||||
logger.debug(f"XC API Request: {url} with params: {params}")
|
||||
|
||||
response = self.session.get(url, params=params, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
# Check if response is empty
|
||||
if not response.content:
|
||||
error_msg = f"XC API returned empty response from {url}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Check for common blocking responses before trying to parse JSON
|
||||
response_text = response.text.strip()
|
||||
if response_text.lower() in ['blocked', 'forbidden', 'access denied', 'unauthorized']:
|
||||
error_msg = f"XC API request blocked by server from {url}. Response: {response_text}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"This may indicate IP blocking, User-Agent filtering, or rate limiting")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except requests.exceptions.JSONDecodeError as json_err:
|
||||
error_msg = f"XC API returned invalid JSON from {url}. Response: {response.text[:1000]}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"JSON decode error: {str(json_err)}")
|
||||
|
||||
# Check if it looks like an HTML error page
|
||||
if response_text.startswith('<'):
|
||||
logger.error("Response appears to be HTML - server may be returning an error page")
|
||||
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Check for XC-specific error responses
|
||||
if isinstance(data, dict) and data.get('user_info') is None and 'error' in data:
|
||||
error_msg = f"XC API Error: {data.get('error', 'Unknown error')}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
error_msg = f"XC API Request failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"Request details: URL={url}, Params={params}")
|
||||
raise
|
||||
except ValueError as e:
|
||||
# This could be from JSON parsing or our explicit raises
|
||||
logger.error(f"XC API Invalid response: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"XC API Unexpected error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate and validate server response"""
|
||||
try:
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password
|
||||
}
|
||||
|
||||
self.server_info = self._make_request(endpoint, params)
|
||||
|
||||
if not self.server_info or not self.server_info.get('user_info'):
|
||||
error_msg = "Authentication failed: Invalid response from server"
|
||||
logger.error(f"{error_msg}. Response: {self.server_info}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"XC Authentication successful for user {self.username}")
|
||||
return self.server_info
|
||||
except Exception as e:
|
||||
logger.error(f"XC Authentication failed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_account_info(self):
|
||||
"""Get account information from the last authentication response"""
|
||||
if not self.server_info:
|
||||
raise ValueError("Not authenticated. Call authenticate() first.")
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
# Extract relevant account information
|
||||
user_info = self.server_info.get('user_info', {})
|
||||
server_info = self.server_info.get('server_info', {})
|
||||
|
||||
account_info = {
|
||||
'last_refresh': datetime.utcnow().isoformat() + 'Z', # Explicit UTC with Z suffix
|
||||
'auth_timestamp': datetime.utcnow().timestamp(),
|
||||
'user_info': {
|
||||
'username': user_info.get('username'),
|
||||
'password': user_info.get('password'),
|
||||
'message': user_info.get('message'),
|
||||
'auth': user_info.get('auth'),
|
||||
'status': user_info.get('status'),
|
||||
'exp_date': user_info.get('exp_date'),
|
||||
'is_trial': user_info.get('is_trial'),
|
||||
'active_cons': user_info.get('active_cons'),
|
||||
'created_at': user_info.get('created_at'),
|
||||
'max_connections': user_info.get('max_connections'),
|
||||
'allowed_output_formats': user_info.get('allowed_output_formats', [])
|
||||
},
|
||||
'server_info': {
|
||||
'url': server_info.get('url'),
|
||||
'port': server_info.get('port'),
|
||||
'https_port': server_info.get('https_port'),
|
||||
'server_protocol': server_info.get('server_protocol'),
|
||||
'rtmp_port': server_info.get('rtmp_port'),
|
||||
'timezone': server_info.get('timezone'),
|
||||
'timestamp_now': server_info.get('timestamp_now'),
|
||||
'time_now': server_info.get('time_now')
|
||||
}
|
||||
}
|
||||
|
||||
return account_info
|
||||
|
||||
def get_live_categories(self):
|
||||
"""Get live TV categories"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_live_categories'
|
||||
}
|
||||
|
||||
categories = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(categories, list):
|
||||
error_msg = f"Invalid categories response: {categories}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(categories)} live categories")
|
||||
logger.debug(f"Categories: {json.dumps(categories[:5])}...")
|
||||
return categories
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get live categories: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_live_category_streams(self, category_id):
|
||||
"""Get streams for a specific category"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_live_streams',
|
||||
'category_id': category_id
|
||||
}
|
||||
|
||||
streams = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(streams, list):
|
||||
error_msg = f"Invalid streams response for category {category_id}: {streams}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(streams)} streams for category {category_id}")
|
||||
return streams
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get streams for category {category_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_all_live_streams(self):
|
||||
"""Get all live streams (no category filter)"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_live_streams'
|
||||
# No category_id = get all streams
|
||||
}
|
||||
|
||||
streams = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(streams, list):
|
||||
error_msg = f"Invalid streams response for all live streams: {streams}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(streams)} total live streams")
|
||||
return streams
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all live streams: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_stream_url(self, stream_id):
|
||||
"""Get the playback URL for a stream"""
|
||||
return f"{self.server_url}/live/{self.username}/{self.password}/{stream_id}.ts"
|
||||
|
||||
def get_episode_stream_url(self, stream_id, container_extension='mp4'):
|
||||
"""Get the playback URL for an episode stream"""
|
||||
return f"{self.server_url}/series/{self.username}/{self.password}/{stream_id}.{container_extension}"
|
||||
|
||||
def get_vod_stream_url(self, stream_id, container_extension='mp4'):
|
||||
"""Get the playback URL for a VOD stream"""
|
||||
return f"{self.server_url}/movie/{self.username}/{self.password}/{stream_id}.{container_extension}"
|
||||
|
||||
def get_vod_categories(self):
|
||||
"""Get VOD categories"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_vod_categories'
|
||||
}
|
||||
|
||||
categories = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(categories, list):
|
||||
error_msg = f"Invalid VOD categories response: {categories}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(categories)} VOD categories")
|
||||
return categories
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VOD categories: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_vod_streams(self, category_id=None):
|
||||
"""Get VOD streams for a specific category"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_vod_streams'
|
||||
}
|
||||
|
||||
if category_id:
|
||||
params['category_id'] = category_id
|
||||
|
||||
streams = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(streams, list):
|
||||
error_msg = f"Invalid VOD streams response for category {category_id}: {streams}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(streams)} VOD streams for category {category_id}")
|
||||
return streams
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VOD streams for category {category_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_vod_info(self, vod_id):
|
||||
"""Get detailed information for a specific VOD"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_vod_info',
|
||||
'vod_id': vod_id
|
||||
}
|
||||
|
||||
vod_info = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(vod_info, dict):
|
||||
error_msg = f"Invalid VOD info response for vod_id {vod_id}: {vod_info}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved VOD info for vod_id {vod_id}")
|
||||
return vod_info
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VOD info for vod_id {vod_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_series_categories(self):
|
||||
"""Get series categories"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_series_categories'
|
||||
}
|
||||
|
||||
categories = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(categories, list):
|
||||
error_msg = f"Invalid series categories response: {categories}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(categories)} series categories")
|
||||
return categories
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get series categories: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_series(self, category_id=None):
|
||||
"""Get series for a specific category"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_series'
|
||||
}
|
||||
|
||||
if category_id:
|
||||
params['category_id'] = category_id
|
||||
|
||||
series = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(series, list):
|
||||
error_msg = f"Invalid series response for category {category_id}: {series}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(series)} series for category {category_id}")
|
||||
return series
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get series for category {category_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_series_info(self, series_id):
|
||||
"""Get detailed information for a specific series including episodes"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_series_info',
|
||||
'series_id': series_id
|
||||
}
|
||||
|
||||
series_info = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(series_info, dict):
|
||||
error_msg = f"Invalid series info response for series_id {series_id}: {series_info}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved series info for series_id {series_id}")
|
||||
return series_info
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get series info for series_id {series_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close the session and cleanup resources"""
|
||||
if hasattr(self, 'session') and self.session:
|
||||
try:
|
||||
self.session.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing XC session: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter the context manager"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit the context manager and cleanup resources"""
|
||||
self.close()
|
||||
return False # Don't suppress exceptions
|
||||
|
||||
def __del__(self):
|
||||
"""Ensure session is closed when object is destroyed"""
|
||||
self.close()
|
||||
Reference in New Issue
Block a user