From 424a4506541ae78d4465c16f67b7b9ef0a268dbc Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 18 Sep 2025 10:23:16 -0500 Subject: [PATCH 1/4] 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 | 327 ++++++++++++++---- frontend/src/pages/DVR.jsx | 168 +++++++-- frontend/src/pages/Settings.jsx | 104 ++++++ frontend/src/store/channels.jsx | 18 + 15 files changed, 1056 insertions(+), 114 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 7cfdc1b1..7999abd9 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -13,12 +13,14 @@ from .api_views import ( UpdateChannelMembershipAPIView, BulkUpdateChannelMembershipAPIView, RecordingViewSet, + RecurringRecordingRuleViewSet, GetChannelStreamsAPIView, SeriesRulesAPIView, DeleteSeriesRuleAPIView, EvaluateSeriesRulesAPIView, BulkRemoveSeriesRecordingsAPIView, BulkDeleteUpcomingRecordingsAPIView, + ComskipConfigAPIView, ) app_name = 'channels' # for DRF routing @@ -30,6 +32,7 @@ router.register(r'channels', ChannelViewSet, basename='channel') 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 @@ urlpatterns = [ 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 7a3d5135..e7991220 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -28,6 +28,7 @@ from .models import ( ChannelProfile, ChannelProfileMembership, Recording, + RecurringRecordingRule, ) from .serializers import ( StreamSerializer, @@ -38,8 +39,17 @@ from .serializers import ( 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.db.models import Q 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 @@ class BulkUpdateChannelMembershipAPIView(APIView): 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 @@ class RecordingViewSet(viewsets.ModelViewSet): 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 00000000..1b8cfdb8 --- /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 af66178d..e6e3bd7a 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 51bfe0a0..d9b34549 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -10,6 +10,7 @@ from .models import ( ChannelProfile, ChannelProfileMembership, Recording, + RecurringRecordingRule, ) from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile @@ -454,6 +455,13 @@ class RecordingSerializer(serializers.ModelSerializer): 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 @@ class RecordingSerializer(serializers.ModelSerializer): 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 2760d1a7..e92c4794 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -7,6 +7,7 @@ import requests 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 comskip_process_recording(recording_id: int): 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 comskip_process_recording(recording_id: int): 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 comskip_process_recording(recording_id: int): 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 comskip_process_recording(recording_id: int): 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 comskip_process_recording(recording_id: int): 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 comskip_process_recording(recording_id: int): "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 00000000..e69de29b diff --git a/apps/channels/tests/test_recurring_rules.py b/apps/channels/tests/test_recurring_rules.py new file mode 100644 index 00000000..982ecb93 --- /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 ba040666..5584d7ca 100644 --- a/core/models.py +++ b/core/models.py @@ -158,6 +158,7 @@ DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir") 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 @@ class CoreSettings(models.Model): 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 289c6794..057780de 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -211,6 +211,10 @@ CELERY_BEAT_SCHEDULE = { "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 01186bf6..19de8cd0 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 7ac36a0f..342276ed 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 DVR = ({ recording = null, channel = null, isOpen, onClose }) => { +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 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: 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 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: (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; + }, + }, + }); - console.log(values); - - await API.createRecording({ - ...values, - channel: channel_id, + 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]); - form.reset(); - onClose(); + const resetForms = () => { + singleForm.reset(); + recurringForm.reset(); + setMode('single'); }; + const handleClose = () => { + resetForms(); + onClose?.(); + }; + + 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); + } + }; + + 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 3/4] 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 d41bebed..1fa2b68a 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -528,26 +528,28 @@ class RecurringRecordingRuleSerializer(serializers.ModelSerializer): 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 540934fc..688dc79d 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 8203cc20..90080676 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 ec6ebbff..83faae06 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 dea6411e1ca7200d95b6f6ffceb1c55a5a61aa5b Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Mon, 6 Oct 2025 07:46:23 -0500 Subject: [PATCH 4/4] 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 00000000..8cdb9868 --- /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 1fa2b68a..7058ced2 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 @@ class RecurringRecordingRuleSerializer(serializers.ModelSerializer): 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 @@ class RecurringRecordingRuleSerializer(serializers.ModelSerializer): 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 688dc79d..23ae82b2 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -8,6 +8,7 @@ import time 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 comskip_process_recording(recording_id: int): 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 5584d7ca..3a5895ba 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 @@ 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") +SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone") class CoreSettings(models.Model): @@ -324,6 +326,30 @@ class CoreSettings(models.Model): 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 83faae06..ae2fd4ca 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') && ( - - )} + + {recordingName} + + + + {channel ? `${channel.channel_number} • ${channel.name}` : '—'} + + + {onWatchLive && ( + + )} + {onWatchRecording && ( + + )} + {onEdit && start.isAfter(userNow()) && ( + + )} + {customProps.status === 'completed' && + (!customProps?.comskip || + customProps?.comskip?.status !== 'completed') && ( + + )} + - - {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} - - ))} - - )} - - + + {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 fa30cd74..865358df 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 = () => { }, ]} /> +