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

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
View File
+41
View File
@@ -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",
)
+29
View File
@@ -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)),
]
+665
View File
@@ -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
})
+48
View File
@@ -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}")
+34
View File
@@ -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
+412
View File
@@ -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"
}
]
}
+55
View File
@@ -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"
}
}
]
+54
View File
@@ -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()
+46
View File
@@ -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',
),
]
+22
View File
@@ -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),
]
+30
View File
@@ -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),
]
+28
View File
@@ -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),
]
View File
+568
View File
@@ -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}"
+263
View File
@@ -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
+203
View File
@@ -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()
+166
View File
@@ -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']
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from .views import settings_view
urlpatterns = [
path('', settings_view, name='settings'),
]
+707
View File
@@ -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
View File
@@ -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"
)
+465
View File
@@ -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()