From 7e5be6094f6fb9bc00ff3eee9e4e4b799c98ce3c Mon Sep 17 00:00:00 2001 From: Marlon Alkan Date: Sun, 8 Jun 2025 16:45:34 +0200 Subject: [PATCH 001/168] docker: init: 02-postgres.sh: allow DB user to create new DB (for tests) --- docker/init/02-postgres.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 69a81dd41..7bb906718 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -57,13 +57,14 @@ if [ -z "$(ls -A $POSTGRES_DIR)" ]; then echo "Creating PostgreSQL database..." su - postgres -c "createdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" - # Create user, set ownership, and grant privileges + # Create user, set ownership, and grant privileges, including privileges to create new databases echo "Creating PostgreSQL user..." su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" < Date: Sun, 8 Jun 2025 16:47:00 +0200 Subject: [PATCH 002/168] apps: output: change body detection logic and add tests --- apps/output/tests.py | 23 +++++++++++++++++++++++ apps/output/views.py | 5 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/output/tests.py b/apps/output/tests.py index e1e857eec..f87c83406 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -14,3 +14,26 @@ def test_generate_m3u_response(self): self.assertEqual(response.status_code, 200) content = response.content.decode() self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_empty_body(self): + """ + Test that a POST request with an empty body returns 200 OK. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded') + content = response.content.decode() + + self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK") + self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_with_body(self): + """ + Test that a POST request with a non-empty body returns 403 Forbidden. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data={'evilstring': 'muhahaha'}) + + self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") + self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) diff --git a/apps/output/views.py b/apps/output/views.py index 2b18d185b..ff02560c1 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -18,9 +18,10 @@ def generate_m3u(request, profile_name=None): The stream URL now points to the new stream_view that uses StreamProfile. Supports both GET and POST methods for compatibility with IPTVSmarters. """ - # Check if this is a POST request with data (which we don't want to allow) + # Check if this is a POST request and the body is not empty (which we don't want to allow) if request.method == "POST" and request.body: - return HttpResponseForbidden("POST requests with content are not allowed") + if request.body.decode() != '{}': + return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) if profile_name is not None: channel_profile = ChannelProfile.objects.get(name=profile_name) From 424a4506541ae78d4465c16f67b7b9ef0a268dbc Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 18 Sep 2025 10:23:16 -0500 Subject: [PATCH 003/168] DVR Features and bug fixes Added ability to use custom comskip.ini Added series recording without reliance on EPG Fixed comskip bug Fixed timezone mismatch when scheduling DVR recordings No migrations completed yet --- apps/channels/api_urls.py | 4 + apps/channels/api_views.py | 92 ++++- .../migrations/0026_recurringrecordingrule.py | 31 ++ apps/channels/models.py | 30 ++ apps/channels/serializers.py | 36 ++ apps/channels/tasks.py | 230 +++++++++++-- apps/channels/tests/__init__.py | 0 apps/channels/tests/test_recurring_rules.py | 40 +++ core/models.py | 22 ++ dispatcharr/settings.py | 4 + frontend/src/api.js | 64 ++++ frontend/src/components/forms/Recording.jsx | 325 ++++++++++++++---- frontend/src/pages/DVR.jsx | 166 +++++++-- frontend/src/pages/Settings.jsx | 104 ++++++ frontend/src/store/channels.jsx | 18 + 15 files changed, 1054 insertions(+), 112 deletions(-) create mode 100644 apps/channels/migrations/0026_recurringrecordingrule.py create mode 100644 apps/channels/tests/__init__.py create mode 100644 apps/channels/tests/test_recurring_rules.py diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 7cfdc1b19..7999abd93 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -13,12 +13,14 @@ UpdateChannelMembershipAPIView, BulkUpdateChannelMembershipAPIView, RecordingViewSet, + RecurringRecordingRuleViewSet, GetChannelStreamsAPIView, SeriesRulesAPIView, DeleteSeriesRuleAPIView, EvaluateSeriesRulesAPIView, BulkRemoveSeriesRecordingsAPIView, BulkDeleteUpcomingRecordingsAPIView, + ComskipConfigAPIView, ) app_name = 'channels' # for DRF routing @@ -30,6 +32,7 @@ router.register(r'logos', LogoViewSet, basename='logo') router.register(r'profiles', ChannelProfileViewSet, basename='profile') router.register(r'recordings', RecordingViewSet, basename='recording') +router.register(r'recurring-rules', RecurringRecordingRuleViewSet, basename='recurring-rule') urlpatterns = [ # Bulk delete is a single APIView, not a ViewSet @@ -46,6 +49,7 @@ path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'), path('series-rules//', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'), + path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'), ] urlpatterns += router.urls diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 7a3d51357..e7991220c 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -28,6 +28,7 @@ ChannelProfile, ChannelProfileMembership, Recording, + RecurringRecordingRule, ) from .serializers import ( StreamSerializer, @@ -38,8 +39,17 @@ BulkChannelProfileMembershipSerializer, ChannelProfileSerializer, RecordingSerializer, + RecurringRecordingRuleSerializer, +) +from .tasks import ( + match_epg_channels, + evaluate_series_rules, + evaluate_series_rules_impl, + match_single_channel_epg, + match_selected_channels_epg, + sync_recurring_rule_impl, + purge_recurring_rule_impl, ) -from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg, match_selected_channels_epg import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter @@ -49,10 +59,12 @@ from django.http import StreamingHttpResponse, FileResponse, Http404 from django.utils import timezone import mimetypes +from django.conf import settings from rest_framework.pagination import PageNumberPagination + logger = logging.getLogger(__name__) @@ -1653,6 +1665,41 @@ def patch(self, request, profile_id): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class RecurringRecordingRuleViewSet(viewsets.ModelViewSet): + queryset = RecurringRecordingRule.objects.all().select_related("channel") + serializer_class = RecurringRecordingRuleSerializer + + def get_permissions(self): + return [IsAdmin()] + + def perform_create(self, serializer): + rule = serializer.save() + try: + sync_recurring_rule_impl(rule.id, drop_existing=True) + except Exception as err: + logger.warning(f"Failed to initialize recurring rule {rule.id}: {err}") + return rule + + def perform_update(self, serializer): + rule = serializer.save() + try: + if rule.enabled: + sync_recurring_rule_impl(rule.id, drop_existing=True) + else: + purge_recurring_rule_impl(rule.id) + except Exception as err: + logger.warning(f"Failed to resync recurring rule {rule.id}: {err}") + return rule + + def perform_destroy(self, instance): + rule_id = instance.id + super().perform_destroy(instance) + try: + purge_recurring_rule_impl(rule_id) + except Exception as err: + logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}") + + class RecordingViewSet(viewsets.ModelViewSet): queryset = Recording.objects.all() serializer_class = RecordingSerializer @@ -1832,6 +1879,49 @@ def _safe_remove(path: str): return response +class ComskipConfigAPIView(APIView): + """Upload or inspect the custom comskip.ini used by DVR processing.""" + + parser_classes = [MultiPartParser, FormParser] + + def get_permissions(self): + return [IsAdmin()] + + def get(self, request): + path = CoreSettings.get_dvr_comskip_custom_path() + exists = bool(path and os.path.exists(path)) + return Response({"path": path, "exists": exists}) + + def post(self, request): + uploaded = request.FILES.get("file") or request.FILES.get("comskip_ini") + if not uploaded: + return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST) + + name = (uploaded.name or "").lower() + if not name.endswith(".ini"): + return Response({"error": "Only .ini files are allowed"}, status=status.HTTP_400_BAD_REQUEST) + + if uploaded.size and uploaded.size > 1024 * 1024: + return Response({"error": "File too large (limit 1MB)"}, status=status.HTTP_400_BAD_REQUEST) + + dest_dir = os.path.join(settings.MEDIA_ROOT, "comskip") + os.makedirs(dest_dir, exist_ok=True) + dest_path = os.path.join(dest_dir, "comskip.ini") + + try: + with open(dest_path, "wb") as dest: + for chunk in uploaded.chunks(): + dest.write(chunk) + except Exception as e: + logger.error(f"Failed to save uploaded comskip.ini: {e}") + return Response({"error": "Unable to save file"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Persist path setting so DVR processing picks it up immediately + CoreSettings.set_dvr_comskip_custom_path(dest_path) + + return Response({"success": True, "path": dest_path, "exists": os.path.exists(dest_path)}) + + class BulkDeleteUpcomingRecordingsAPIView(APIView): """Delete all upcoming (future) recordings.""" def get_permissions(self): diff --git a/apps/channels/migrations/0026_recurringrecordingrule.py b/apps/channels/migrations/0026_recurringrecordingrule.py new file mode 100644 index 000000000..1b8cfdb87 --- /dev/null +++ b/apps/channels/migrations/0026_recurringrecordingrule.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.14 on 2025-09-18 14:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0025_alter_channelgroupm3uaccount_custom_properties_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='RecurringRecordingRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('days_of_week', models.JSONField(default=list)), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('enabled', models.BooleanField(default=True)), + ('name', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_rules', to='dispatcharr_channels.channel')), + ], + options={ + 'ordering': ['channel', 'start_time'], + }, + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index af66178de..e6e3bd7a8 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -601,3 +601,33 @@ class Recording(models.Model): def __str__(self): return f"{self.channel.name} - {self.start_time} to {self.end_time}" + + +class RecurringRecordingRule(models.Model): + """Rule describing a recurring manual DVR schedule.""" + + channel = models.ForeignKey( + "Channel", + on_delete=models.CASCADE, + related_name="recurring_rules", + ) + days_of_week = models.JSONField(default=list) + start_time = models.TimeField() + end_time = models.TimeField() + enabled = models.BooleanField(default=True) + name = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["channel", "start_time"] + + def __str__(self): + channel_name = getattr(self.channel, "name", str(self.channel_id)) + return f"Recurring rule for {channel_name}" + + def cleaned_days(self): + try: + return sorted({int(d) for d in (self.days_of_week or []) if 0 <= int(d) <= 6}) + except Exception: + return [] diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 51bfe0a04..d9b345494 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -10,6 +10,7 @@ ChannelProfile, ChannelProfileMembership, Recording, + RecurringRecordingRule, ) from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile @@ -454,6 +455,13 @@ def validate(self, data): start_time = data.get("start_time") end_time = data.get("end_time") + if start_time and timezone.is_naive(start_time): + start_time = timezone.make_aware(start_time, timezone.get_current_timezone()) + data["start_time"] = start_time + if end_time and timezone.is_naive(end_time): + end_time = timezone.make_aware(end_time, timezone.get_current_timezone()) + data["end_time"] = end_time + # If this is an EPG-based recording (program provided), apply global pre/post offsets try: cp = data.get("custom_properties") or {} @@ -497,3 +505,31 @@ def validate(self, data): raise serializers.ValidationError("End time must be after start time.") return data + + +class RecurringRecordingRuleSerializer(serializers.ModelSerializer): + class Meta: + model = RecurringRecordingRule + fields = "__all__" + read_only_fields = ["created_at", "updated_at"] + + def validate_days_of_week(self, value): + if not value: + raise serializers.ValidationError("Select at least one day of the week") + cleaned = [] + for entry in value: + try: + iv = int(entry) + except (TypeError, ValueError): + raise serializers.ValidationError("Days of week must be integers 0-6") + if iv < 0 or iv > 6: + raise serializers.ValidationError("Days of week must be between 0 (Monday) and 6 (Sunday)") + cleaned.append(iv) + return sorted(set(cleaned)) + + def validate(self, attrs): + start = attrs.get("start_time") or getattr(self.instance, "start_time", None) + end = attrs.get("end_time") or getattr(self.instance, "end_time", None) + if start and end and end <= start: + raise serializers.ValidationError("End time must be after start time") + return super().validate(attrs) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 2760d1a75..e92c47947 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -7,6 +7,7 @@ import time import json import subprocess +import signal from datetime import datetime, timedelta import gc @@ -1095,6 +1096,130 @@ def reschedule_upcoming_recordings_for_offset_change(): return reschedule_upcoming_recordings_for_offset_change_impl() +def _notify_recordings_refresh(): + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"}) + except Exception: + pass + + +def purge_recurring_rule_impl(rule_id: int) -> int: + """Remove all future recordings created by a recurring rule.""" + from django.utils import timezone + from .models import Recording + + now = timezone.now() + try: + removed, _ = Recording.objects.filter( + start_time__gte=now, + custom_properties__rule__id=rule_id, + ).delete() + except Exception: + removed = 0 + if removed: + _notify_recordings_refresh() + return removed + + +def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_days: int = 14) -> int: + """Ensure recordings exist for a recurring rule within the scheduling horizon.""" + from django.utils import timezone + from .models import RecurringRecordingRule, Recording + + rule = RecurringRecordingRule.objects.filter(pk=rule_id).select_related("channel").first() + now = timezone.now() + removed = 0 + if drop_existing: + removed = purge_recurring_rule_impl(rule_id) + + if not rule or not rule.enabled: + return 0 + + days = rule.cleaned_days() + if not days: + return 0 + + tz = timezone.get_current_timezone() + horizon = now + timedelta(days=horizon_days) + start_date = now.date() + end_date = horizon.date() + total_created = 0 + + for offset in range((end_date - start_date).days + 1): + target_date = start_date + timedelta(days=offset) + if target_date.weekday() not in days: + continue + try: + start_dt = timezone.make_aware(datetime.combine(target_date, rule.start_time), tz) + end_dt = timezone.make_aware(datetime.combine(target_date, rule.end_time), tz) + except Exception: + continue + if end_dt <= start_dt or start_dt <= now: + continue + exists = Recording.objects.filter( + channel=rule.channel, + start_time=start_dt, + custom_properties__rule__id=rule.id, + ).exists() + if exists: + continue + description = rule.name or f"Recurring recording for {rule.channel.name}" + cp = { + "rule": { + "type": "recurring", + "id": rule.id, + "days_of_week": days, + "name": rule.name or "", + }, + "status": "scheduled", + "description": description, + "program": { + "title": rule.name or rule.channel.name, + "description": description, + "start_time": start_dt.isoformat(), + "end_time": end_dt.isoformat(), + }, + } + try: + Recording.objects.create( + channel=rule.channel, + start_time=start_dt, + end_time=end_dt, + custom_properties=cp, + ) + total_created += 1 + except Exception as err: + logger.warning(f"Failed to create recurring recording for rule {rule.id}: {err}") + + if removed or total_created: + _notify_recordings_refresh() + + return total_created + + +@shared_task +def rebuild_recurring_rule(rule_id: int, horizon_days: int = 14): + return sync_recurring_rule_impl(rule_id, drop_existing=True, horizon_days=horizon_days) + + +@shared_task +def maintain_recurring_recordings(): + from .models import RecurringRecordingRule + + total = 0 + for rule_id in RecurringRecordingRule.objects.filter(enabled=True).values_list("id", flat=True): + try: + total += sync_recurring_rule_impl(rule_id, drop_existing=False) + except Exception as err: + logger.warning(f"Recurring rule maintenance failed for {rule_id}: {err}") + return total + + +@shared_task +def purge_recurring_rule(rule_id: int): + return purge_recurring_rule_impl(rule_id) + @shared_task def _safe_name(s): try: @@ -1817,6 +1942,7 @@ def comskip_process_recording(recording_id: int): Safe to call even if comskip is not installed; stores status in custom_properties.comskip. """ import shutil + from django.db import DatabaseError from .models import Recording # Helper to broadcast status over websocket def _ws(status: str, extra: dict | None = None): @@ -1834,7 +1960,33 @@ def _ws(status: str, extra: dict | None = None): except Recording.DoesNotExist: return "not_found" - cp = rec.custom_properties or {} + cp = rec.custom_properties.copy() if isinstance(rec.custom_properties, dict) else {} + + def _persist_custom_properties(): + """Persist updated custom_properties without raising if the row disappeared.""" + try: + updated = Recording.objects.filter(pk=recording_id).update(custom_properties=cp) + if not updated: + logger.warning( + "Recording %s vanished before comskip status could be saved", + recording_id, + ) + return False + except DatabaseError as db_err: + logger.warning( + "Failed to persist comskip status for recording %s: %s", + recording_id, + db_err, + ) + return False + except Exception as unexpected: + logger.warning( + "Unexpected error while saving comskip status for recording %s: %s", + recording_id, + unexpected, + ) + return False + return True file_path = (cp or {}).get("file_path") if not file_path or not os.path.exists(file_path): return "no_file" @@ -1845,8 +1997,7 @@ def _ws(status: str, extra: dict | None = None): comskip_bin = shutil.which("comskip") if not comskip_bin: cp["comskip"] = {"status": "skipped", "reason": "comskip_not_installed"} - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + _persist_custom_properties() _ws('skipped', {"reason": "comskip_not_installed"}) return "comskip_missing" @@ -1858,24 +2009,59 @@ def _ws(status: str, extra: dict | None = None): try: cmd = [comskip_bin, "--output", os.path.dirname(file_path)] - # Prefer system ini if present to squelch warning and get sane defaults - for ini_path in ("/etc/comskip/comskip.ini", "/app/docker/comskip.ini"): - if os.path.exists(ini_path): + # Prefer user-specified INI, fall back to known defaults + ini_candidates = [] + try: + custom_ini = CoreSettings.get_dvr_comskip_custom_path() + if custom_ini: + ini_candidates.append(custom_ini) + except Exception as ini_err: + logger.debug(f"Unable to load custom comskip.ini path: {ini_err}") + ini_candidates.extend(["/etc/comskip/comskip.ini", "/app/docker/comskip.ini"]) + selected_ini = None + for ini_path in ini_candidates: + if ini_path and os.path.exists(ini_path): + selected_ini = ini_path cmd.extend([f"--ini={ini_path}"]) break cmd.append(file_path) - subprocess.run(cmd, check=True) + subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as e: + stderr_tail = (e.stderr or "").strip().splitlines() + stderr_tail = stderr_tail[-5:] if stderr_tail else [] + detail = { + "status": "error", + "reason": "comskip_failed", + "returncode": e.returncode, + } + if e.returncode and e.returncode < 0: + try: + detail["signal"] = signal.Signals(-e.returncode).name + except Exception: + detail["signal"] = f"signal_{-e.returncode}" + if stderr_tail: + detail["stderr"] = "\n".join(stderr_tail) + if selected_ini: + detail["ini_path"] = selected_ini + cp["comskip"] = detail + _persist_custom_properties() + _ws('error', {"reason": "comskip_failed", "returncode": e.returncode}) + return "comskip_failed" except Exception as e: cp["comskip"] = {"status": "error", "reason": f"comskip_failed: {e}"} - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + _persist_custom_properties() _ws('error', {"reason": str(e)}) return "comskip_failed" if not os.path.exists(edl_path): cp["comskip"] = {"status": "error", "reason": "edl_not_found"} - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + _persist_custom_properties() _ws('error', {"reason": "edl_not_found"}) return "no_edl" @@ -1893,8 +2079,7 @@ def _ffprobe_duration(path): duration = _ffprobe_duration(file_path) if duration is None: cp["comskip"] = {"status": "error", "reason": "duration_unknown"} - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + _persist_custom_properties() _ws('error', {"reason": "duration_unknown"}) return "no_duration" @@ -1923,9 +2108,14 @@ def _ffprobe_duration(path): keep.append((cur, duration)) if not commercials or sum((e - s) for s, e in commercials) <= 0.5: - cp["comskip"] = {"status": "completed", "skipped": True, "edl": os.path.basename(edl_path)} - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + cp["comskip"] = { + "status": "completed", + "skipped": True, + "edl": os.path.basename(edl_path), + } + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() _ws('skipped', {"reason": "no_commercials", "commercials": 0}) return "no_commercials" @@ -1975,14 +2165,14 @@ def _ffprobe_duration(path): "segments_kept": len(parts), "commercials": len(commercials), } - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() _ws('completed', {"commercials": len(commercials), "segments_kept": len(parts)}) return "ok" except Exception as e: cp["comskip"] = {"status": "error", "reason": str(e)} - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) + _persist_custom_properties() _ws('error', {"reason": str(e)}) return f"error:{e}" def _resolve_poster_for_program(channel_name, program): diff --git a/apps/channels/tests/__init__.py b/apps/channels/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/channels/tests/test_recurring_rules.py b/apps/channels/tests/test_recurring_rules.py new file mode 100644 index 000000000..982ecb932 --- /dev/null +++ b/apps/channels/tests/test_recurring_rules.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel, RecurringRecordingRule, Recording +from apps.channels.tasks import sync_recurring_rule_impl, purge_recurring_rule_impl + + +class RecurringRecordingRuleTasksTests(TestCase): + def test_sync_recurring_rule_creates_and_purges_recordings(self): + now = timezone.now() + channel = Channel.objects.create(channel_number=1, name='Test Channel') + + start_time = (now + timedelta(minutes=15)).time().replace(second=0, microsecond=0) + end_time = (now + timedelta(minutes=75)).time().replace(second=0, microsecond=0) + + rule = RecurringRecordingRule.objects.create( + channel=channel, + days_of_week=[now.weekday()], + start_time=start_time, + end_time=end_time, + ) + + created = sync_recurring_rule_impl(rule.id, drop_existing=True, horizon_days=1) + self.assertEqual(created, 1) + + recording = Recording.objects.filter(custom_properties__rule__id=rule.id).first() + self.assertIsNotNone(recording) + self.assertEqual(recording.channel, channel) + self.assertEqual(recording.custom_properties.get('rule', {}).get('id'), rule.id) + + expected_start = timezone.make_aware( + datetime.combine(recording.start_time.date(), start_time), + timezone.get_current_timezone(), + ) + self.assertLess(abs((recording.start_time - expected_start).total_seconds()), 60) + + removed = purge_recurring_rule_impl(rule.id) + self.assertEqual(removed, 1) + self.assertFalse(Recording.objects.filter(custom_properties__rule__id=rule.id).exists()) diff --git a/core/models.py b/core/models.py index ba0406664..5584d7ca1 100644 --- a/core/models.py +++ b/core/models.py @@ -158,6 +158,7 @@ def _replace_in_part(self, part, replacements): DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template") DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template") DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled") +DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path") DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") @@ -274,6 +275,27 @@ def get_dvr_comskip_enabled(cls): except cls.DoesNotExist: return False + @classmethod + def get_dvr_comskip_custom_path(cls): + """Return configured comskip.ini path or empty string if unset.""" + try: + return cls.objects.get(key=DVR_COMSKIP_CUSTOM_PATH_KEY).value + except cls.DoesNotExist: + return "" + + @classmethod + def set_dvr_comskip_custom_path(cls, path: str | None): + """Persist the comskip.ini path setting, normalizing nulls to empty string.""" + value = (path or "").strip() + obj, _ = cls.objects.get_or_create( + key=DVR_COMSKIP_CUSTOM_PATH_KEY, + defaults={"name": "DVR Comskip Custom Path", "value": value}, + ) + if obj.value != value: + obj.value = value + obj.save(update_fields=["value"]) + return value + @classmethod def get_dvr_pre_offset_minutes(cls): """Minutes to start recording before scheduled start (default 0).""" diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 289c67942..057780dea 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -211,6 +211,10 @@ "task": "core.tasks.scan_and_process_files", # Direct task call "schedule": 20.0, # Every 20 seconds }, + "maintain-recurring-recordings": { + "task": "apps.channels.tasks.maintain_recurring_recordings", + "schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead + }, } MEDIA_ROOT = BASE_DIR / "media" diff --git a/frontend/src/api.js b/frontend/src/api.js index 01186bf6f..19de8cd0e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1873,6 +1873,70 @@ export default class API { } } + static async getComskipConfig() { + try { + return await request(`${host}/api/channels/dvr/comskip-config/`); + } catch (e) { + errorNotification('Failed to retrieve comskip configuration', e); + } + } + + static async uploadComskipIni(file) { + try { + const formData = new FormData(); + formData.append('file', file); + return await request(`${host}/api/channels/dvr/comskip-config/`, { + method: 'POST', + body: formData, + }); + } catch (e) { + errorNotification('Failed to upload comskip.ini', e); + } + } + + static async listRecurringRules() { + try { + const response = await request(`${host}/api/channels/recurring-rules/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve recurring DVR rules', e); + } + } + + static async createRecurringRule(payload) { + try { + const response = await request(`${host}/api/channels/recurring-rules/`, { + method: 'POST', + body: payload, + }); + return response; + } catch (e) { + errorNotification('Failed to create recurring DVR rule', e); + } + } + + static async updateRecurringRule(ruleId, payload) { + try { + const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, { + method: 'PATCH', + body: payload, + }); + return response; + } catch (e) { + errorNotification(`Failed to update recurring rule ${ruleId}`, e); + } + } + + static async deleteRecurringRule(ruleId) { + try { + await request(`${host}/api/channels/recurring-rules/${ruleId}/`, { + method: 'DELETE', + }); + } catch (e) { + errorNotification(`Failed to delete recurring rule ${ruleId}`, e); + } + } + static async deleteRecording(id) { try { await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' }); diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 7ac36a0fd..342276ed1 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,117 +1,300 @@ -// Modal.js -import React from 'react'; +import React, { useMemo, useState } from 'react'; import API from '../../api'; -import { Button, Modal, Flex, Select, Alert } from '@mantine/core'; -import useChannelsStore from '../../store/channels'; -import { DateTimePicker } from '@mantine/dates'; +import { + Alert, + Button, + Modal, + Select, + Stack, + SegmentedControl, + MultiSelect, + Group, + Text, +} from '@mantine/core'; +import { DateTimePicker, TimeInput } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; import { isNotEmpty, useForm } from '@mantine/form'; +import useChannelsStore from '../../store/channels'; +import { notifications } from '@mantine/notifications'; + +const DAY_OPTIONS = [ + { value: '6', label: 'Sun' }, + { value: '0', label: 'Mon' }, + { value: '1', label: 'Tue' }, + { value: '2', label: 'Wed' }, + { value: '3', label: 'Thu' }, + { value: '4', label: 'Fri' }, + { value: '5', label: 'Sat' }, +]; + +const asDate = (value) => { + if (!value) return null; + if (value instanceof Date) return value; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +const toIsoIfDate = (value) => { + const dt = asDate(value); + return dt ? dt.toISOString() : value; +}; -const DVR = ({ recording = null, channel = null, isOpen, onClose }) => { +const toTimeString = (value) => { + const dt = asDate(value); + if (!dt) return ''; + const hours = String(dt.getHours()).padStart(2, '0'); + const minutes = String(dt.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; +}; + +const createRoundedDate = (minutesAhead = 0) => { + const dt = new Date(); + dt.setSeconds(0); + dt.setMilliseconds(0); + dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); + if (minutesAhead) { + dt.setMinutes(dt.getMinutes() + minutesAhead); + } + return dt; +}; + +const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => { const channels = useChannelsStore((s) => s.channels); + const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); + const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); - let startTime = new Date(); - startTime.setMinutes(Math.ceil(startTime.getMinutes() / 30) * 30); - startTime.setSeconds(0); - startTime.setMilliseconds(0); + const [mode, setMode] = useState('single'); + const [submitting, setSubmitting] = useState(false); - let endTime = new Date(); - endTime.setMinutes(Math.ceil(endTime.getMinutes() / 30) * 30); - endTime.setSeconds(0); - endTime.setMilliseconds(0); - endTime.setHours(endTime.getHours() + 1); + const defaultStart = createRoundedDate(); + const defaultEnd = createRoundedDate(60); - const form = useForm({ - mode: 'uncontrolled', + const singleForm = useForm({ + mode: 'controlled', initialValues: { channel_id: recording - ? recording.channel_id + ? `${recording.channel}` : channel ? `${channel.id}` : '', - start_time: recording ? recording.start_time : startTime, - end_time: recording ? recording.end_time : endTime, + start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart, + end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd, + }, + validate: { + channel_id: isNotEmpty('Select a channel'), + start_time: isNotEmpty('Select a start time'), + end_time: (value, values) => { + const start = asDate(values.start_time); + const end = asDate(value); + if (!end) return 'Select an end time'; + if (start && end <= start) return 'End time must be after start time'; + return null; + }, }, + }); + const recurringForm = useForm({ + mode: 'controlled', + initialValues: { + channel_id: channel ? `${channel.id}` : '', + days_of_week: [], + start_time: defaultStart, + end_time: defaultEnd, + }, validate: { channel_id: isNotEmpty('Select a channel'), + days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'), start_time: isNotEmpty('Select a start time'), - end_time: isNotEmpty('Select an end time'), + end_time: (value, values) => { + const start = asDate(values.start_time); + const end = asDate(value); + if (!end) return 'Select an end time'; + if (start && end <= start) return 'End time must be after start time'; + return null; + }, }, }); - const onSubmit = async () => { - const { channel_id, ...values } = form.getValues(); + const channelOptions = useMemo(() => { + const list = Object.values(channels || {}); + list.sort((a, b) => { + const aNum = Number(a.channel_number) || 0; + const bNum = Number(b.channel_number) || 0; + if (aNum === bNum) { + return (a.name || '').localeCompare(b.name || ''); + } + return aNum - bNum; + }); + return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` })); + }, [channels]); + + const resetForms = () => { + singleForm.reset(); + recurringForm.reset(); + setMode('single'); + }; - console.log(values); + const handleClose = () => { + resetForms(); + onClose?.(); + }; - await API.createRecording({ - ...values, - channel: channel_id, - }); + const handleSingleSubmit = async (values) => { + try { + setSubmitting(true); + await API.createRecording({ + channel: values.channel_id, + start_time: toIsoIfDate(values.start_time), + end_time: toIsoIfDate(values.end_time), + }); + await fetchRecordings(); + notifications.show({ + title: 'Recording scheduled', + message: 'One-time recording added to DVR queue', + color: 'green', + autoClose: 2500, + }); + handleClose(); + } catch (error) { + console.error('Failed to create recording', error); + } finally { + setSubmitting(false); + } + }; - form.reset(); - onClose(); + const handleRecurringSubmit = async (values) => { + try { + setSubmitting(true); + await API.createRecurringRule({ + channel: values.channel_id, + days_of_week: (values.days_of_week || []).map((d) => Number(d)), + start_time: toTimeString(values.start_time), + end_time: toTimeString(values.end_time), + }); + await Promise.all([fetchRecurringRules(), fetchRecordings()]); + notifications.show({ + title: 'Recurring rule saved', + message: 'Future slots will be scheduled automatically', + color: 'green', + autoClose: 2500, + }); + handleClose(); + } catch (error) { + console.error('Failed to create recurring rule', error); + } finally { + setSubmitting(false); + } }; + const onSubmit = mode === 'single' + ? singleForm.onSubmit(handleSingleSubmit) + : recurringForm.onSubmit(handleRecurringSubmit); + if (!isOpen) { - return <>; + return null; } return ( - + } - style={{ paddingBottom: 5 }} + style={{ paddingBottom: 5, marginBottom: 12 }} > - Recordings may fail if active streams or overlapping recordings use up - all available streams + Recordings may fail if active streams or overlapping recordings use up all available tuners. -
- + ) : ( + + + ({ value: String(opt.value), label: opt.label }))} + searchable + clearable + /> + + form.setFieldValue('start_date', value || dayjs().toDate())} + valueFormat="MMM D, YYYY" + /> + form.setFieldValue('end_date', value)} + valueFormat="MMM D, YYYY" + minDate={form.values.start_date || undefined} + /> + + + form.setFieldValue('start_time', value)} + withSeconds={false} + format="12" + amLabel="AM" + pmLabel="PM" + /> + form.setFieldValue('end_time', value)} + withSeconds={false} + format="12" + amLabel="AM" + pmLabel="PM" + /> + + + + + + + + + + Upcoming occurrences + {upcomingOccurrences.length} + + {upcomingOccurrences.length === 0 ? ( + No future airings currently scheduled. + ) : ( + + {upcomingOccurrences.map((occ) => { + const occStart = dayjs(occ.start_time); + const occEnd = dayjs(occ.end_time); + return ( + + + + {occStart.format('MMM D, YYYY')} + {occStart.format('h:mma')} – {occEnd.format('h:mma')} + + + + + + + + ); + })} + + )} + + +
+ ); +}; + +const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { const channels = useChannelsStore((s) => s.channels); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); @@ -332,11 +664,11 @@ const RecordingCard = ({ recording, onOpenDetails }) => { const deleteRecording = (id) => { // Optimistically remove immediately from UI - try { useChannelsStore.getState().removeRecording(id); } catch {} + try { useChannelsStore.getState().removeRecording(id); } catch (error) { console.error('Failed to optimistically remove recording', error); } // Fire-and-forget server delete; websocket will keep others in sync API.deleteRecording(id).catch(() => { // On failure, fallback to refetch to restore state - try { useChannelsStore.getState().fetchRecordings(); } catch {} + try { useChannelsStore.getState().fetchRecordings(); } catch (error) { console.error('Failed to refresh recordings after delete', error); } }); }; @@ -345,6 +677,7 @@ const RecordingCard = ({ recording, onOpenDetails }) => { const recordingName = program.title || 'Custom Recording'; const subTitle = program.sub_title || ''; const description = program.description || customProps.description || ''; + const isRecurringRule = customProps?.rule?.type === 'recurring'; // Poster or channel logo const posterLogoId = customProps.poster_logo_id; @@ -395,7 +728,9 @@ const RecordingCard = ({ recording, onOpenDetails }) => { try { await API.runComskip(recording.id); notifications.show({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', autoClose: 2000 }); - } catch {} + } catch (error) { + console.error('Failed to queue comskip for recording', error); + } }; // Cancel handling for series groups @@ -403,6 +738,10 @@ const RecordingCard = ({ recording, onOpenDetails }) => { const [busy, setBusy] = React.useState(false); const handleCancelClick = (e) => { e.stopPropagation(); + if (isRecurringRule) { + onOpenRecurring?.(recording, true); + return; + } if (isSeriesGroup) { setCancelOpen(true); } else { @@ -410,11 +749,11 @@ const RecordingCard = ({ recording, onOpenDetails }) => { } }; - const seriesInfo = React.useMemo(() => { + const seriesInfo = (() => { const cp = customProps || {}; const pr = cp.program || {}; return { tvg_id: pr.tvg_id, title: pr.title }; - }, [customProps]); + })(); const removeUpcomingOnly = async () => { try { @@ -423,7 +762,7 @@ const RecordingCard = ({ recording, onOpenDetails }) => { } finally { setBusy(false); setCancelOpen(false); - try { await fetchRecordings(); } catch {} + try { await fetchRecordings(); } catch (error) { console.error('Failed to refresh recordings', error); } } }; @@ -432,13 +771,13 @@ const RecordingCard = ({ recording, onOpenDetails }) => { setBusy(true); const { tvg_id, title } = seriesInfo; if (tvg_id) { - try { await API.bulkRemoveSeriesRecordings({ tvg_id, title, scope: 'title' }); } catch {} - try { await API.deleteSeriesRule(tvg_id); } catch {} + try { await API.bulkRemoveSeriesRecordings({ tvg_id, title, scope: 'title' }); } catch (error) { console.error('Failed to remove series recordings', error); } + try { await API.deleteSeriesRule(tvg_id); } catch (error) { console.error('Failed to delete series rule', error); } } } finally { setBusy(false); setCancelOpen(false); - try { await fetchRecordings(); } catch {} + try { await fetchRecordings(); } catch (error) { console.error('Failed to refresh recordings after series removal', error); } } }; @@ -455,7 +794,13 @@ const RecordingCard = ({ recording, onOpenDetails }) => { height: '100%', cursor: 'pointer', }} - onClick={() => onOpenDetails?.(recording)} + onClick={() => { + if (isRecurringRule) { + onOpenRecurring?.(recording, false); + } else { + onOpenDetails?.(recording); + } + }} > @@ -471,7 +816,7 @@ const RecordingCard = ({ recording, onOpenDetails }) => { {isSeriesGroup && ( Series )} - {customProps?.rule?.type === 'recurring' && ( + {isRecurringRule && ( Recurring )} {seLabel && !isSeriesGroup && ( @@ -622,13 +967,13 @@ const DVRPage = () => { const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); const channels = useChannelsStore((s) => s.channels); const fetchChannels = useChannelsStore((s) => s.fetchChannels); - const recurringRules = useChannelsStore((s) => s.recurringRules) || []; const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); const [recordingModalOpen, setRecordingModalOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsRecording, setDetailsRecording] = useState(null); - const [busyRuleId, setBusyRuleId] = useState(null); + const [ruleModal, setRuleModal] = useState({ open: false, ruleId: null }); + const [editRecording, setEditRecording] = useState(null); const openRecordingModal = () => { setRecordingModalOpen(true); @@ -644,50 +989,27 @@ const DVRPage = () => { }; const closeDetails = () => setDetailsOpen(false); + const openRuleModal = (recording) => { + const ruleId = recording?.custom_properties?.rule?.id; + if (!ruleId) { + openDetails(recording); + return; + } + setDetailsOpen(false); + setDetailsRecording(null); + setEditRecording(null); + setRuleModal({ open: true, ruleId }); + }; + + const closeRuleModal = () => setRuleModal({ open: false, ruleId: null }); + useEffect(() => { - // Ensure channels and recordings are loaded for this view if (!channels || Object.keys(channels).length === 0) { fetchChannels(); } fetchRecordings(); fetchRecurringRules(); - }, []); - - const handleDeleteRule = async (ruleId) => { - setBusyRuleId(ruleId); - try { - await API.deleteRecurringRule(ruleId); - await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ - title: 'Recurring rule removed', - message: 'Future recordings for this rule were cancelled', - color: 'red', - autoClose: 2500, - }); - } catch (error) { - console.error('Failed to delete recurring rule', error); - } finally { - setBusyRuleId(null); - } - }; - - const handleToggleRule = async (rule, enabled) => { - setBusyRuleId(rule.id); - try { - await API.updateRecurringRule(rule.id, { enabled }); - await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ - title: enabled ? 'Recurring rule enabled' : 'Recurring rule paused', - message: enabled ? 'Future occurrences will be scheduled automatically' : 'Upcoming recordings removed', - color: enabled ? 'green' : 'yellow', - autoClose: 2500, - }); - } catch (error) { - console.error('Failed to update recurring rule', error); - } finally { - setBusyRuleId(null); - } - }; + }, [channels, fetchChannels, fetchRecordings, fetchRecurringRules]); // Re-render every second so time-based bucketing updates without a refresh const [now, setNow] = useState(dayjs()); @@ -761,7 +1083,7 @@ const DVRPage = () => { }); completed.sort((a, b) => dayjs(b.end_time) - dayjs(a.end_time)); return { inProgress: inProgressDedup, upcoming: upcomingGrouped, completed }; - }, [recordings]); + }, [recordings, now]); return ( @@ -781,56 +1103,6 @@ const DVRPage = () => { New Recording -
- - Recurring Rules - {recurringRules.length} - - {recurringRules.length === 0 ? ( - - No recurring rules yet. Create one from the New Recording dialog. - - ) : ( - - {recurringRules.map((rule) => { - const ch = channels?.[rule.channel]; - const channelName = ch?.name || `Channel ${rule.channel}`; - const range = `${formatRuleTime(rule.start_time)} – ${formatRuleTime(rule.end_time)}`; - const days = formatRuleDays(rule.days_of_week); - return ( - - - - - {channelName} - {!rule.enabled && Paused} - - {days} • {range} - - - handleToggleRule(rule, event.currentTarget.checked)} - disabled={busyRuleId === rule.id} - /> - handleDeleteRule(rule.id)} - disabled={busyRuleId === rule.id} - > - - - - - - ); - })} - - )} -
-
Currently Recording @@ -838,7 +1110,7 @@ const DVRPage = () => { {inProgress.map((rec) => ( - + ))} {inProgress.length === 0 && ( @@ -855,7 +1127,7 @@ const DVRPage = () => { {upcoming.map((rec) => ( - + ))} {upcoming.length === 0 && ( @@ -872,7 +1144,7 @@ const DVRPage = () => { {completed.map((rec) => ( - + ))} {completed.length === 0 && ( @@ -888,6 +1160,22 @@ const DVRPage = () => { onClose={closeRecordingModal} /> + setEditRecording(null)} + /> + + { + setRuleModal({ open: false, ruleId: null }); + setEditRecording(occ); + }} + /> + {/* Details Modal */} {detailsRecording && ( { } useVideoStore.getState().showVideo(fileUrl, 'vod', { name: detailsRecording.custom_properties?.program?.title || 'Recording', logo: { url: (detailsRecording.custom_properties?.poster_logo_id ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/` : channels[detailsRecording.channel]?.logo?.cache_url) || '/logo.png' } }); }} + onEdit={(rec) => { + setEditRecording(rec); + closeDetails(); + }} /> )} From 6536f35dc0e7d01b060d646f4dcb2ddb4b3f5ca8 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Fri, 19 Sep 2025 19:47:59 -0500 Subject: [PATCH 005/168] FIxed bug Fixed bug that stopped stream from ending --- apps/channels/serializers.py | 14 ++- apps/channels/tasks.py | 11 +- frontend/src/components/forms/Recording.jsx | 120 +++++++++++--------- frontend/src/pages/DVR.jsx | 19 ++-- 4 files changed, 93 insertions(+), 71 deletions(-) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index d41bebed1..1fa2b68a0 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -528,26 +528,28 @@ def validate_days_of_week(self, value): return sorted(set(cleaned)) def validate(self, attrs): - from django.utils import timezone start = attrs.get("start_time") or getattr(self.instance, "start_time", None) end = attrs.get("end_time") or getattr(self.instance, "end_time", None) if start and end and end <= start: raise serializers.ValidationError("End time must be after start time") start_date = attrs.get("start_date") if "start_date" in attrs else getattr(self.instance, "start_date", None) end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None) + if start_date is None: + existing_start = getattr(self.instance, "start_date", None) + if existing_start is None: + raise serializers.ValidationError("Start date is required") if start_date and end_date and end_date < start_date: raise serializers.ValidationError("End date must be on or after start date") + if end_date is None: + existing_end = getattr(self.instance, "end_date", None) + if existing_end is None: + raise serializers.ValidationError("End date is required") # Normalize empty strings to None for dates if attrs.get("end_date") == "": attrs["end_date"] = None if attrs.get("start_date") == "": attrs["start_date"] = None - if attrs.get("start_date") is None and not getattr(self.instance, "start_date", None): - attrs["start_date"] = timezone.localdate() return super().validate(attrs) def create(self, validated_data): - from django.utils import timezone - if not validated_data.get("start_date"): - validated_data["start_date"] = timezone.localdate() return super().create(validated_data) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 540934fcb..688dc79da 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1145,9 +1145,12 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d end_limit = rule.end_date horizon = now + timedelta(days=horizon_days) start_window = max(start_limit, now.date()) - end_window = horizon.date() - if end_limit and end_limit < end_window: + if drop_existing and end_limit: end_window = end_limit + else: + end_window = horizon.date() + if end_limit and end_limit < end_window: + end_window = end_limit if end_window < start_window: return 0 total_created = 0 @@ -1163,7 +1166,9 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d end_dt = timezone.make_aware(datetime.combine(target_date, rule.end_time), tz) except Exception: continue - if end_dt <= start_dt or start_dt <= now: + if end_dt <= start_dt: + end_dt = end_dt + timedelta(days=1) + if start_dt <= now: continue exists = Recording.objects.filter( channel=rule.channel, diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 8203cc208..90080676e 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -10,7 +10,6 @@ import { SegmentedControl, MultiSelect, Group, - Text, TextInput, } from '@mantine/core'; import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; @@ -41,13 +40,12 @@ const toIsoIfDate = (value) => { return dt ? dt.toISOString() : value; }; +// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" const toTimeString = (value) => { if (!value) return '00:00'; if (typeof value === 'string') { - const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true); - if (parsed.isValid()) { - return parsed.format('HH:mm'); - } + const parsed = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], true); + if (parsed.isValid()) return parsed.format('HH:mm'); return value; } const dt = asDate(value); @@ -69,12 +67,16 @@ const createRoundedDate = (minutesAhead = 0) => { dt.setSeconds(0); dt.setMilliseconds(0); dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) { - dt.setMinutes(dt.getMinutes() + minutesAhead); - } + if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); return dt; }; +// robust onChange for TimeInput (string or event) +const timeChange = (setter) => (valOrEvent) => { + if (typeof valOrEvent === 'string') setter(valOrEvent); + else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); +}; + const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => { const channels = useChannelsStore((s) => s.channels); const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); @@ -87,14 +89,11 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = const defaultEnd = createRoundedDate(60); const defaultDate = new Date(); + // One-time form const singleForm = useForm({ mode: 'controlled', initialValues: { - channel_id: recording - ? `${recording.channel}` - : channel - ? `${channel.id}` - : '', + channel_id: recording ? `${recording.channel}` : channel ? `${channel.id}` : '', start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart, end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd, }, @@ -111,8 +110,11 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = }, }); + // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', + validateInputOnChange: false, + validateInputOnBlur: true, initialValues: { channel_id: channel ? `${channel.id}` : '', days_of_week: [], @@ -120,34 +122,38 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = end_time: dayjs(defaultEnd).format('HH:mm'), rule_name: '', start_date: defaultDate, - end_date: null, + end_date: defaultDate, }, validate: { channel_id: isNotEmpty('Select a channel'), days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'), - start_time: isNotEmpty('Select a start time'), + start_time: (value) => (value ? null : 'Select a start time'), end_time: (value, values) => { - const start = asDate(values.start_time); - const end = asDate(value); - if (!end) return 'Select an end time'; - if (start && end <= start) return 'End time must be after start time'; + if (!value) return 'Select an end time'; + const start = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + if (start.isValid() && end.isValid() && end.diff(start, 'minute') === 0) { + return 'End time must differ from start time'; + } return null; }, end_date: (value, values) => { const end = asDate(value); const start = asDate(values.start_date); - if (end && start && end < start) { - return 'End date cannot be before start date'; - } + if (!end) return 'Select an end date'; + if (start && end < start) return 'End date cannot be before start date'; return null; }, }, }); useEffect(() => { - if (!isOpen) { - return; - } + if (!isOpen) return; + + const freshStart = createRoundedDate(); + const freshEnd = createRoundedDate(60); + const freshDate = new Date(); + if (recording && recording.id) { setMode('single'); singleForm.setValues({ @@ -156,23 +162,22 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = end_time: asDate(recording.end_time) || defaultEnd, }); } else { - // Reset forms to defaults when opening fresh - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); + // Reset forms for fresh open singleForm.setValues({ channel_id: channel ? `${channel.id}` : '', start_time: freshStart, end_time: freshEnd, }); + + const startStr = dayjs(freshStart).format('HH:mm'); recurringForm.setValues({ channel_id: channel ? `${channel.id}` : '', days_of_week: [], - start_time: dayjs(freshStart).format('HH:mm'), + start_time: startStr, end_time: dayjs(freshEnd).format('HH:mm'), rule_name: channel?.name || '', start_date: freshDate, - end_date: null, + end_date: freshDate, }); setMode('single'); } @@ -184,9 +189,7 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = list.sort((a, b) => { const aNum = Number(a.channel_number) || 0; const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) { - return (a.name || '').localeCompare(b.name || ''); - } + if (aNum === bNum) return (a.name || '').localeCompare(b.name || ''); return aNum - bNum; }); return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` })); @@ -252,6 +255,7 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = end_date: toDateString(values.end_date), name: values.rule_name?.trim() || '', }); + await Promise.all([fetchRecurringRules(), fetchRecordings()]); notifications.show({ title: 'Recurring rule saved', @@ -267,13 +271,12 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = } }; - const onSubmit = mode === 'single' - ? singleForm.onSubmit(handleSingleSubmit) - : recurringForm.onSubmit(handleRecurringSubmit); + const onSubmit = + mode === 'single' + ? singleForm.onSubmit(handleSingleSubmit) + : recurringForm.onSubmit(handleRecurringSubmit); - if (!isOpen) { - return null; - } + if (!isOpen) return null; return ( @@ -326,15 +329,15 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = {...singleForm.getInputProps('start_time')} key={singleForm.key('start_time')} label="Start" - valueFormat="MMM D, YYYY hh:mm A" - timeInputProps={{ format: '12', amLabel: 'AM', pmLabel: 'PM', withSeconds: false }} + valueFormat="MMM D, YYYY h:mm A" + timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }} /> ) : ( @@ -353,41 +356,52 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = data={DAY_OPTIONS} searchable clearable - nothingFound="No match" + nothingFoundMessage="No match" /> + recurringForm.setFieldValue('start_date', value || new Date())} + onChange={(value) => + recurringForm.setFieldValue('start_date', value || new Date()) + } valueFormat="MMM D, YYYY" /> recurringForm.setFieldValue('end_date', value)} valueFormat="MMM D, YYYY" minDate={recurringForm.values.start_date || undefined} /> + recurringForm.setFieldValue('start_time', value)} label="Start time" + value={recurringForm.values.start_time} + onChange={timeChange((val) => + recurringForm.setFieldValue('start_time', toTimeString(val)) + )} + onBlur={() => recurringForm.validateField('start_time')} withSeconds={false} - format="12" + format="12" // shows 12-hour (so "00:00" renders "12:00 AM") + inputMode="numeric" amLabel="AM" pmLabel="PM" /> + recurringForm.setFieldValue('end_time', value)} label="End time" + value={recurringForm.values.end_time} + onChange={timeChange((val) => + recurringForm.setFieldValue('end_time', toTimeString(val)) + )} + onBlur={() => recurringForm.validateField('end_time')} withSeconds={false} format="12" + inputMode="numeric" amLabel="AM" pmLabel="PM" /> diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index ec6ebbff4..83faae061 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -375,23 +375,26 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { start_time: dayjs().startOf('hour').format('HH:mm'), end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), start_date: dayjs().toDate(), - end_date: null, + end_date: dayjs().toDate(), enabled: true, }, validate: { channel_id: (value) => (value ? null : 'Select a channel'), days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'), end_time: (value, values) => { - const startValue = dayjs(values.start_time); - const endValue = dayjs(value); if (!value) return 'Select an end time'; - if (endValue.isSameOrBefore(startValue)) return 'End time must be after start time'; + const startValue = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + if (startValue.isValid() && endValue.isValid() && endValue.diff(startValue, 'minute') === 0) { + return 'End time must differ from start time'; + } return null; }, end_date: (value, values) => { const endDate = dayjs(value); const startDate = dayjs(values.start_date); - if (value && startDate.isValid() && endDate.isBefore(startDate, 'day')) { + if (!value) return 'Select an end date'; + if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { return 'End date cannot be before start date'; } return null; @@ -564,8 +567,6 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { /> form.setFieldValue('end_date', value)} valueFormat="MMM D, YYYY" @@ -576,7 +577,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { form.setFieldValue('start_time', value)} + onChange={(value) => form.setFieldValue('start_time', toTimeString(value))} withSeconds={false} format="12" amLabel="AM" @@ -585,7 +586,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { form.setFieldValue('end_time', value)} + onChange={(value) => form.setFieldValue('end_time', toTimeString(value))} withSeconds={false} format="12" amLabel="AM" From fd9038463b706fc253f197a3c8028dbce9cb2cfa Mon Sep 17 00:00:00 2001 From: Connor Smith Date: Sun, 21 Sep 2025 01:23:57 -0400 Subject: [PATCH 006/168] Allow for batch editing channel logos --- .../src/components/forms/ChannelBatch.jsx | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index ad61fb263..134807d4d 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -29,18 +29,32 @@ import { FixedSizeList as List } from 'react-window'; import { useForm } from '@mantine/form'; import { notifications } from '@mantine/notifications'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; +import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; +import LazyLogo from '../LazyLogo'; +import logo from '../../images/logo.png'; const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const theme = useMantineTheme(); const groupListRef = useRef(null); + const logoListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const { + logos: channelLogos, + ensureLogosLoaded, + isLoading: logosLoading, + } = useChannelLogoSelection(); + + useEffect(() => { + ensureLogosLoaded(); + }, [ensureLogosLoaded]); const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); + const [selectedLogoId, setSelectedLogoId] = useState('-1'); const [isSubmitting, setIsSubmitting] = useState(false); const [regexFind, setRegexFind] = useState(''); const [regexReplace, setRegexReplace] = useState(''); @@ -49,10 +63,14 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const [groupFilter, setGroupFilter] = useState(''); const groupOptions = Object.values(channelGroups); + const [logoPopoverOpened, setLogoPopoverOpened] = useState(false); + const [logoFilter, setLogoFilter] = useState(''); + const form = useForm({ mode: 'uncontrolled', initialValues: { channel_group: '(no change)', + logo: '(no change)', stream_profile_id: '-1', user_level: '-1', }, @@ -70,6 +88,15 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { delete values.channel_group_id; } + if (selectedLogoId && selectedLogoId !== '-1') { + if (selectedLogoId === '0') { + values.logo_id = null; + } else { + values.logo_id = parseInt(selectedLogoId); + } + } + delete values.logo; + // Handle stream profile ID - convert special values if (!values.stream_profile_id || values.stream_profile_id === '-1') { delete values.stream_profile_id; @@ -242,6 +269,18 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { ), ]; + const logoOptions = useMemo(() => { + return [ + { id: '-1', name: '(no change)' }, + { id: '0', name: '(remove logo)' }, + ...Object.values(channelLogos), + ]; + }, [channelLogos]); + + const filteredLogos = logoOptions.filter((logo) => + logo.name.toLowerCase().includes(logoFilter.toLowerCase()) + ); + if (!isOpen) { return <>; } @@ -445,6 +484,153 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { + + + + setLogoPopoverOpened(true)} + size="xs" + style={{ flex: 1 }} + rightSection={ + selectedLogoId !== '-1' && ( + { + e.stopPropagation(); + setSelectedLogoId('-1'); + form.setValues({ logo: '(no change)' }); + }} + > + + + ) + } + /> + + e.stopPropagation()}> + + + setLogoFilter(event.currentTarget.value) + } + mb="xs" + size="xs" + /> + {logosLoading && ( + + Loading... + + )} + + + {filteredLogos.length === 0 ? ( +
+ + {logoFilter + ? 'No logos match your filter' + : 'No logos available'} + +
+ ) : ( + + {({ index, style }) => ( +
{ + setSelectedLogoId(filteredLogos[index].id); + form.setValues({ + logo: filteredLogos[index].name, + }); + setLogoPopoverOpened(false); + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = + 'rgb(68, 68, 68)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = + 'transparent'; + }} + > +
+ {filteredLogos[index].id > 0 ? ( + {filteredLogos[index].name { + if (e.target.src !== logo) { + e.target.src = logo; + } + }} + /> + ) : ( + + )} + + {filteredLogos[index].name} + +
+
+ )} +
+ )} +
+
+
+ {selectedLogoId > 0 && ( + + )} +
+ + Date: Sun, 5 Oct 2025 19:32:40 -0500 Subject: [PATCH 022/168] Bug fix: Ensure distinct channel results in generate_m3u, generate_epg, and xc_get_live_streams functions. Fixes duplicate channels output for streamer profiles that were set to "All" --- apps/output/views.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/output/views.py b/apps/output/views.py index 1666013de..e4d05d595 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -62,7 +62,7 @@ def generate_m3u(request, profile_name=None, user=None): channel_profiles ) - channels = Channel.objects.filter(**filters).order_by("channel_number") + channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( "channel_number" @@ -326,7 +326,7 @@ def epg_generator(): channel_profiles ) - channels = Channel.objects.filter(**filters).order_by("channel_number") + channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( "channel_number" @@ -910,7 +910,7 @@ def xc_get_live_streams(request, user, category_id=None): if category_id is not None: filters["channel_group__id"] = category_id - channels = Channel.objects.filter(**filters).order_by("channel_number") + channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: if not category_id: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number") @@ -966,7 +966,10 @@ def xc_get_epg(request, user, short=False): channel_profiles = user.channel_profiles.all() filters["channelprofilemembership__channel_profile__in"] = channel_profiles - channel = get_object_or_404(Channel, **filters) + # Use filter().first() with distinct instead of get_object_or_404 to handle multiple profile memberships + channel = Channel.objects.filter(**filters).distinct().first() + if not channel: + raise Http404() else: channel = get_object_or_404(Channel, id=channel_id) From dea6411e1ca7200d95b6f6ffceb1c55a5a61aa5b Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Mon, 6 Oct 2025 07:46:23 -0500 Subject: [PATCH 023/168] Time Zones - Added time zone settings --- ...ecurringrecordingrule_end_date_and_more.py | 23 + apps/channels/serializers.py | 11 +- apps/channels/tasks.py | 11 +- core/models.py | 26 + frontend/src/pages/DVR.jsx | 866 ++++++++++++++---- frontend/src/pages/Settings.jsx | 218 ++++- 6 files changed, 948 insertions(+), 207 deletions(-) create mode 100644 apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py diff --git a/apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py b/apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py new file mode 100644 index 000000000..8cdb9868f --- /dev/null +++ b/apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2025-10-05 20:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0026_recurringrecordingrule'), + ] + + operations = [ + migrations.AddField( + model_name='recurringrecordingrule', + name='end_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='recurringrecordingrule', + name='start_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 1fa2b68a0..7058ced23 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -1,4 +1,6 @@ import json +from datetime import datetime + from rest_framework import serializers from .models import ( Stream, @@ -530,8 +532,6 @@ def validate_days_of_week(self, value): def validate(self, attrs): start = attrs.get("start_time") or getattr(self.instance, "start_time", None) end = attrs.get("end_time") or getattr(self.instance, "end_time", None) - if start and end and end <= start: - raise serializers.ValidationError("End time must be after start time") start_date = attrs.get("start_date") if "start_date" in attrs else getattr(self.instance, "start_date", None) end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None) if start_date is None: @@ -544,6 +544,13 @@ def validate(self, attrs): existing_end = getattr(self.instance, "end_date", None) if existing_end is None: raise serializers.ValidationError("End date is required") + if start and end and start_date and end_date: + start_dt = datetime.combine(start_date, start) + end_dt = datetime.combine(end_date, end) + if end_dt <= start_dt: + raise serializers.ValidationError("End datetime must be after start datetime") + elif start and end and end == start: + raise serializers.ValidationError("End time must be different from start time") # Normalize empty strings to None for dates if attrs.get("end_date") == "": attrs["end_date"] = None diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 688dc79da..23ae82b23 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -8,6 +8,7 @@ import json import subprocess import signal +from zoneinfo import ZoneInfo from datetime import datetime, timedelta import gc @@ -1140,7 +1141,12 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d if not days: return 0 - tz = timezone.get_current_timezone() + tz_name = CoreSettings.get_system_time_zone() + try: + tz = ZoneInfo(tz_name) + except Exception: + logger.warning("Invalid or unsupported time zone '%s'; falling back to Server default", tz_name) + tz = timezone.get_current_timezone() start_limit = rule.start_date or now.date() end_limit = rule.end_date horizon = now + timedelta(days=horizon_days) @@ -2152,7 +2158,8 @@ def _ffprobe_duration(path): list_path = os.path.join(workdir, "concat_list.txt") with open(list_path, "w") as lf: for pth in parts: - lf.write(f"file '{pth}'\n") + escaped = pth.replace("'", "'\\''") + lf.write(f"file '{escaped}'\n") output_path = os.path.join(workdir, f"{os.path.splitext(os.path.basename(file_path))[0]}.cut.mkv") subprocess.run([ diff --git a/core/models.py b/core/models.py index 5584d7ca1..3a5895ba6 100644 --- a/core/models.py +++ b/core/models.py @@ -1,4 +1,5 @@ # core/models.py +from django.conf import settings from django.db import models from django.utils.text import slugify from django.core.exceptions import ValidationError @@ -161,6 +162,7 @@ def _replace_in_part(self, part, replacements): DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path") DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") +SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone") class CoreSettings(models.Model): @@ -324,6 +326,30 @@ def get_dvr_post_offset_minutes(cls): except Exception: return 0 + @classmethod + def get_system_time_zone(cls): + """Return configured system time zone or fall back to Django settings.""" + try: + value = cls.objects.get(key=SYSTEM_TIME_ZONE_KEY).value + if value: + return value + except cls.DoesNotExist: + pass + return getattr(settings, "TIME_ZONE", "UTC") or "UTC" + + @classmethod + def set_system_time_zone(cls, tz_name: str | None): + """Persist the desired system time zone identifier.""" + value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC" + obj, _ = cls.objects.get_or_create( + key=SYSTEM_TIME_ZONE_KEY, + defaults={"name": "System Time Zone", "value": value}, + ) + if obj.value != value: + obj.value = value + obj.save(update_fields=["value"]) + return value + @classmethod def get_dvr_series_rules(cls): """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 83faae061..ae2fd4cad 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { ActionIcon, Box, @@ -36,8 +36,11 @@ import { import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; import useChannelsStore from '../store/channels'; import useSettingsStore from '../store/settings'; +import useLocalStorage from '../hooks/useLocalStorage'; import useVideoStore from '../store/useVideoStore'; import RecordingForm from '../components/forms/Recording'; import { notifications } from '@mantine/notifications'; @@ -47,6 +50,47 @@ import { useForm } from '@mantine/form'; dayjs.extend(duration); dayjs.extend(relativeTime); +dayjs.extend(utc); +dayjs.extend(timezone); + +const useUserTimeZone = () => { + const settings = useSettingsStore((s) => s.settings); + const [timeZone, setTimeZone] = useLocalStorage( + 'time-zone', + dayjs.tz?.guess + ? dayjs.tz.guess() + : Intl.DateTimeFormat().resolvedOptions().timeZone + ); + + useEffect(() => { + const tz = settings?.['system-time-zone']?.value; + if (tz && tz !== timeZone) { + setTimeZone(tz); + } + }, [settings, timeZone, setTimeZone]); + + return timeZone; +}; + +const useTimeHelpers = () => { + const timeZone = useUserTimeZone(); + + const toUserTime = useCallback( + (value) => { + if (!value) return dayjs.invalid(); + try { + return dayjs(value).tz(timeZone); + } catch (error) { + return dayjs(value); + } + }, + [timeZone] + ); + + const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]); + + return { timeZone, toUserTime, userNow }; +}; const RECURRING_DAY_OPTIONS = [ { value: 6, label: 'Sun' }, @@ -61,7 +105,9 @@ const RECURRING_DAY_OPTIONS = [ // Short preview that triggers the details modal when clicked const RecordingSynopsis = ({ description, onOpen }) => { const truncated = description?.length > 140; - const preview = truncated ? `${description.slice(0, 140).trim()}...` : description; + const preview = truncated + ? `${description.slice(0, 140).trim()}...` + : description; if (!description) return null; return ( { ); }; -const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, onWatchLive, onWatchRecording, env_mode, onEdit }) => { +const RecordingDetailsModal = ({ + opened, + onClose, + recording, + channel, + posterUrl, + onWatchLive, + onWatchRecording, + env_mode, + onEdit, +}) => { const allRecordings = useChannelsStore((s) => s.recordings); const channelMap = useChannelsStore((s) => s.channels); + const { toUserTime, userNow } = useTimeHelpers(); const [childOpen, setChildOpen] = React.useState(false); const [childRec, setChildRec] = React.useState(null); @@ -88,13 +145,17 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, const program = customProps.program || {}; const recordingName = program.title || 'Custom Recording'; const description = program.description || customProps.description || ''; - const start = dayjs(safeRecording.start_time); - const end = dayjs(safeRecording.end_time); + const start = toUserTime(safeRecording.start_time); + const end = toUserTime(safeRecording.end_time); const stats = customProps.stream_info || {}; const statRows = [ ['Video Codec', stats.video_codec], - ['Resolution', stats.resolution || (stats.width && stats.height ? `${stats.width}x${stats.height}` : null)], + [ + 'Resolution', + stats.resolution || + (stats.width && stats.height ? `${stats.width}x${stats.height}` : null), + ], ['FPS', stats.source_fps], ['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`], ['Audio Codec', stats.audio_codec], @@ -104,34 +165,48 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, ].filter(([, v]) => v !== null && v !== undefined && v !== ''); // Rating (if available) - const rating = customProps.rating || customProps.rating_value || (program && program.custom_properties && program.custom_properties.rating); + const rating = + customProps.rating || + customProps.rating_value || + (program && program.custom_properties && program.custom_properties.rating); const ratingSystem = customProps.rating_system || 'MPAA'; const fileUrl = customProps.file_url || customProps.output_file_url; - const canWatchRecording = (customProps.status === 'completed' || customProps.status === 'interrupted') && Boolean(fileUrl); + const canWatchRecording = + (customProps.status === 'completed' || + customProps.status === 'interrupted') && + Boolean(fileUrl); // Prefix in dev (Vite) if needed let resolvedPosterUrl = posterUrl; - if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV) { + if ( + typeof import.meta !== 'undefined' && + import.meta.env && + import.meta.env.DEV + ) { if (resolvedPosterUrl && resolvedPosterUrl.startsWith('/')) { resolvedPosterUrl = `${window.location.protocol}//${window.location.hostname}:5656${resolvedPosterUrl}`; } } - const isSeriesGroup = Boolean(safeRecording._group_count && safeRecording._group_count > 1); + const isSeriesGroup = Boolean( + safeRecording._group_count && safeRecording._group_count > 1 + ); const upcomingEpisodes = React.useMemo(() => { if (!isSeriesGroup) return []; - const arr = Array.isArray(allRecordings) ? allRecordings : Object.values(allRecordings || {}); + const arr = Array.isArray(allRecordings) + ? allRecordings + : Object.values(allRecordings || {}); const tvid = program.tvg_id || ''; const titleKey = (program.title || '').toLowerCase(); const filtered = arr.filter((r) => { - const cp = r.custom_properties || {}; - const pr = cp.program || {}; - if ((pr.tvg_id || '') !== tvid) return false; - if ((pr.title || '').toLowerCase() !== titleKey) return false; - const st = dayjs(r.start_time); - return st.isAfter(dayjs()); - }); + const cp = r.custom_properties || {}; + const pr = cp.program || {}; + if ((pr.tvg_id || '') !== tvid) return false; + if ((pr.title || '').toLowerCase() !== titleKey) return false; + const st = toUserTime(r.start_time); + return st.isAfter(userNow()); + }); // Deduplicate by program.id if present, else by time+title const seen = new Set(); const deduped = []; @@ -141,54 +216,117 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, // Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot const season = cp.season ?? pr?.custom_properties?.season; const episode = cp.episode ?? pr?.custom_properties?.episode; - const onscreen = cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode; + const onscreen = + cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode; let key = null; if (season != null && episode != null) key = `se:${season}:${episode}`; else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`; else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`; else if (pr.id != null) key = `id:${pr.id}`; - else key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${(pr.title||'')}`; + else + key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`; if (seen.has(key)) continue; seen.add(key); deduped.push(r); } - return deduped.sort((a, b) => dayjs(a.start_time) - dayjs(b.start_time)); - }, [allRecordings, isSeriesGroup, program.tvg_id, program.title]); + return deduped.sort( + (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time) + ); + }, [ + allRecordings, + isSeriesGroup, + program.tvg_id, + program.title, + toUserTime, + userNow, + ]); if (!recording) return null; const EpisodeRow = ({ rec }) => { const cp = rec.custom_properties || {}; const pr = cp.program || {}; - const start = dayjs(rec.start_time); - const end = dayjs(rec.end_time); + const start = toUserTime(rec.start_time); + const end = toUserTime(rec.end_time); const season = cp.season ?? pr?.custom_properties?.season; const episode = cp.episode ?? pr?.custom_properties?.episode; - const onscreen = cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode; - const se = season && episode ? `S${String(season).padStart(2,'0')}E${String(episode).padStart(2,'0')}` : (onscreen || null); + const onscreen = + cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode; + const se = + season && episode + ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` + : onscreen || null; const posterLogoId = cp.poster_logo_id; - let purl = posterLogoId ? `/api/channels/logos/${posterLogoId}/cache/` : cp.poster_url || posterUrl || '/logo.png'; - if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV && purl && purl.startsWith('/')) { + let purl = posterLogoId + ? `/api/channels/logos/${posterLogoId}/cache/` + : cp.poster_url || posterUrl || '/logo.png'; + if ( + typeof import.meta !== 'undefined' && + import.meta.env && + import.meta.env.DEV && + purl && + purl.startsWith('/') + ) { purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`; } const onRemove = async (e) => { e?.stopPropagation?.(); - try { await API.deleteRecording(rec.id); } catch (error) { console.error('Failed to delete upcoming recording', error); } - try { await useChannelsStore.getState().fetchRecordings(); } catch (error) { console.error('Failed to refresh recordings after delete', error); } + try { + await API.deleteRecording(rec.id); + } catch (error) { + console.error('Failed to delete upcoming recording', error); + } + try { + await useChannelsStore.getState().fetchRecordings(); + } catch (error) { + console.error('Failed to refresh recordings after delete', error); + } }; return ( - { setChildRec(rec); setChildOpen(true); }}> + { + setChildRec(rec); + setChildOpen(true); + }} + > - {pr.title + {pr.title - {pr.sub_title || pr.title} - {se && {se}} + + {pr.sub_title || pr.title} + + {se && ( + + {se} + + )} - {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} + + {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} + - + @@ -199,7 +337,11 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, {upcomingEpisodes.length === 0 && ( - No upcoming episodes found + + No upcoming episodes found + )} {upcomingEpisodes.map((ep) => ( @@ -225,17 +369,19 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, onClose={() => setChildOpen(false)} recording={childRec} channel={channelMap[childRec.channel]} - posterUrl={( - childRec.custom_properties?.poster_logo_id + posterUrl={ + (childRec.custom_properties?.poster_logo_id ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/` - : childRec.custom_properties?.poster_url || channelMap[childRec.channel]?.logo?.cache_url - ) || '/logo.png'} + : childRec.custom_properties?.poster_url || + channelMap[childRec.channel]?.logo?.cache_url) || + '/logo.png' + } env_mode={env_mode} onWatchLive={() => { const rec = childRec; - const now = dayjs(); - const s = dayjs(rec.start_time); - const e = dayjs(rec.end_time); + const now = userNow(); + const s = toUserTime(rec.start_time); + const e = toUserTime(rec.end_time); if (now.isAfter(s) && now.isBefore(e)) { const ch = channelMap[rec.channel]; if (!ch) return; @@ -247,77 +393,142 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, } }} onWatchRecording={() => { - let fileUrl = childRec.custom_properties?.file_url || childRec.custom_properties?.output_file_url; + let fileUrl = + childRec.custom_properties?.file_url || + childRec.custom_properties?.output_file_url; if (!fileUrl) return; if (env_mode === 'dev' && fileUrl.startsWith('/')) { fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; } - useVideoStore.getState().showVideo(fileUrl, 'vod', { name: childRec.custom_properties?.program?.title || 'Recording', logo: { url: (childRec.custom_properties?.poster_logo_id ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/` : channelMap[childRec.channel]?.logo?.cache_url) || '/logo.png' } }); + useVideoStore.getState().showVideo(fileUrl, 'vod', { + name: + childRec.custom_properties?.program?.title || 'Recording', + logo: { + url: + (childRec.custom_properties?.poster_logo_id + ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/` + : channelMap[childRec.channel]?.logo?.cache_url) || + '/logo.png', + }, + }); }} /> )} ) : ( - - {recordingName} - - - {channel ? `${channel.channel_number} • ${channel.name}` : '—'} - - {onWatchLive && ( - - )} - {onWatchRecording && ( - - )} - {onEdit && start.isAfter(dayjs()) && ( - - )} - {customProps.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && ( - - )} - - - {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} - {rating && ( - - {rating} + + {recordingName} + + + + {channel ? `${channel.channel_number} • ${channel.name}` : '—'} + + + {onWatchLive && ( + + )} + {onWatchRecording && ( + + )} + {onEdit && start.isAfter(userNow()) && ( + + )} + {customProps.status === 'completed' && + (!customProps?.comskip || + customProps?.comskip?.status !== 'completed') && ( + + )} + - )} - {description && ( - {description} - )} - {statRows.length > 0 && ( - - Stream Stats - {statRows.map(([k, v]) => ( - - {k} - {v} - - ))} - - )} - - + + {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} + + {rating && ( + + + {rating} + + + )} + {description && ( + + {description} + + )} + {statRows.length > 0 && ( + + + Stream Stats + + {statRows.map(([k, v]) => ( + + + {k} + + {v} + + ))} + + )} + + )} ); @@ -346,6 +557,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); const recordings = useChannelsStore((s) => s.recordings); + const { toUserTime, userNow } = useTimeHelpers(); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); @@ -363,7 +575,10 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { } return aNum - bNum; }); - return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` })); + return list.map((item) => ({ + value: `${item.id}`, + label: item.name || `Channel ${item.id}`, + })); }, [channels]); const form = useForm({ @@ -380,12 +595,21 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { }, validate: { channel_id: (value) => (value ? null : 'Select a channel'), - days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'), + days_of_week: (value) => + value && value.length ? null : 'Pick at least one day', end_time: (value, values) => { if (!value) return 'Select an end time'; - const startValue = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + const startValue = dayjs( + values.start_time, + ['HH:mm', 'hh:mm A', 'h:mm A'], + true + ); const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if (startValue.isValid() && endValue.isValid() && endValue.diff(startValue, 'minute') === 0) { + if ( + startValue.isValid() && + endValue.isValid() && + endValue.diff(startValue, 'minute') === 0 + ) { return 'End time must differ from start time'; } return null; @@ -421,11 +645,22 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { }, [opened, ruleId, rule]); const upcomingOccurrences = useMemo(() => { - const list = Array.isArray(recordings) ? recordings : Object.values(recordings || {}); + const list = Array.isArray(recordings) + ? recordings + : Object.values(recordings || {}); + const now = userNow(); return list - .filter((rec) => rec?.custom_properties?.rule?.id === ruleId && dayjs(rec.start_time).isAfter(dayjs())) - .sort((a, b) => dayjs(a.start_time).valueOf() - dayjs(b.start_time).valueOf()); - }, [recordings, ruleId]); + .filter( + (rec) => + rec?.custom_properties?.rule?.id === ruleId && + toUserTime(rec.start_time).isAfter(now) + ) + .sort( + (a, b) => + toUserTime(a.start_time).valueOf() - + toUserTime(b.start_time).valueOf() + ); + }, [recordings, ruleId, toUserTime, userNow]); const handleSave = async (values) => { if (!rule) return; @@ -436,8 +671,12 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { days_of_week: (values.days_of_week || []).map((d) => Number(d)), start_time: toTimeString(values.start_time), end_time: toTimeString(values.end_time), - start_date: values.start_date ? dayjs(values.start_date).format('YYYY-MM-DD') : null, - end_date: values.end_date ? dayjs(values.end_date).format('YYYY-MM-DD') : null, + start_date: values.start_date + ? dayjs(values.start_date).format('YYYY-MM-DD') + : null, + end_date: values.end_date + ? dayjs(values.end_date).format('YYYY-MM-DD') + : null, name: values.rule_name?.trim() || '', enabled: Boolean(values.enabled), }); @@ -484,7 +723,9 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { await Promise.all([fetchRecurringRules(), fetchRecordings()]); notifications.show({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', - message: checked ? 'Future occurrences will resume' : 'Upcoming occurrences were removed', + message: checked + ? 'Future occurrences will resume' + : 'Upcoming occurrences were removed', color: checked ? 'green' : 'yellow', autoClose: 2500, }); @@ -523,10 +764,18 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { } return ( - + - {channels?.[rule.channel]?.name || `Channel ${rule.channel}`} + + {channels?.[rule.channel]?.name || `Channel ${rule.channel}`} + { ({ value: String(opt.value), label: opt.label }))} + data={RECURRING_DAY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} searchable clearable /> @@ -562,7 +814,9 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { form.setFieldValue('start_date', value || dayjs().toDate())} + onChange={(value) => + form.setFieldValue('start_date', value || dayjs().toDate()) + } valueFormat="MMM D, YYYY" /> { form.setFieldValue('start_time', toTimeString(value))} + onChange={(value) => + form.setFieldValue('start_time', toTimeString(value)) + } withSeconds={false} format="12" amLabel="AM" @@ -586,7 +842,9 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { form.setFieldValue('end_time', toTimeString(value))} + onChange={(value) => + form.setFieldValue('end_time', toTimeString(value)) + } withSeconds={false} format="12" amLabel="AM" @@ -597,7 +855,12 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { - @@ -605,22 +868,35 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { - Upcoming occurrences + + Upcoming occurrences + {upcomingOccurrences.length} {upcomingOccurrences.length === 0 ? ( - No future airings currently scheduled. + + No future airings currently scheduled. + ) : ( {upcomingOccurrences.map((occ) => { - const occStart = dayjs(occ.start_time); - const occEnd = dayjs(occ.end_time); + const occStart = toUserTime(occ.start_time); + const occEnd = toUserTime(occ.end_time); return ( - + - {occStart.format('MMM D, YYYY')} - {occStart.format('h:mma')} – {occEnd.format('h:mma')} + + {occStart.format('MMM D, YYYY')} + + + {occStart.format('h:mma')} – {occEnd.format('h:mma')} + )} {!isUpcoming && ( - + )} - {!isUpcoming && customProps?.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && ( - - )} + {!isUpcoming && + customProps?.status === 'completed' && + (!customProps?.comskip || + customProps?.comskip?.status !== 'completed') && ( + + )} {/* If this card is a grouped upcoming series, show count */} {recording._group_count > 1 && ( - + Next of {recording._group_count} )} @@ -922,12 +1309,27 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { // Stacked look for series groups: render two shadow layers behind the main card return ( - setCancelOpen(false)} title="Cancel Series" centered size="md" zIndex={9999}> + setCancelOpen(false)} + title="Cancel Series" + centered + size="md" + zIndex={9999} + > This is a series rule. What would you like to cancel? - - + + @@ -969,6 +1371,7 @@ const DVRPage = () => { const channels = useChannelsStore((s) => s.channels); const fetchChannels = useChannelsStore((s) => s.fetchChannels); const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); + const { toUserTime, userNow } = useTimeHelpers(); const [recordingModalOpen, setRecordingModalOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); @@ -1013,18 +1416,24 @@ const DVRPage = () => { }, [channels, fetchChannels, fetchRecordings, fetchRecurringRules]); // Re-render every second so time-based bucketing updates without a refresh - const [now, setNow] = useState(dayjs()); + const [now, setNow] = useState(userNow()); useEffect(() => { - const interval = setInterval(() => setNow(dayjs()), 1000); + const interval = setInterval(() => setNow(userNow()), 1000); return () => clearInterval(interval); - }, []); + }, [userNow]); + + useEffect(() => { + setNow(userNow()); + }, [userNow]); // Categorize recordings const { inProgress, upcoming, completed } = useMemo(() => { const inProgress = []; const upcoming = []; const completed = []; - const list = Array.isArray(recordings) ? recordings : Object.values(recordings || {}); + const list = Array.isArray(recordings) + ? recordings + : Object.values(recordings || {}); // ID-based dedupe guard in case store returns duplicates const seenIds = new Set(); @@ -1034,8 +1443,8 @@ const DVRPage = () => { if (seenIds.has(k)) continue; seenIds.add(k); } - const s = dayjs(rec.start_time); - const e = dayjs(rec.end_time); + const s = toUserTime(rec.start_time); + const e = toUserTime(rec.end_time); const status = rec.custom_properties?.status; if (status === 'interrupted' || status === 'completed') { completed.push(rec); @@ -1053,7 +1462,10 @@ const DVRPage = () => { for (const r of arr) { const cp = r.custom_properties || {}; const pr = cp.program || {}; - const sig = pr?.id != null ? `id:${pr.id}` : `slot:${r.channel}|${r.start_time}|${r.end_time}|${(pr.title||'')}`; + const sig = + pr?.id != null + ? `id:${pr.id}` + : `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`; if (sigs.has(sig)) continue; sigs.add(sig); out.push(r); @@ -1061,11 +1473,15 @@ const DVRPage = () => { return out; }; - const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort((a, b) => dayjs(b.start_time) - dayjs(a.start_time)); + const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort( + (a, b) => toUserTime(b.start_time) - toUserTime(a.start_time) + ); // Group upcoming by series title+tvg_id (keep only next episode) const grouped = new Map(); - const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort((a, b) => dayjs(a.start_time) - dayjs(b.start_time)); + const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort( + (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time) + ); for (const rec of upcomingDedup) { const cp = rec.custom_properties || {}; const prog = cp.program || {}; @@ -1082,9 +1498,13 @@ const DVRPage = () => { item._group_count = e.count; return item; }); - completed.sort((a, b) => dayjs(b.end_time) - dayjs(a.end_time)); - return { inProgress: inProgressDedup, upcoming: upcomingGrouped, completed }; - }, [recordings, now]); + completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time)); + return { + inProgress: inProgressDedup, + upcoming: upcomingGrouped, + completed, + }; + }, [recordings, now, toUserTime]); return ( @@ -1109,9 +1529,21 @@ const DVRPage = () => { Currently Recording {inProgress.length}
- + {inProgress.map((rec) => ( - + ))} {inProgress.length === 0 && ( @@ -1126,9 +1558,21 @@ const DVRPage = () => { Upcoming Recordings {upcoming.length} - + {upcoming.map((rec) => ( - + ))} {upcoming.length === 0 && ( @@ -1143,9 +1587,21 @@ const DVRPage = () => { Previously Recorded {completed.length} - + {completed.map((rec) => ( - + ))} {completed.length === 0 && ( @@ -1184,17 +1640,19 @@ const DVRPage = () => { onClose={closeDetails} recording={detailsRecording} channel={channels[detailsRecording.channel]} - posterUrl={( - detailsRecording.custom_properties?.poster_logo_id + posterUrl={ + (detailsRecording.custom_properties?.poster_logo_id ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/` - : detailsRecording.custom_properties?.poster_url || channels[detailsRecording.channel]?.logo?.cache_url - ) || '/logo.png'} + : detailsRecording.custom_properties?.poster_url || + channels[detailsRecording.channel]?.logo?.cache_url) || + '/logo.png' + } env_mode={useSettingsStore.getState().environment.env_mode} onWatchLive={() => { const rec = detailsRecording; - const now = dayjs(); - const s = dayjs(rec.start_time); - const e = dayjs(rec.end_time); + const now = userNow(); + const s = toUserTime(rec.start_time); + const e = toUserTime(rec.end_time); if (now.isAfter(s) && now.isBefore(e)) { // call into child RecordingCard behavior by constructing a URL like there const channel = channels[rec.channel]; @@ -1207,12 +1665,28 @@ const DVRPage = () => { } }} onWatchRecording={() => { - let fileUrl = detailsRecording.custom_properties?.file_url || detailsRecording.custom_properties?.output_file_url; + let fileUrl = + detailsRecording.custom_properties?.file_url || + detailsRecording.custom_properties?.output_file_url; if (!fileUrl) return; - if (useSettingsStore.getState().environment.env_mode === 'dev' && fileUrl.startsWith('/')) { + if ( + useSettingsStore.getState().environment.env_mode === 'dev' && + fileUrl.startsWith('/') + ) { fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; } - useVideoStore.getState().showVideo(fileUrl, 'vod', { name: detailsRecording.custom_properties?.program?.title || 'Recording', logo: { url: (detailsRecording.custom_properties?.poster_logo_id ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/` : channels[detailsRecording.channel]?.logo?.cache_url) || '/logo.png' } }); + useVideoStore.getState().showVideo(fileUrl, 'vod', { + name: + detailsRecording.custom_properties?.program?.title || + 'Recording', + logo: { + url: + (detailsRecording.custom_properties?.poster_logo_id + ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/` + : channels[detailsRecording.channel]?.logo?.cache_url) || + '/logo.png', + }, + }); }} onEdit={(rec) => { setEditRecording(rec); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fa30cd74a..865358df1 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,4 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import API from '../api'; import useSettingsStore from '../store/settings'; import useUserAgentsStore from '../store/userAgents'; @@ -35,6 +41,140 @@ import { import ConfirmationDialog from '../components/ConfirmationDialog'; import useWarningsStore from '../store/warnings'; +const TIMEZONE_FALLBACKS = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Phoenix', + 'America/Anchorage', + 'Pacific/Honolulu', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Warsaw', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Australia/Sydney', +]; + +const getSupportedTimeZones = () => { + try { + if (typeof Intl.supportedValuesOf === 'function') { + return Intl.supportedValuesOf('timeZone'); + } + } catch (error) { + console.warn('Unable to enumerate supported time zones:', error); + } + return TIMEZONE_FALLBACKS; +}; + +const getTimeZoneOffsetMinutes = (date, timeZone) => { + try { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + const parts = dtf.formatToParts(date).reduce((acc, part) => { + if (part.type !== 'literal') acc[part.type] = part.value; + return acc; + }, {}); + const asUTC = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + return (asUTC - date.getTime()) / 60000; + } catch (error) { + console.warn(`Failed to compute offset for ${timeZone}:`, error); + return 0; + } +}; + +const formatOffset = (minutes) => { + const rounded = Math.round(minutes); + const sign = rounded < 0 ? '-' : '+'; + const absolute = Math.abs(rounded); + const hours = String(Math.floor(absolute / 60)).padStart(2, '0'); + const mins = String(absolute % 60).padStart(2, '0'); + return `UTC${sign}${hours}:${mins}`; +}; + +const buildTimeZoneOptions = (preferredZone) => { + const zones = getSupportedTimeZones(); + const referenceYear = new Date().getUTCFullYear(); + const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0)); + const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0)); + + const options = zones + .map((zone) => { + const janOffset = getTimeZoneOffsetMinutes(janDate, zone); + const julOffset = getTimeZoneOffsetMinutes(julDate, zone); + const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone); + const minOffset = Math.min(janOffset, julOffset); + const maxOffset = Math.max(janOffset, julOffset); + const usesDst = minOffset !== maxOffset; + const labelParts = [`now ${formatOffset(currentOffset)}`]; + if (usesDst) { + labelParts.push( + `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}` + ); + } + return { + value: zone, + label: `${zone} (${labelParts.join(' | ')})`, + numericOffset: minOffset, + }; + }) + .sort((a, b) => { + if (a.numericOffset !== b.numericOffset) { + return a.numericOffset - b.numericOffset; + } + return a.value.localeCompare(b.value); + }); + if ( + preferredZone && + !options.some((option) => option.value === preferredZone) + ) { + const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone); + options.push({ + value: preferredZone, + label: `${preferredZone} (now ${formatOffset(currentOffset)})`, + numericOffset: currentOffset, + }); + options.sort((a, b) => { + if (a.numericOffset !== b.numericOffset) { + return a.numericOffset - b.numericOffset; + } + return a.value.localeCompare(b.value); + }); + } + return options; +}; + +const getDefaultTimeZone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch (error) { + return 'UTC'; + } +}; + const SettingsPage = () => { const settings = useSettingsStore((s) => s.settings); const userAgents = useUserAgentsStore((s) => s.userAgents); @@ -61,12 +201,49 @@ const SettingsPage = () => { const [comskipFile, setComskipFile] = useState(null); const [comskipUploadLoading, setComskipUploadLoading] = useState(false); - const [comskipConfig, setComskipConfig] = useState({ path: '', exists: false }); + const [comskipConfig, setComskipConfig] = useState({ + path: '', + exists: false, + }); // UI / local storage settings const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h'); const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy'); + const [timeZone, setTimeZone] = useLocalStorage( + 'time-zone', + getDefaultTimeZone() + ); + const timeZoneOptions = useMemo( + () => buildTimeZoneOptions(timeZone), + [timeZone] + ); + const timeZoneSyncedRef = useRef(false); + + const persistTimeZoneSetting = useCallback( + async (tzValue) => { + try { + const existing = settings['system-time-zone']; + if (existing && existing.id) { + await API.updateSetting({ ...existing, value: tzValue }); + } else { + await API.createSetting({ + key: 'system-time-zone', + name: 'System Time Zone', + value: tzValue, + }); + } + } catch (error) { + console.error('Failed to persist time zone setting', error); + notifications.show({ + title: 'Failed to update time zone', + message: 'Could not save the selected time zone. Please try again.', + color: 'red', + }); + } + }, + [settings] + ); const regionChoices = REGION_CHOICES; @@ -187,8 +364,19 @@ const SettingsPage = () => { console.error('Error parsing proxy settings:', error); } } + + const tzSetting = settings['system-time-zone']; + if (tzSetting?.value) { + timeZoneSyncedRef.current = true; + setTimeZone((prev) => + prev === tzSetting.value ? prev : tzSetting.value + ); + } else if (!timeZoneSyncedRef.current && timeZone) { + timeZoneSyncedRef.current = true; + persistTimeZoneSetting(timeZone); + } } - }, [settings]); + }, [settings, timeZone, setTimeZone, persistTimeZoneSetting]); useEffect(() => { const loadComskipConfig = async () => { @@ -357,13 +545,19 @@ const SettingsPage = () => { const onUISettingsChange = (name, value) => { switch (name) { case 'table-size': - setTableSize(value); + if (value) setTableSize(value); break; case 'time-format': - setTimeFormat(value); + if (value) setTimeFormat(value); break; case 'date-format': - setDateFormat(value); + if (value) setDateFormat(value); + break; + case 'time-zone': + if (value) { + setTimeZone(value); + persistTimeZoneSetting(value); + } break; } }; @@ -490,6 +684,14 @@ const SettingsPage = () => { }, ]} /> + Date: Tue, 14 Oct 2025 13:44:28 -0500 Subject: [PATCH 050/168] Enhancement: Implement caching for proxy settings to improve performance and reduce database load. Also, ensure database connections are closed after use in both config and stream manager. --- apps/proxy/config.py | 28 +++++++++++++++++++++++++-- apps/proxy/ts_proxy/stream_manager.py | 7 +++++++ dispatcharr/settings.py | 1 + 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/proxy/config.py b/apps/proxy/config.py index 9ce5b66c5..c3d0cc272 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -1,4 +1,6 @@ """Shared configuration between proxy types""" +import time +from django.db import connection class BaseConfig: DEFAULT_USER_AGENT = 'VLC/3.0.20 LibVLC/3.0.20' # Will only be used if connection to settings fail @@ -12,13 +14,35 @@ class BaseConfig: BUFFERING_TIMEOUT = 15 # Seconds to wait for buffering before switching streams BUFFER_SPEED = 1 # What speed to condsider the stream buffering, 1x is normal speed, 2x is double speed, etc. + # Cache for proxy settings (class-level, shared across all instances) + _proxy_settings_cache = None + _proxy_settings_cache_time = 0 + _proxy_settings_cache_ttl = 10 # Cache for 10 seconds + @classmethod def get_proxy_settings(cls): - """Get proxy settings from CoreSettings JSON data with fallback to defaults""" + """Get proxy settings from CoreSettings JSON data with fallback to defaults (cached)""" + # Check if cache is still valid + now = time.time() + if cls._proxy_settings_cache is not None and (now - cls._proxy_settings_cache_time) < cls._proxy_settings_cache_ttl: + return cls._proxy_settings_cache + + # Cache miss or expired - fetch from database try: from core.models import CoreSettings - return CoreSettings.get_proxy_settings() + settings = CoreSettings.get_proxy_settings() + cls._proxy_settings_cache = settings + cls._proxy_settings_cache_time = now + + # Close the connection after reading settings to avoid keeping it open + try: + connection.close() + except Exception: + pass + + return settings except Exception: + # Return defaults if database query fails return { "buffering_timeout": 15, "buffering_speed": 1.0, diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index d1f4ded6b..e36208866 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -9,6 +9,7 @@ import gevent import re from typing import Optional, List +from django.db import connection from django.shortcuts import get_object_or_404 from urllib3.exceptions import ReadTimeoutError from apps.proxy.config import TSConfig as Config @@ -384,6 +385,12 @@ def run(self): except Exception as e: logger.error(f"Failed to update channel state in Redis: {e} for channel {self.channel_id}", exc_info=True) + # Close database connection for this thread + try: + connection.close() + except Exception: + pass + logger.info(f"Stream manager stopped for channel {self.channel_id}") def _establish_transcode_connection(self): diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 057780dea..a0c4fc841 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -134,6 +134,7 @@ "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": int(os.environ.get("POSTGRES_PORT", 5432)), + "CONN_MAX_AGE": DATABASE_CONN_MAX_AGE, } } From 4df2f79bcf3a1858ff92659c2001ab2da07b88aa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 14 Oct 2025 15:03:42 -0500 Subject: [PATCH 051/168] Bug fix: Fixes bug where if there were no channel profiles other than ALL, streamer and standard accounts could not stream any channels even if they had ALL profiles selected. --- apps/proxy/ts_proxy/views.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index bb07d6974..4218e60ff 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -474,17 +474,26 @@ def stream_xc(request, username, password, channel_id): print(f"Fetchin channel with ID: {channel_id}") if user.user_level < 10: - filters = { - "id": int(channel_id), - "channelprofilemembership__enabled": True, - "user_level__lte": user.user_level, - } - - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - filters["channelprofilemembership__channel_profile__in"] = channel_profiles + user_profile_count = user.channel_profiles.count() + + # If user has ALL profiles or NO profiles, give unrestricted access + if user_profile_count == 0: + # No profile filtering - user sees all channels based on user_level + filters = { + "id": int(channel_id), + "user_level__lte": user.user_level + } + channel = Channel.objects.filter(**filters).first() + else: + # User has specific limited profiles assigned + filters = { + "id": int(channel_id), + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + "channelprofilemembership__channel_profile__in": user.channel_profiles.all() + } + channel = Channel.objects.filter(**filters).distinct().first() - channel = Channel.objects.filter(**filters).distinct().first() if not channel: return JsonResponse({"error": "Not found"}, status=404) else: From ae8b85a3e2d019234d4b183fd1963a35d0a7c85f Mon Sep 17 00:00:00 2001 From: Ragchuck Date: Wed, 15 Oct 2025 22:06:01 +0200 Subject: [PATCH 052/168] feat: added support for rtsp --- apps/m3u/tasks.py | 6 +++++- core/utils.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 0ba595c5d..52847e77e 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -219,6 +219,10 @@ def fetch_m3u_lines(account, use_cache=False): # Has HTTP URLs, might be a simple M3U without headers is_valid_m3u = True logger.info("Content validated as M3U: contains HTTP URLs") + elif any(line.strip().startswith('rtsp') for line in content_lines): + # Has HTTP URLs, might be a simple M3U without headers + is_valid_m3u = True + logger.info("Content validated as M3U: contains RTSP URLs") if not is_valid_m3u: # Log what we actually received for debugging @@ -1381,7 +1385,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): ) problematic_lines.append((line_index + 1, line[:200])) - elif extinf_data and line.startswith("http"): + elif extinf_data and (line.startswith("http") or line.startswith("rtsp")): url_count += 1 # Associate URL with the last EXTINF line extinf_data[-1]["url"] = line diff --git a/core/utils.py b/core/utils.py index 36ac5fef4..da40d19c9 100644 --- a/core/utils.py +++ b/core/utils.py @@ -377,8 +377,8 @@ def validate_flexible_url(value): import re # More flexible pattern for non-FQDN hostnames with paths - # Matches: http://hostname, http://hostname/, http://hostname:port/path/to/file.xml - non_fqdn_pattern = r'^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\:[0-9]+)?(/[^\s]*)?$' + # Matches: http://hostname, http://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1 + non_fqdn_pattern = r'^(rts?p|https?)://([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: From 9b2ebf169bea3f99cce1879128b78c056fa6a561 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 16 Oct 2025 14:22:19 -0500 Subject: [PATCH 053/168] Better database connection cleanup. --- apps/proxy/config.py | 15 ++++++++------- apps/proxy/ts_proxy/services/channel_service.py | 9 +++++++++ apps/proxy/ts_proxy/stream_manager.py | 9 +++++++++ dispatcharr/celery.py | 14 +++++++++++--- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/proxy/config.py b/apps/proxy/config.py index c3d0cc272..74bfc61f1 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -33,14 +33,8 @@ def get_proxy_settings(cls): settings = CoreSettings.get_proxy_settings() cls._proxy_settings_cache = settings cls._proxy_settings_cache_time = now - - # Close the connection after reading settings to avoid keeping it open - try: - connection.close() - except Exception: - pass - return settings + except Exception: # Return defaults if database query fails return { @@ -50,6 +44,13 @@ def get_proxy_settings(cls): "channel_shutdown_delay": 0, "channel_init_grace_period": 5, } + + finally: + # Always close the connection after reading settings + try: + connection.close() + except Exception: + pass @classmethod def get_redis_chunk_ttl(cls): diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index ac8f3a10c..551e2d273 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -597,6 +597,8 @@ def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, f @staticmethod def _update_stream_stats_in_db(stream_id, **stats): """Update stream stats in database""" + from django.db import connection + try: from apps.channels.models import Stream from django.utils import timezone @@ -622,6 +624,13 @@ def _update_stream_stats_in_db(stream_id, **stats): except Exception as e: logger.error(f"Error updating stream stats in database for stream {stream_id}: {e}") return False + + finally: + # Always close database connection after update + try: + connection.close() + except Exception: + pass # Helper methods for Redis operations diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index e36208866..99ae80278 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -930,6 +930,7 @@ def update_url(self, new_url, stream_id=None, m3u_profile_id=None): # Import both models for proper resource management from apps.channels.models import Stream, Channel + from django.db import connection # Update stream profile if we're switching streams if self.current_stream_id and stream_id and self.current_stream_id != stream_id: @@ -947,8 +948,16 @@ def update_url(self, new_url, stream_id=None, m3u_profile_id=None): logger.debug(f"Updated m3u profile for channel {self.channel_id} to use profile from stream {stream_id}") else: logger.warning(f"Failed to update stream profile for channel {self.channel_id}") + except Exception as e: logger.error(f"Error updating stream profile for channel {self.channel_id}: {e}") + + finally: + # Always close database connection after profile update + try: + connection.close() + except Exception: + pass # CRITICAL: Set a flag to prevent immediate reconnection with old URL self.url_switching = True diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 98c6210b9..c845dafe9 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -50,13 +50,21 @@ def get_effective_log_level(): ) # Add memory cleanup after task completion -#@task_postrun.connect # Use the imported signal +@task_postrun.connect # Use the imported signal def cleanup_task_memory(**kwargs): - """Clean up memory after each task completes""" + """Clean up memory and database connections after each task completes""" + from django.db import connection + # Get task name from kwargs task_name = kwargs.get('task').name if kwargs.get('task') else '' - # Only run cleanup for memory-intensive tasks + # Close database connection for this Celery worker process + try: + connection.close() + except Exception: + pass + + # Only run memory cleanup for memory-intensive tasks memory_intensive_tasks = [ 'apps.m3u.tasks.refresh_single_m3u_account', 'apps.m3u.tasks.refresh_m3u_accounts', From b9a0aaa574726c1f8d63ca340d38302db347fdb4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 16 Oct 2025 14:33:15 -0500 Subject: [PATCH 054/168] Re-allign tables and buttons after adjusting button layout. --- frontend/src/components/tables/StreamsTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 5e68b67b6..d309552ca 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -879,7 +879,7 @@ const StreamsTable = () => { justify="space-between" align="center" wrap="nowrap" - style={{ paddingLeft: 10, paddingRight: 10, paddingTop: 10 }} + style={{ padding: 10 }} gap={6} > From 0031d55bab220c7f90e7dd233708f15928139718 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 16 Oct 2025 16:49:13 -0500 Subject: [PATCH 055/168] Bug Fix: Resizing columns in the channel table may cause the page to crash. Fixes #516 --- frontend/src/components/tables/ChannelsTable.jsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b9da2318d..f9ef3c7f4 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -307,6 +307,7 @@ const ChannelsTable = ({}) => { const [channelToDelete, setChannelToDelete] = useState(null); // Column sizing state for resizable columns + // Store in localStorage but with empty object as default const [columnSizing, setColumnSizing] = useLocalStorage( 'channels-table-column-sizing', {} @@ -882,7 +883,12 @@ const ChannelsTable = ({}) => { ), }, ], - [selectedProfileId, channelGroups, logos, theme, columnSizing] + // Note: columnSizing is intentionally excluded from dependencies to prevent + // columns from being recreated during drag operations (which causes infinite loops). + // The column.size values are only used for INITIAL sizing - TanStack Table manages + // the actual sizes through its own state after initialization. + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedProfileId, channelGroups, logos, theme] ); const renderHeaderCell = (header) => { @@ -979,17 +985,18 @@ const ChannelsTable = ({}) => { filters, pagination, sorting, + columnSizing, + setColumnSizing, manualPagination: true, manualSorting: true, manualFiltering: true, enableRowSelection: true, onRowSelectionChange: onRowSelectionChange, state: { - columnSizing, pagination, sorting, }, - onColumnSizingChange: setColumnSizing, + columnResizeMode: 'onChange', getExpandedRowHeight: (row) => { return 20 + 28 * row.original.streams.length; }, From ec21e8329d62e273429a64f4c0ff1475ca1386e9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 16 Oct 2025 17:38:23 -0500 Subject: [PATCH 056/168] Enhancement: Increase time for a client to search for an available connection from 1.5 seconds to 3 seconds. This will help when clients are changing channels and release the old connection AFTER attempting to start the new connection. Closes #503 Bug Fix: Fix a bug where searching for an available stream could clear out stream locks for streams that it never acquired a lock for. --- apps/proxy/ts_proxy/views.py | 70 +++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 4218e60ff..109e88cfc 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -128,7 +128,7 @@ def stream_ts(request, channel_id): ChannelService.stop_channel(channel_id) # Use fixed retry interval and timeout - retry_timeout = 1.5 # 1.5 seconds total timeout + retry_timeout = 3 # 3 seconds total timeout retry_interval = 0.1 # 100ms between attempts wait_start_time = time.time() @@ -138,9 +138,10 @@ def stream_ts(request, channel_id): profile_value = None error_reason = None attempt = 0 + should_retry = True # Try to get a stream with fixed interval retries - while time.time() - wait_start_time < retry_timeout: + while should_retry and time.time() - wait_start_time < retry_timeout: attempt += 1 stream_url, stream_user_agent, transcode, profile_value = ( generate_stream_url(channel_id) @@ -152,35 +153,53 @@ def stream_ts(request, channel_id): ) break - # If we failed because there are no streams assigned, don't retry - _, _, error_reason = channel.get_stream() - if error_reason and "maximum connection limits" not in error_reason: - logger.warning( - f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}" - ) - break + # On first failure, check if the error is retryable + if attempt == 1: + _, _, error_reason = channel.get_stream() + if error_reason and "maximum connection limits" not in error_reason: + logger.warning( + f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}" + ) + should_retry = False + break - # Wait 100ms before retrying + # Check if we have time remaining for another sleep cycle elapsed_time = time.time() - wait_start_time remaining_time = retry_timeout - elapsed_time - if remaining_time > retry_interval: + + # If we don't have enough time for the next sleep interval, break + # but only after we've already made an attempt (the while condition will try one more time) + if remaining_time <= retry_interval: + logger.info( + f"[{client_id}] Insufficient time ({remaining_time:.1f}s) for another sleep cycle, will make one final attempt" + ) + break + + # Wait before retrying + logger.info( + f"[{client_id}] Waiting {retry_interval*1000:.0f}ms for a connection to become available (attempt {attempt}, {remaining_time:.1f}s remaining)" + ) + gevent.sleep(retry_interval) + retry_interval += 0.025 # Increase wait time by 25ms for next attempt + + # Make one final attempt if we still don't have a stream, should retry, and haven't exceeded timeout + if stream_url is None and should_retry and time.time() - wait_start_time < retry_timeout: + attempt += 1 + logger.info( + f"[{client_id}] Making final attempt {attempt} at timeout boundary" + ) + stream_url, stream_user_agent, transcode, profile_value = ( + generate_stream_url(channel_id) + ) + if stream_url is not None: logger.info( - f"[{client_id}] Waiting {retry_interval*1000:.0f}ms for a connection to become available (attempt {attempt}, {remaining_time:.1f}s remaining)" + f"[{client_id}] Successfully obtained stream on final attempt for channel {channel_id}" ) - gevent.sleep(retry_interval) - retry_interval += 0.025 # Increase wait time by 25ms for next attempt if stream_url is None: - # Make sure to release any stream locks that might have been acquired - if hasattr(channel, "streams") and channel.streams.exists(): - for stream in channel.streams.all(): - try: - stream.release_stream() - logger.info( - f"[{client_id}] Released stream {stream.id} for channel {channel_id}" - ) - except Exception as e: - logger.error(f"[{client_id}] Error releasing stream: {e}") + # Release the channel's stream lock if one was acquired + # Note: Only call this if get_stream() actually assigned a stream + # In our case, if stream_url is None, no stream was ever assigned, so don't release # Get the specific error message if available wait_duration = f"{int(time.time() - wait_start_time)}s" @@ -189,6 +208,9 @@ def stream_ts(request, channel_id): if error_reason else "No available streams for this channel" ) + logger.info( + f"[{client_id}] Failed to obtain stream after {attempt} attempts over {wait_duration}: {error_msg}" + ) return JsonResponse( {"error": error_msg, "waited": wait_duration}, status=503 ) # 503 Service Unavailable is appropriate here From ca8e9d01437e6b4b21a51a57d2a860dbb2b78eb7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 17 Oct 2025 10:03:21 -0500 Subject: [PATCH 057/168] Enhancement: Add custom logo support for channel groups in Auto Sync Channels. Closes #555 --- apps/m3u/tasks.py | 25 +- .../src/components/forms/LiveGroupFilter.jsx | 329 +++++++++++++++++- 2 files changed, 337 insertions(+), 17 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 593b27046..6b1dbdcc5 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1557,6 +1557,7 @@ def sync_auto_channels(account_id, scan_start_time=None): channel_sort_order = None channel_sort_reverse = False stream_profile_id = None + custom_logo_id = None if group_relation.custom_properties: group_custom_props = group_relation.custom_properties force_dummy_epg = group_custom_props.get("force_dummy_epg", False) @@ -1572,6 +1573,7 @@ def sync_auto_channels(account_id, scan_start_time=None): "channel_sort_reverse", False ) stream_profile_id = group_custom_props.get("stream_profile_id") + custom_logo_id = group_custom_props.get("custom_logo_id") # Determine which group to use for created channels target_group = channel_group @@ -1947,7 +1949,28 @@ def sync_auto_channels(account_id, scan_start_time=None): channel.save(update_fields=["epg_data"]) # Handle logo - if stream.logo_url: + if custom_logo_id: + # Use the custom logo specified in group settings + from apps.channels.models import Logo + try: + custom_logo = Logo.objects.get(id=custom_logo_id) + channel.logo = custom_logo + channel.save(update_fields=["logo"]) + except Logo.DoesNotExist: + logger.warning( + f"Custom logo with ID {custom_logo_id} not found, falling back to stream logo" + ) + # Fall back to stream logo if custom logo not found + if stream.logo_url: + logo, _ = Logo.objects.get_or_create( + url=stream.logo_url, + defaults={ + "name": stream.name or stream.tvg_id or "Unknown" + }, + ) + channel.logo = logo + channel.save(update_fields=["logo"]) + elif stream.logo_url: from apps.channels.models import Logo logo, _ = Logo.objects.get_or_create( diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx index c5ac5f83d..18928a0bd 100644 --- a/frontend/src/components/forms/LiveGroupFilter.jsx +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -16,11 +16,19 @@ import { Box, MultiSelect, Tooltip, + Popover, + ScrollArea, + Center, } from '@mantine/core'; import { Info } from 'lucide-react'; import useChannelsStore from '../../store/channels'; import useStreamProfilesStore from '../../store/streamProfiles'; import { CircleCheck, CircleX } from 'lucide-react'; +import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; +import { FixedSizeList as List } from 'react-window'; +import LazyLogo from '../LazyLogo'; +import LogoForm from './Logo'; +import logo from '../../images/logo.png'; // Custom item component for MultiSelect with tooltip const OptionWithTooltip = forwardRef( @@ -46,6 +54,20 @@ const LiveGroupFilter = ({ const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles); const [groupFilter, setGroupFilter] = useState(''); + // Logo selection functionality + const { + logos: channelLogos, + ensureLogosLoaded, + isLoading: logosLoading, + } = useChannelLogoSelection(); + const [logoModalOpen, setLogoModalOpen] = useState(false); + const [currentEditingGroupId, setCurrentEditingGroupId] = useState(null); + + // Ensure logos are loaded when component mounts + useEffect(() => { + ensureLogosLoaded(); + }, [ensureLogosLoaded]); + // Fetch stream profiles when component mounts useEffect(() => { if (streamProfiles.length === 0) { @@ -68,7 +90,7 @@ const LiveGroupFilter = ({ typeof group.custom_properties === 'string' ? JSON.parse(group.custom_properties) : group.custom_properties; - } catch (e) { + } catch { customProps = {}; } } @@ -115,21 +137,27 @@ const LiveGroupFilter = ({ ); }; - // Toggle force_dummy_epg in custom_properties for a group - const toggleForceDummyEPG = (id) => { - setGroupStates( - groupStates.map((state) => { - if (state.channel_group == id) { - const customProps = { ...(state.custom_properties || {}) }; - customProps.force_dummy_epg = !customProps.force_dummy_epg; - return { - ...state, - custom_properties: customProps, - }; - } - return state; - }) - ); + // Handle logo selection from LogoForm + const handleLogoSuccess = ({ logo }) => { + if (logo && logo.id && currentEditingGroupId !== null) { + setGroupStates( + groupStates.map((state) => { + if (state.channel_group === currentEditingGroupId) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + custom_logo_id: logo.id, + }, + }; + } + return state; + }) + ); + ensureLogosLoaded(); // Refresh logos + } + setLogoModalOpen(false); + setCurrentEditingGroupId(null); }; const selectAll = () => { @@ -311,6 +339,12 @@ const LiveGroupFilter = ({ description: 'Assign a specific stream profile to all channels in this group during auto sync', }, + { + value: 'custom_logo', + label: 'Custom Logo', + description: + 'Assign a custom logo to all auto-synced channels in this group', + }, ]} itemComponent={OptionWithTooltip} value={(() => { @@ -356,6 +390,12 @@ const LiveGroupFilter = ({ ) { selectedValues.push('stream_profile_assignment'); } + if ( + group.custom_properties?.custom_logo_id !== + undefined + ) { + selectedValues.push('custom_logo'); + } return selectedValues; })()} onChange={(values) => { @@ -475,6 +515,17 @@ const LiveGroupFilter = ({ delete newCustomProps.stream_profile_id; } + // Handle custom_logo + if (selectedOptions.includes('custom_logo')) { + if ( + newCustomProps.custom_logo_id === undefined + ) { + newCustomProps.custom_logo_id = null; + } + } else { + delete newCustomProps.custom_logo_id; + } + return { ...state, custom_properties: newCustomProps, @@ -801,6 +852,242 @@ const LiveGroupFilter = ({ /> )} + + {/* Show logo selector only if custom_logo is selected */} + {group.custom_properties?.custom_logo_id !== + undefined && ( + + + { + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + return { + ...state, + logoPopoverOpened: opened, + }; + } + return state; + }) + ); + if (opened) { + ensureLogosLoaded(); + } + }} + withArrow + > + + { + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + return { + ...state, + logoPopoverOpened: true, + }; + } + return { + ...state, + logoPopoverOpened: false, + }; + }) + ); + }} + size="xs" + /> + + + e.stopPropagation()} + > + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map((state) => + state.channel_group === + group.channel_group + ? { + ...state, + logoFilter: val, + } + : state + ) + ); + }} + /> + {logosLoading && ( + + Loading... + + )} + + + + {(() => { + const logoOptions = [ + { id: '0', name: 'Default' }, + ...Object.values(channelLogos), + ]; + const filteredLogos = logoOptions.filter( + (logo) => + logo.name + .toLowerCase() + .includes( + ( + group.logoFilter || '' + ).toLowerCase() + ) + ); + + if (filteredLogos.length === 0) { + return ( +
+ + {group.logoFilter + ? 'No logos match your filter' + : 'No logos available'} + +
+ ); + } + + return ( + + {({ index, style }) => { + const logoItem = filteredLogos[index]; + return ( +
{ + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + custom_logo_id: + logoItem.id, + }, + logoPopoverOpened: false, + }; + } + return state; + }) + ); + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = + 'rgb(68, 68, 68)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = + 'transparent'; + }} + > +
+ {logoItem.name { + if (e.target.src !== logo) { + e.target.src = logo; + } + }} + /> + + {logoItem.name || 'Default'} + +
+
+ ); + }} +
+ ); + })()} +
+
+
+ + + + +
+ + +
+ )} )} @@ -808,6 +1095,16 @@ const LiveGroupFilter = ({ ))}
+ + {/* Logo Upload Modal */} + { + setLogoModalOpen(false); + setCurrentEditingGroupId(null); + }} + onSuccess={handleLogoSuccess} + /> ); }; From 22fb0b3bdd78a6d649a3275a8871640afb97bf5f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 18 Oct 2025 12:08:56 -0500 Subject: [PATCH 058/168] Enhancement: Add Custom Dummy EPG with Dynamic Pattern Matching and Name Source Selection This enhancement introduces a powerful custom dummy EPG system that allows users to generate EPG programs on-demand by parsing channel or stream names using configurable regex patterns. Key Features: - Custom Pattern Matching: Define regex patterns to extract information from channel/stream names (teams, leagues, times, dates, etc.) - Flexible Name Source: Choose to parse either the channel name or a specific stream name (by index) - Timezone-Aware Scheduling: Automatic DST handling using pytz timezone names (e.g., 'US/Eastern', 'Europe/London') - Time Format Support: Parse both 12-hour (AM/PM) and 24-hour time formats - Date Parsing: Extract dates from names with flexible month/day/year patterns - Custom Templates: Format EPG titles and descriptions using captured groups with {placeholder} syntax - Upcoming/Ended Customization: Define custom titles and descriptions for programs before and after scheduled events - Live Preview: Test patterns and templates in real-time with sample input - Smart Program Generation: Automatically creates "Upcoming" and "Ended" programs around scheduled events Use Cases: - Sports channels with event details in stream names (e.g., "NHL 01: Bruins VS Leafs @ 8:00PM ET") - Movie channels with genre/title/year information - Racing events with driver/track/series details - Any scenario where EPG data is embedded in channel/stream naming conventions Technical Implementation: - Backend: Pattern matching engine with timezone conversion and program scheduling logic - Frontend: Interactive form with validation, pattern testing, and visual group preview - Name Source Options: Parse from channel name or selectable stream index (1-based) - Fallback Behavior: Uses standard dummy EPG if patterns don't match - Custom Properties: Stores all configuration in EPGSource.custom_properties JSON field Configuration Options: - Title Pattern: Extract primary information (required) - Time Pattern: Extract hour/minute/AM-PM (optional) - Date Pattern: Extract month/day/year (optional) - Timezone: Event timezone with automatic DST support - Program Duration: Length of generated programs in minutes - Title Template: Format EPG title using captured groups - Description Template: Format EPG description using captured groups - Upcoming Title Template: Custom title for programs before event starts (optional) - Upcoming Description Template: Custom description for programs before event starts (optional) - Ended Title Template: Custom title for programs after event ends (optional) - Ended Description Template: Custom description for programs after event ends (optional) - Name Source: Channel name or stream name - Stream Index: Which stream to use when parsing stream names (1, 2, 3, etc.) Closes #293 --- apps/epg/api_views.py | 135 +++- ...18_epgsource_custom_properties_and_more.py | 23 + apps/epg/models.py | 7 + apps/epg/serializers.py | 1 + apps/epg/signals.py | 77 +- apps/epg/tasks.py | 29 +- apps/output/views.py | 550 ++++++++++++- core/api_urls.py | 3 +- core/api_views.py | 54 +- frontend/src/WebSocket.jsx | 10 + frontend/src/api.js | 15 + frontend/src/components/forms/DummyEPG.jsx | 761 ++++++++++++++++++ frontend/src/components/forms/EPG.jsx | 30 +- frontend/src/components/tables/EPGsTable.jsx | 109 ++- frontend/src/pages/Guide.jsx | 5 +- frontend/src/pages/guideUtils.js | 21 +- 16 files changed, 1739 insertions(+), 91 deletions(-) create mode 100644 apps/epg/migrations/0018_epgsource_custom_properties_and_more.py create mode 100644 frontend/src/components/forms/DummyEPG.jsx diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index f3248677e..2fc5a7439 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -147,23 +147,37 @@ def get(self, request, format=None): f"EPGGridAPIView: Found {count} program(s), including recently ended, currently running, and upcoming shows." ) - # Generate dummy programs for channels that have no EPG data + # Generate dummy programs for channels that have no EPG data OR dummy EPG sources from apps.channels.models import Channel + from apps.epg.models import EPGSource from django.db.models import Q - # Get channels with no EPG data + # Get channels with no EPG data at all (standard dummy) channels_without_epg = Channel.objects.filter(Q(epg_data__isnull=True)) - channels_count = channels_without_epg.count() - # Log more detailed information about channels missing EPG data - if channels_count > 0: + # Get channels with custom dummy EPG sources (generate on-demand with patterns) + channels_with_custom_dummy = Channel.objects.filter( + epg_data__epg_source__source_type='dummy' + ).distinct() + + # Log what we found + without_count = channels_without_epg.count() + custom_count = channels_with_custom_dummy.count() + + if without_count > 0: channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_without_epg] - logger.warning( - f"EPGGridAPIView: Missing EPG data for these channels: {', '.join(channel_names)}" + logger.debug( + f"EPGGridAPIView: Channels needing standard dummy EPG: {', '.join(channel_names)}" + ) + + if custom_count > 0: + channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_with_custom_dummy] + logger.debug( + f"EPGGridAPIView: Channels needing custom dummy EPG: {', '.join(channel_names)}" ) logger.debug( - f"EPGGridAPIView: Found {channels_count} channels with no EPG data." + f"EPGGridAPIView: Found {without_count} channels needing standard dummy, {custom_count} needing custom dummy EPG." ) # Serialize the regular programs @@ -205,12 +219,91 @@ def get(self, request, format=None): # Generate and append dummy programs dummy_programs = [] + + # Import the function from output.views + from apps.output.views import generate_dummy_programs as gen_dummy_progs + + # Handle channels with CUSTOM dummy EPG sources (with patterns) + for channel in channels_with_custom_dummy: + # For dummy EPGs, ALWAYS use channel UUID to ensure unique programs per channel + # This prevents multiple channels assigned to the same dummy EPG from showing identical data + # Each channel gets its own unique program data even if they share the same EPG source + dummy_tvg_id = str(channel.uuid) + + try: + # Get the custom dummy EPG source + epg_source = channel.epg_data.epg_source if channel.epg_data else None + + logger.debug(f"Generating custom dummy programs for channel: {channel.name} (ID: {channel.id})") + + # Determine which name to parse based on custom properties + name_to_parse = channel.name + if epg_source and epg_source.custom_properties: + custom_props = epg_source.custom_properties + name_source = custom_props.get('name_source') + + if name_source == 'stream': + # Get the stream index (1-based from user, convert to 0-based) + stream_index = custom_props.get('stream_index', 1) - 1 + + # Get streams ordered by channelstream order + channel_streams = channel.streams.all().order_by('channelstream__order') + + if channel_streams.exists() and 0 <= stream_index < channel_streams.count(): + stream = list(channel_streams)[stream_index] + name_to_parse = stream.name + logger.debug(f"Using stream name for parsing: {name_to_parse} (stream index: {stream_index})") + else: + logger.warning(f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name") + elif name_source == 'channel': + logger.debug(f"Using channel name for parsing: {name_to_parse}") + + # Generate programs using custom patterns from the dummy EPG source + # Use the same tvg_id that will be set in the program data + generated = gen_dummy_progs( + channel_id=dummy_tvg_id, + channel_name=name_to_parse, + num_days=1, + program_length_hours=4, + epg_source=epg_source + ) + + # Custom dummy should always return data (either from patterns or fallback) + if generated: + logger.debug(f"Generated {len(generated)} custom dummy programs for {channel.name}") + # Convert generated programs to API format + for program in generated: + dummy_program = { + "id": f"dummy-custom-{channel.id}-{program['start_time'].hour}", + "epg": {"tvg_id": dummy_tvg_id, "name": channel.name}, + "start_time": program['start_time'].isoformat(), + "end_time": program['end_time'].isoformat(), + "title": program['title'], + "description": program['description'], + "tvg_id": dummy_tvg_id, + "sub_title": None, + "custom_properties": None, + } + dummy_programs.append(dummy_program) + else: + logger.warning(f"No programs generated for custom dummy EPG channel: {channel.name}") + + except Exception as e: + logger.error( + f"Error creating custom dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}" + ) + + # Handle channels with NO EPG data (standard dummy with humorous descriptions) for channel in channels_without_epg: - # Use the channel UUID as tvg_id for dummy programs to match in the guide + # For channels with no EPG, use UUID to ensure uniqueness (matches frontend logic) + # The frontend uses: tvgRecord?.tvg_id ?? channel.uuid + # Since there's no EPG data, it will fall back to UUID dummy_tvg_id = str(channel.uuid) try: - # Create programs every 4 hours for the next 24 hours + logger.debug(f"Generating standard dummy programs for channel: {channel.name} (ID: {channel.id})") + + # Create programs every 4 hours for the next 24 hours with humorous descriptions for hour_offset in range(0, 24, 4): # Use timedelta for time arithmetic instead of replace() to avoid hour overflow start_time = now + timedelta(hours=hour_offset) @@ -238,7 +331,7 @@ def get(self, request, format=None): # Create a dummy program in the same format as regular programs dummy_program = { - "id": f"dummy-{channel.id}-{hour_offset}", # Create a unique ID + "id": f"dummy-standard-{channel.id}-{hour_offset}", "epg": {"tvg_id": dummy_tvg_id, "name": channel.name}, "start_time": start_time.isoformat(), "end_time": end_time.isoformat(), @@ -252,7 +345,7 @@ def get(self, request, format=None): except Exception as e: logger.error( - f"Error creating dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}" + f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}" ) # Combine regular and dummy programs @@ -284,7 +377,22 @@ def get_permissions(self): ) def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") - refresh_epg_data.delay(request.data.get("id", None)) # Trigger Celery task + epg_id = request.data.get("id", None) + + # Check if this is a dummy EPG source + try: + from .models import EPGSource + epg_source = EPGSource.objects.get(id=epg_id) + if epg_source.source_type == 'dummy': + logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}") + return Response( + {"success": False, "message": "Dummy EPG sources do not require refreshing."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except EPGSource.DoesNotExist: + pass # Let the task handle the missing source + + refresh_epg_data.delay(epg_id) # Trigger Celery task logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.") return Response( {"success": True, "message": "EPG data import initiated."}, @@ -308,3 +416,4 @@ def get_permissions(self): return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] + diff --git a/apps/epg/migrations/0018_epgsource_custom_properties_and_more.py b/apps/epg/migrations/0018_epgsource_custom_properties_and_more.py new file mode 100644 index 000000000..70ebb2149 --- /dev/null +++ b/apps/epg/migrations/0018_epgsource_custom_properties_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2025-10-17 17:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0017_alter_epgsource_url'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='custom_properties', + field=models.JSONField(blank=True, default=dict, help_text='Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)', null=True), + ), + migrations.AlterField( + model_name='epgsource', + name='source_type', + field=models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API'), ('dummy', 'Custom Dummy EPG')], max_length=20), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index da6ac8e6e..6c70add2c 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -8,6 +8,7 @@ class EPGSource(models.Model): SOURCE_TYPE_CHOICES = [ ('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API'), + ('dummy', 'Custom Dummy EPG'), ] STATUS_IDLE = 'idle' @@ -38,6 +39,12 @@ class EPGSource(models.Model): refresh_task = models.ForeignKey( PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True ) + custom_properties = models.JSONField( + default=dict, + blank=True, + null=True, + help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)" + ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 85186cae8..3404cca9d 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -28,6 +28,7 @@ class Meta: 'last_message', 'created_at', 'updated_at', + 'custom_properties', 'epg_data_ids' ] diff --git a/apps/epg/signals.py b/apps/epg/signals.py index e8a004cbd..e41d3aaf4 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -1,9 +1,9 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver -from .models import EPGSource +from .models import EPGSource, EPGData from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id from django_celery_beat.models import PeriodicTask, IntervalSchedule -from core.utils import is_protected_path +from core.utils import is_protected_path, send_websocket_update import json import logging import os @@ -12,15 +12,77 @@ @receiver(post_save, sender=EPGSource) def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs): - # Trigger refresh only if the source is newly created and active - if created and instance.is_active: + # Trigger refresh only if the source is newly created, active, and not a dummy EPG + if created and instance.is_active and instance.source_type != 'dummy': refresh_epg_data.delay(instance.id) +@receiver(post_save, sender=EPGSource) +def create_dummy_epg_data(sender, instance, created, **kwargs): + """ + Automatically create EPGData for dummy EPG sources when they are created. + This allows channels to be assigned to dummy EPGs immediately without + requiring a refresh first. + """ + if instance.source_type == 'dummy': + # Ensure dummy EPGs always have idle status and no status message + if instance.status != EPGSource.STATUS_IDLE or instance.last_message: + instance.status = EPGSource.STATUS_IDLE + instance.last_message = None + instance.save(update_fields=['status', 'last_message']) + + # Create a URL-friendly tvg_id from the dummy EPG name + # Replace spaces and special characters with underscores + friendly_tvg_id = instance.name.replace(' ', '_').replace('-', '_') + # Remove any characters that aren't alphanumeric or underscores + friendly_tvg_id = ''.join(c for c in friendly_tvg_id if c.isalnum() or c == '_') + # Convert to lowercase for consistency + friendly_tvg_id = friendly_tvg_id.lower() + # Prefix with 'dummy_' to make it clear this is a dummy EPG + friendly_tvg_id = f"dummy_{friendly_tvg_id}" + + # Create or update the EPGData record + epg_data, data_created = EPGData.objects.get_or_create( + tvg_id=friendly_tvg_id, + epg_source=instance, + defaults={ + 'name': instance.name, + 'icon_url': None + } + ) + + # Update name if it changed and record already existed + if not data_created and epg_data.name != instance.name: + epg_data.name = instance.name + epg_data.save(update_fields=['name']) + + if data_created: + logger.info(f"Auto-created EPGData for dummy EPG source: {instance.name} (ID: {instance.id})") + + # Send websocket update to notify frontend that EPG data has been created + # This allows the channel form to immediately show the new dummy EPG without refreshing + send_websocket_update('updates', 'update', { + 'type': 'epg_data_created', + 'source_id': instance.id, + 'source_name': instance.name, + 'epg_data_id': epg_data.id + }) + else: + logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})") + @receiver(post_save, sender=EPGSource) def create_or_update_refresh_task(sender, instance, **kwargs): """ Create or update a Celery Beat periodic task when an EPGSource is created/updated. + Skip creating tasks for dummy EPG sources as they don't need refreshing. """ + # Skip task creation for dummy EPGs + if instance.source_type == 'dummy': + # If there's an existing task, disable it + if instance.refresh_task: + instance.refresh_task.enabled = False + instance.refresh_task.save(update_fields=['enabled']) + return + task_name = f"epg_source-refresh-{instance.id}" interval, _ = IntervalSchedule.objects.get_or_create( every=int(instance.refresh_interval), @@ -80,7 +142,14 @@ def delete_refresh_task(sender, instance, **kwargs): def update_status_on_active_change(sender, instance, **kwargs): """ When an EPGSource's is_active field changes, update the status accordingly. + For dummy EPGs, always ensure status is idle and no status message. """ + # Dummy EPGs should always be idle with no status message + if instance.source_type == 'dummy': + instance.status = EPGSource.STATUS_IDLE + instance.last_message = None + return + if instance.pk: # Only for existing records, not new ones try: # Get the current record from the database diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index d9ae5a5d3..2028cd98d 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -133,8 +133,9 @@ def delete_epg_refresh_task_by_id(epg_id): @shared_task def refresh_all_epg_data(): logger.info("Starting refresh_epg_data task.") - active_sources = EPGSource.objects.filter(is_active=True) - logger.debug(f"Found {active_sources.count()} active EPGSource(s).") + # Exclude dummy EPG sources from refresh - they don't need refreshing + active_sources = EPGSource.objects.filter(is_active=True).exclude(source_type='dummy') + logger.debug(f"Found {active_sources.count()} active EPGSource(s) (excluding dummy EPGs).") for source in active_sources: refresh_epg_data(source.id) @@ -180,6 +181,13 @@ def refresh_epg_data(source_id): gc.collect() return + # Skip refresh for dummy EPG sources - they don't need refreshing + if source.source_type == 'dummy': + logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})") + release_task_lock('refresh_epg_data', source_id) + gc.collect() + return + # Continue with the normal processing... logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})") if source.source_type == 'xmltv': @@ -1943,3 +1951,20 @@ def detect_file_format(file_path=None, content=None): # If we reach here, we couldn't reliably determine the format return format_type, is_compressed, file_extension + + +def generate_dummy_epg(source): + """ + DEPRECATED: This function is no longer used. + + Dummy EPG programs are now generated on-demand when they are requested + (during XMLTV export or EPG grid display), rather than being pre-generated + and stored in the database. + + See: apps/output/views.py - generate_custom_dummy_programs() + + This function remains for backward compatibility but should not be called. + """ + logger.warning(f"generate_dummy_epg() called for {source.name} but this function is deprecated. " + f"Dummy EPG programs are now generated on-demand.") + return True diff --git a/apps/output/views.py b/apps/output/views.py index 150367104..c7a827fc2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -9,7 +9,7 @@ from apps.accounts.models import User from core.models import CoreSettings, NETWORK_ACCESS from dispatcharr.utils import network_access_allowed -from django.utils import timezone +from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta import html # Add this import for XML escaping @@ -186,12 +186,44 @@ def generate_m3u(request, profile_name=None, user=None): return response -def generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4): +def generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4, epg_source=None): + """ + Generate dummy EPG programs for channels. + + If epg_source is provided and it's a custom dummy EPG with patterns, + use those patterns to generate programs from the channel title. + Otherwise, generate default dummy programs. + + Args: + channel_id: Channel ID for the programs + channel_name: Channel title/name + num_days: Number of days to generate programs for + program_length_hours: Length of each program in hours + epg_source: Optional EPGSource for custom dummy EPG with patterns + + Returns: + List of program dictionaries + """ + import re + # Get current time rounded to hour - now = timezone.now() + now = django_timezone.now() now = now.replace(minute=0, second=0, microsecond=0) - # Humorous program descriptions based on time of day + # Check if this is a custom dummy EPG with regex patterns + if epg_source and epg_source.source_type == 'dummy' and epg_source.custom_properties: + custom_programs = generate_custom_dummy_programs( + channel_id, channel_name, now, num_days, + epg_source.custom_properties + ) + # If custom generation succeeded, return those programs + # If it returned empty (pattern didn't match), fall through to default + if custom_programs: + return custom_programs + else: + logger.info(f"Custom pattern didn't match for '{channel_name}', using default dummy EPG") + + # Default humorous program descriptions based on time of day time_descriptions = { (0, 4): [ f"Late Night with {channel_name} - Where insomniacs unite!", @@ -263,6 +295,443 @@ def generate_dummy_programs(channel_id, channel_name, num_days=1, program_length return programs +def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, custom_properties): + """ + Generate programs using custom dummy EPG regex patterns. + + Extracts information from channel title using regex patterns and generates + programs based on the extracted data. + + TIMEZONE HANDLING: + ------------------ + The timezone parameter specifies the timezone of the event times in your channel + titles using standard timezone names (e.g., 'US/Eastern', 'US/Pacific', 'Europe/London'). + DST (Daylight Saving Time) is handled automatically by pytz. + + Examples: + - Channel: "NHL 01: Bruins VS Maple Leafs @ 8:00PM ET" + - Set timezone = "US/Eastern" + - In October (DST): 8:00PM EDT → 12:00AM UTC (automatically uses UTC-4) + - In January (no DST): 8:00PM EST → 1:00AM UTC (automatically uses UTC-5) + + Args: + channel_id: Channel ID for the programs + channel_name: Channel title to parse + now: Current datetime (in UTC) + num_days: Number of days to generate programs for + custom_properties: Dict with title_pattern, time_pattern, templates, etc. + - timezone: Timezone name (e.g., 'US/Eastern') + + Returns: + List of program dictionaries with start_time/end_time in UTC + """ + import re + import pytz + + logger.info(f"Generating custom dummy programs for channel: {channel_name}") + + # Extract patterns from custom properties + title_pattern = custom_properties.get('title_pattern', '') + time_pattern = custom_properties.get('time_pattern', '') + date_pattern = custom_properties.get('date_pattern', '') + + # Get timezone name (e.g., 'US/Eastern', 'US/Pacific', 'Europe/London') + timezone_value = custom_properties.get('timezone', 'UTC') + program_duration = custom_properties.get('program_duration', 180) # Minutes + title_template = custom_properties.get('title_template', '') + description_template = custom_properties.get('description_template', '') + + # Templates for upcoming/ended programs + upcoming_title_template = custom_properties.get('upcoming_title_template', '') + upcoming_description_template = custom_properties.get('upcoming_description_template', '') + ended_title_template = custom_properties.get('ended_title_template', '') + ended_description_template = custom_properties.get('ended_description_template', '') + + # Parse timezone name + try: + source_tz = pytz.timezone(timezone_value) + logger.debug(f"Using timezone: {timezone_value} (DST will be handled automatically)") + except pytz.exceptions.UnknownTimeZoneError: + logger.warning(f"Unknown timezone: {timezone_value}, defaulting to UTC") + source_tz = pytz.utc + + if not title_pattern: + logger.warning(f"No title_pattern in custom_properties, falling back to default") + return [] # Return empty, will use default + + logger.debug(f"Title pattern from DB: {repr(title_pattern)}") + + # Convert PCRE/JavaScript named groups (?) to Python format (?P) + # This handles patterns created with JavaScript regex syntax + title_pattern = re.sub(r'\(\?<([^>]+)>', r'(?P<\1>', title_pattern) + logger.debug(f"Converted title pattern: {repr(title_pattern)}") + + # Compile regex patterns + try: + title_regex = re.compile(title_pattern) + except re.error as e: + logger.error(f"Invalid title regex pattern after conversion: {e}") + logger.error(f"Pattern was: {repr(title_pattern)}") + return [] + + time_regex = None + if time_pattern: + # Convert PCRE/JavaScript named groups to Python format + time_pattern = re.sub(r'\(\?<([^>]+)>', r'(?P<\1>', time_pattern) + logger.debug(f"Converted time pattern: {repr(time_pattern)}") + try: + time_regex = re.compile(time_pattern) + except re.error as e: + logger.warning(f"Invalid time regex pattern after conversion: {e}") + logger.warning(f"Pattern was: {repr(time_pattern)}") + + # Compile date regex if provided + date_regex = None + if date_pattern: + # Convert PCRE/JavaScript named groups to Python format + date_pattern = re.sub(r'\(\?<([^>]+)>', r'(?P<\1>', date_pattern) + logger.debug(f"Converted date pattern: {repr(date_pattern)}") + try: + date_regex = re.compile(date_pattern) + except re.error as e: + logger.warning(f"Invalid date regex pattern after conversion: {e}") + logger.warning(f"Pattern was: {repr(date_pattern)}") + + # Try to match the channel name with the title pattern + title_match = title_regex.match(channel_name) + if not title_match: + logger.debug(f"Channel name '{channel_name}' doesn't match title pattern") + return [] # Return empty, will use default + + groups = title_match.groupdict() + logger.debug(f"Title pattern matched. Groups: {groups}") + + # Helper function to format template with matched groups + def format_template(template, groups): + """Replace {groupname} placeholders with matched group values""" + if not template: + return '' + result = template + for key, value in groups.items(): + result = result.replace(f'{{{key}}}', str(value) if value else '') + return result + + # Extract time from title if time pattern exists + time_info = None + time_groups = {} + if time_regex: + time_match = time_regex.search(channel_name) + if time_match: + time_groups = time_match.groupdict() + try: + hour = int(time_groups.get('hour')) + minute = int(time_groups.get('minute', 0)) + ampm = time_groups.get('ampm') + ampm = ampm.lower() if ampm else None + + # Determine if this is 12-hour or 24-hour format + if ampm in ('am', 'pm'): + # 12-hour format: convert to 24-hour + if ampm == 'pm' and hour != 12: + hour += 12 + elif ampm == 'am' and hour == 12: + hour = 0 + logger.debug(f"Extracted time (12-hour): {hour}:{minute:02d} {ampm}") + else: + # 24-hour format: hour is already in 24-hour format + # Validate that it's actually a 24-hour time (0-23) + if hour > 23: + logger.warning(f"Invalid 24-hour time: {hour}. Must be 0-23.") + hour = hour % 24 # Wrap around just in case + logger.debug(f"Extracted time (24-hour): {hour}:{minute:02d}") + + time_info = {'hour': hour, 'minute': minute} + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing time: {e}") + + # Extract date from title if date pattern exists + date_info = None + date_groups = {} + if date_regex: + date_match = date_regex.search(channel_name) + if date_match: + date_groups = date_match.groupdict() + try: + # Support various date group names: month, day, year + month_str = date_groups.get('month', '') + day = int(date_groups.get('day', 1)) + year = int(date_groups.get('year', now.year)) # Default to current year if not provided + + # Parse month - can be numeric (1-12) or text (Jan, January, etc.) + month = None + if month_str.isdigit(): + month = int(month_str) + else: + # Try to parse text month names + import calendar + month_str_lower = month_str.lower() + # Check full month names + for i, month_name in enumerate(calendar.month_name): + if month_name.lower() == month_str_lower: + month = i + break + # Check abbreviated month names if not found + if month is None: + for i, month_abbr in enumerate(calendar.month_abbr): + if month_abbr.lower() == month_str_lower: + month = i + break + + if month and 1 <= month <= 12 and 1 <= day <= 31: + date_info = {'year': year, 'month': month, 'day': day} + logger.debug(f"Extracted date: {year}-{month:02d}-{day:02d}") + else: + logger.warning(f"Invalid date values: month={month}, day={day}, year={year}") + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing date: {e}") + + # Merge title groups, time groups, and date groups for template formatting + all_groups = {**groups, **time_groups, **date_groups} + + # Generate programs + programs = [] + + # If we have extracted time AND date, the event happens on a SPECIFIC date + # If we have time but NO date, generate for multiple days (existing behavior) + # All other days and times show "Upcoming" before or "Ended" after + event_happened = False + + # Determine how many iterations we need + if date_info and time_info: + # Specific date extracted - only generate for that one date + iterations = 1 + logger.debug(f"Date extracted, generating single event for specific date") + else: + # No specific date - use num_days (existing behavior) + iterations = num_days + + for day in range(iterations): + # Start from current time (like standard dummy) instead of midnight + # This ensures programs appear in the guide's current viewing window + day_start = now + timedelta(days=day) + day_end = day_start + timedelta(days=1) + + if time_info: + # We have an extracted event time - this is when the MAIN event starts + # The extracted time is in the SOURCE timezone (e.g., 8PM ET) + # We need to convert it to UTC for storage + + # Determine which date to use + if date_info: + # Use the extracted date from the channel title + current_date = datetime( + date_info['year'], + date_info['month'], + date_info['day'] + ).date() + logger.debug(f"Using extracted date: {current_date}") + else: + # No date extracted, use day offset from current time (existing behavior) + current_date = (now + timedelta(days=day)).date() + logger.debug(f"No date extracted, using day offset: {current_date}") + + # Create a naive datetime (no timezone info) representing the event in source timezone + event_start_naive = datetime.combine( + current_date, + datetime.min.time().replace( + hour=time_info['hour'], + minute=time_info['minute'] + ) + ) + + # Use pytz to localize the naive datetime to the source timezone + # This automatically handles DST! + try: + event_start_local = source_tz.localize(event_start_naive) + # Convert to UTC + event_start_utc = event_start_local.astimezone(pytz.utc) + logger.debug(f"Converted {event_start_local} to UTC: {event_start_utc}") + except Exception as e: + logger.error(f"Error localizing time to {source_tz}: {e}") + # Fallback: treat as UTC + event_start_utc = django_timezone.make_aware(event_start_naive, pytz.utc) + + event_end_utc = event_start_utc + timedelta(minutes=program_duration) + + # Pre-generate the main event title and description for reuse + if title_template: + main_event_title = format_template(title_template, all_groups) + else: + title_parts = [] + if 'league' in all_groups and all_groups['league']: + title_parts.append(all_groups['league']) + if 'team1' in all_groups and 'team2' in all_groups: + title_parts.append(f"{all_groups['team1']} vs {all_groups['team2']}") + elif 'title' in all_groups and all_groups['title']: + title_parts.append(all_groups['title']) + main_event_title = ' - '.join(title_parts) if title_parts else channel_name + + if description_template: + main_event_description = format_template(description_template, all_groups) + else: + main_event_description = main_event_title + + + + # Determine if this day is before, during, or after the event + # Event only happens on day 0 (first day) + is_event_day = (day == 0) + + if is_event_day and not event_happened: + # This is THE day the event happens + # Fill programs BEFORE the event + current_time = day_start + + while current_time < event_start_utc: + program_start_utc = current_time + program_end_utc = min(current_time + timedelta(minutes=program_duration), event_start_utc) + + # Use custom upcoming templates if provided, otherwise use defaults + if upcoming_title_template: + upcoming_title = format_template(upcoming_title_template, all_groups) + else: + upcoming_title = main_event_title + + if upcoming_description_template: + upcoming_description = format_template(upcoming_description_template, all_groups) + else: + upcoming_description = f"Upcoming: {main_event_description}" + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": upcoming_title, + "description": upcoming_description, + }) + + current_time += timedelta(minutes=program_duration) + + # Add the MAIN EVENT at the extracted time + programs.append({ + "channel_id": channel_id, + "start_time": event_start_utc, + "end_time": event_end_utc, + "title": main_event_title, + "description": main_event_description, + }) + + event_happened = True + + # Fill programs AFTER the event until end of day + current_time = event_end_utc + + while current_time < day_end: + program_start_utc = current_time + program_end_utc = min(current_time + timedelta(minutes=program_duration), day_end) + + # Use custom ended templates if provided, otherwise use defaults + if ended_title_template: + ended_title = format_template(ended_title_template, all_groups) + else: + ended_title = main_event_title + + if ended_description_template: + ended_description = format_template(ended_description_template, all_groups) + else: + ended_description = f"Ended: {main_event_description}" + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": ended_title, + "description": ended_description, + }) + + current_time += timedelta(minutes=program_duration) + else: + # This day is either before the event (future days) or after the event happened + # Fill entire day with appropriate message + current_time = day_start + + # If event already happened, all programs show "Ended" + # If event hasn't happened yet (shouldn't occur with day 0 logic), show "Upcoming" + is_ended = event_happened + + while current_time < day_end: + program_start_utc = current_time + program_end_utc = min(current_time + timedelta(minutes=program_duration), day_end) + + # Use custom templates based on whether event has ended or is upcoming + if is_ended: + if ended_title_template: + program_title = format_template(ended_title_template, all_groups) + else: + program_title = main_event_title + + if ended_description_template: + program_description = format_template(ended_description_template, all_groups) + else: + program_description = f"Ended: {main_event_description}" + else: + if upcoming_title_template: + program_title = format_template(upcoming_title_template, all_groups) + else: + program_title = main_event_title + + if upcoming_description_template: + program_description = format_template(upcoming_description_template, all_groups) + else: + program_description = f"Upcoming: {main_event_description}" + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": program_title, + "description": program_description, + }) + + current_time += timedelta(minutes=program_duration) + else: + # No extracted time - fill entire day with regular intervals + # day_start and day_end are already in UTC, so no conversion needed + programs_per_day = max(1, int(24 / (program_duration / 60))) + + for program_num in range(programs_per_day): + program_start_utc = day_start + timedelta(minutes=program_num * program_duration) + program_end_utc = program_start_utc + timedelta(minutes=program_duration) + + if title_template: + title = format_template(title_template, all_groups) + else: + title_parts = [] + if 'league' in all_groups and all_groups['league']: + title_parts.append(all_groups['league']) + if 'team1' in all_groups and 'team2' in all_groups: + title_parts.append(f"{all_groups['team1']} vs {all_groups['team2']}") + elif 'title' in all_groups and all_groups['title']: + title_parts.append(all_groups['title']) + title = ' - '.join(title_parts) if title_parts else channel_name + + if description_template: + description = format_template(description_template, all_groups) + else: + description = title + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": title, + "description": description, + }) + + logger.info(f"Generated {len(programs)} custom dummy programs for {channel_name}") + return programs + + def generate_dummy_epg( channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4 ): @@ -367,7 +836,7 @@ def epg_generator(): dummy_days = num_days if num_days > 0 else 3 # Calculate cutoff date for EPG data filtering (only if days > 0) - now = timezone.now() + now = django_timezone.now() cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None # Process channels for the section @@ -434,12 +903,20 @@ def epg_generator(): # Default to channel number channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + # Use EPG data name for display, but channel name for pattern matching display_name = channel.epg_data.name if channel.epg_data else channel.name + # For dummy EPG pattern matching, always use the actual channel name + pattern_match_name = channel.name if not channel.epg_data: # Use the enhanced dummy EPG generation function with defaults program_length_hours = 4 # Default to 4-hour program blocks - dummy_programs = generate_dummy_programs(channel_id, display_name, num_days=dummy_days, program_length_hours=program_length_hours) + dummy_programs = generate_dummy_programs( + channel_id, pattern_match_name, + num_days=dummy_days, + program_length_hours=program_length_hours, + epg_source=None + ) for program in dummy_programs: # Format times in XMLTV format @@ -453,6 +930,31 @@ def epg_generator(): yield f" \n" else: + # Check if this is a dummy EPG with no programs (generate on-demand) + if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': + # This is a custom dummy EPG - check if it has programs + if not channel.epg_data.programs.exists(): + # No programs stored, generate on-demand using custom patterns + # Use actual channel name for pattern matching + program_length_hours = 4 + dummy_programs = generate_dummy_programs( + channel_id, pattern_match_name, + num_days=dummy_days, + program_length_hours=program_length_hours, + epg_source=channel.epg_data.epg_source + ) + + for program in dummy_programs: + start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") + stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") + + yield f' \n' + yield f" {html.escape(program['title'])}\n" + yield f" {html.escape(program['description'])}\n" + yield f" \n" + + continue # Skip to next channel + # For real EPG data - filter only if days parameter was specified if num_days > 0: programs_qs = channel.epg_data.programs.filter( @@ -1013,14 +1515,34 @@ def xc_get_epg(request, user, short=False): limit = request.GET.get('limit', 4) if channel.epg_data: - if short == False: - programs = channel.epg_data.programs.filter( - start_time__gte=timezone.now() - ).order_by('start_time') + # Check if this is a dummy EPG that generates on-demand + if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': + if not channel.epg_data.programs.exists(): + # Generate on-demand using custom patterns + programs = generate_dummy_programs( + channel_id=channel_id, + channel_name=channel.name, + epg_source=channel.epg_data.epg_source + ) + else: + # Has stored programs, use them + if short == False: + programs = channel.epg_data.programs.filter( + start_time__gte=django_timezone.now() + ).order_by('start_time') + else: + programs = channel.epg_data.programs.all().order_by('start_time')[:limit] else: - programs = channel.epg_data.programs.all().order_by('start_time')[:limit] + # Regular EPG with stored programs + if short == False: + programs = channel.epg_data.programs.filter( + start_time__gte=django_timezone.now() + ).order_by('start_time') + else: + programs = channel.epg_data.programs.all().order_by('start_time')[:limit] else: - programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name) + # No EPG data assigned, generate default dummy + programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None) output = {"epg_listings": []} for program in programs: @@ -1047,7 +1569,7 @@ def xc_get_epg(request, user, short=False): } if short == False: - program_output["now_playing"] = 1 if start <= timezone.now() <= end else 0 + program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 program_output["has_archive"] = "0" output['epg_listings'].append(program_output) @@ -1232,7 +1754,7 @@ def xc_get_series_info(request, user, series_id): try: should_refresh = ( not series_relation.last_episode_refresh or - series_relation.last_episode_refresh < timezone.now() - timedelta(hours=24) + series_relation.last_episode_refresh < django_timezone.now() - timedelta(hours=24) ) # Check if detailed data has been fetched diff --git a/core/api_urls.py b/core/api_urls.py index 00e20a6e4..baa4bbe54 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint +from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint, TimezoneListView router = DefaultRouter() router.register(r'useragents', UserAgentViewSet, basename='useragent') @@ -12,5 +12,6 @@ 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('', include(router.urls)), ] diff --git a/core/api_views.py b/core/api_views.py index 9de5aa5a6..f475909ad 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -5,10 +5,12 @@ import logging 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 from rest_framework.decorators import api_view, permission_classes, action from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi from .models import ( UserAgent, StreamProfile, @@ -328,25 +330,69 @@ def rehash_streams_endpoint(request): # Get the current hash keys from settings hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY) hash_keys = hash_key_setting.value.split(",") - + # 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 CoreSettings.DoesNotExist: return Response({ "success": False, "message": "Hash key settings not found" }, 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()] + + @swagger_auto_schema( + operation_description="Get list of all supported timezones", + responses={200: openapi.Response('List of timezones with grouping by region')} + ) + 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) + }) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 23a9a6567..0f46b012c 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -642,6 +642,16 @@ export const WebsocketProvider = ({ children }) => { } break; + case 'epg_data_created': + // A new EPG data entry was created (e.g., for a dummy EPG) + // Fetch EPG data so the channel form can immediately assign it + try { + await fetchEPGData(); + } catch (e) { + console.warn('Failed to refresh EPG data after creation:', e); + } + break; + case 'stream_rehash': // Handle stream rehash progress updates if (parsedEvent.data.action === 'starting') { diff --git a/frontend/src/api.js b/frontend/src/api.js index 4ef5f97eb..5b80a3f7d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1118,6 +1118,21 @@ export default class API { } } + static async getTimezones() { + try { + const response = await request(`${host}/api/core/timezones/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve timezones', e); + // Return fallback data instead of throwing + return { + timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'], + grouped: {}, + count: 5 + }; + } + } + static async getStreamProfiles() { try { const response = await request(`${host}/api/core/streamprofiles/`); diff --git a/frontend/src/components/forms/DummyEPG.jsx b/frontend/src/components/forms/DummyEPG.jsx new file mode 100644 index 000000000..8f2731186 --- /dev/null +++ b/frontend/src/components/forms/DummyEPG.jsx @@ -0,0 +1,761 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Box, + Button, + Divider, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, + TextInput, + Textarea, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import API from '../../api'; + +const DummyEPGForm = ({ epg, isOpen, onClose }) => { + // Separate state for each field to prevent focus loss + const [titlePattern, setTitlePattern] = useState(''); + const [timePattern, setTimePattern] = useState(''); + const [datePattern, setDatePattern] = useState(''); + const [sampleTitle, setSampleTitle] = useState(''); + const [titleTemplate, setTitleTemplate] = useState(''); + const [descriptionTemplate, setDescriptionTemplate] = useState(''); + const [upcomingTitleTemplate, setUpcomingTitleTemplate] = useState(''); + const [upcomingDescriptionTemplate, setUpcomingDescriptionTemplate] = + useState(''); + const [endedTitleTemplate, setEndedTitleTemplate] = useState(''); + const [endedDescriptionTemplate, setEndedDescriptionTemplate] = useState(''); + const [timezoneOptions, setTimezoneOptions] = useState([]); + const [loadingTimezones, setLoadingTimezones] = useState(true); + + const form = useForm({ + initialValues: { + name: '', + is_active: true, + source_type: 'dummy', + custom_properties: { + title_pattern: '', + time_pattern: '', + date_pattern: '', + timezone: 'US/Eastern', + program_duration: 180, + sample_title: '', + title_template: '', + description_template: '', + upcoming_title_template: '', + upcoming_description_template: '', + ended_title_template: '', + ended_description_template: '', + name_source: 'channel', + stream_index: 1, + }, + }, + validate: { + name: (value) => (value?.trim() ? null : 'Name is required'), + 'custom_properties.title_pattern': (value) => { + if (!value?.trim()) return 'Title pattern is required'; + try { + new RegExp(value); + return null; + } catch (e) { + return `Invalid regex: ${e.message}`; + } + }, + 'custom_properties.name_source': (value) => { + if (!value) return 'Name source is required'; + return null; + }, + 'custom_properties.stream_index': (value, values) => { + if (values.custom_properties?.name_source === 'stream') { + if (!value || value < 1) { + return 'Stream index must be at least 1'; + } + } + return null; + }, + }, + }); + + // Real-time pattern validation with useMemo to prevent re-renders + const patternValidation = useMemo(() => { + const result = { + titleMatch: false, + timeMatch: false, + dateMatch: false, + titleGroups: {}, + timeGroups: {}, + dateGroups: {}, + formattedTitle: '', + formattedDescription: '', + error: null, + }; + + // Validate title pattern + if (titlePattern && sampleTitle) { + try { + const titleRegex = new RegExp(titlePattern); + const titleMatch = sampleTitle.match(titleRegex); + + if (titleMatch) { + result.titleMatch = true; + result.titleGroups = titleMatch.groups || {}; + } + } catch (e) { + result.error = `Title pattern error: ${e.message}`; + } + } + + // Validate time pattern + if (timePattern && sampleTitle) { + try { + const timeRegex = new RegExp(timePattern); + const timeMatch = sampleTitle.match(timeRegex); + + if (timeMatch) { + result.timeMatch = true; + result.timeGroups = timeMatch.groups || {}; + } + } catch (e) { + result.error = result.error + ? `${result.error}; Time pattern error: ${e.message}` + : `Time pattern error: ${e.message}`; + } + } + + // Validate date pattern + if (datePattern && sampleTitle) { + try { + const dateRegex = new RegExp(datePattern); + const dateMatch = sampleTitle.match(dateRegex); + + if (dateMatch) { + result.dateMatch = true; + result.dateGroups = dateMatch.groups || {}; + } + } catch (e) { + result.error = result.error + ? `${result.error}; Date pattern error: ${e.message}` + : `Date pattern error: ${e.message}`; + } + } + + // Merge all groups for template formatting + const allGroups = { + ...result.titleGroups, + ...result.timeGroups, + ...result.dateGroups, + }; + + // Format title template + if (titleTemplate && (result.titleMatch || result.timeMatch)) { + result.formattedTitle = titleTemplate.replace( + /\{(\w+)\}/g, + (match, key) => allGroups[key] || match + ); + } + + // Format description template + if (descriptionTemplate && (result.titleMatch || result.timeMatch)) { + result.formattedDescription = descriptionTemplate.replace( + /\{(\w+)\}/g, + (match, key) => allGroups[key] || match + ); + } + + return result; + }, [ + titlePattern, + timePattern, + datePattern, + sampleTitle, + titleTemplate, + descriptionTemplate, + ]); + + useEffect(() => { + if (epg) { + const custom = epg.custom_properties || {}; + + form.setValues({ + name: epg.name || '', + is_active: epg.is_active ?? true, + source_type: 'dummy', + custom_properties: { + title_pattern: custom.title_pattern || '', + time_pattern: custom.time_pattern || '', + date_pattern: custom.date_pattern || '', + timezone: + custom.timezone || + custom.timezone_offset?.toString() || + 'US/Eastern', + program_duration: custom.program_duration || 180, + sample_title: custom.sample_title || '', + title_template: custom.title_template || '', + description_template: custom.description_template || '', + upcoming_title_template: custom.upcoming_title_template || '', + upcoming_description_template: + custom.upcoming_description_template || '', + ended_title_template: custom.ended_title_template || '', + ended_description_template: custom.ended_description_template || '', + name_source: custom.name_source || 'channel', + stream_index: custom.stream_index || 1, + }, + }); + + // Set controlled state + setTitlePattern(custom.title_pattern || ''); + setTimePattern(custom.time_pattern || ''); + setDatePattern(custom.date_pattern || ''); + setSampleTitle(custom.sample_title || ''); + setTitleTemplate(custom.title_template || ''); + setDescriptionTemplate(custom.description_template || ''); + setUpcomingTitleTemplate(custom.upcoming_title_template || ''); + setUpcomingDescriptionTemplate( + custom.upcoming_description_template || '' + ); + setEndedTitleTemplate(custom.ended_title_template || ''); + setEndedDescriptionTemplate(custom.ended_description_template || ''); + } else { + form.reset(); + setTitlePattern(''); + setTimePattern(''); + setDatePattern(''); + setSampleTitle(''); + setTitleTemplate(''); + setDescriptionTemplate(''); + setUpcomingTitleTemplate(''); + setUpcomingDescriptionTemplate(''); + setEndedTitleTemplate(''); + setEndedDescriptionTemplate(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [epg]); + + // Fetch available timezones from the API + useEffect(() => { + const fetchTimezones = async () => { + try { + setLoadingTimezones(true); + const response = await API.getTimezones(); + + // Convert timezone list to Select options format + const options = response.timezones.map((tz) => ({ + value: tz, + label: tz, + })); + + setTimezoneOptions(options); + } catch (error) { + console.error('Failed to load timezones:', error); + notifications.show({ + title: 'Warning', + message: 'Failed to load timezone list. Using default options.', + color: 'yellow', + }); + // Fallback to a minimal list + setTimezoneOptions([ + { value: 'UTC', label: 'UTC' }, + { value: 'US/Eastern', label: 'US/Eastern' }, + { value: 'US/Central', label: 'US/Central' }, + { value: 'US/Pacific', label: 'US/Pacific' }, + ]); + } finally { + setLoadingTimezones(false); + } + }; + + fetchTimezones(); + }, []); + + const handleSubmit = async (values) => { + try { + if (epg?.id) { + await API.updateEPG({ ...values, id: epg.id }); + notifications.show({ + title: 'Success', + message: 'Dummy EPG source updated successfully', + color: 'green', + }); + } else { + await API.addEPG(values); + notifications.show({ + title: 'Success', + message: 'Dummy EPG source created successfully', + color: 'green', + }); + } + onClose(); + } catch (error) { + notifications.show({ + title: 'Error', + message: error.message || 'Failed to save dummy EPG source', + color: 'red', + }); + } + }; + + return ( + +
+ + {/* Basic Settings */} + + + {/* Pattern Configuration */} + + + + Define regex patterns to extract information from channel titles or + stream names. Use named capture groups like + (?<groupname>pattern). + + +