From dea6411e1ca7200d95b6f6ffceb1c55a5a61aa5b Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Mon, 6 Oct 2025 07:46:23 -0500 Subject: [PATCH] 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 = () => { }, ]} /> +