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.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',
+ },
+ });
}}
/>
)}
) : (
-
-
-
-
- {channel ? `${channel.channel_number} • ${channel.name}` : '—'}
-
- {onWatchLive && (
-
- )}
- {onWatchRecording && (
-
- )}
- {onEdit && start.isAfter(dayjs()) && (
-
- )}
- {customProps.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && (
-
- )}
+
+
+
+
+
+ {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')}
+
{
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
+ const { toUserTime, userNow } = useTimeHelpers();
const channel = channels?.[recording.channel];
const deleteRecording = (id) => {
// Optimistically remove immediately from UI
- try { useChannelsStore.getState().removeRecording(id); } catch (error) { console.error('Failed to optimistically remove recording', error); }
+ 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 (error) { console.error('Failed to refresh recordings after delete', error); }
+ try {
+ useChannelsStore.getState().fetchRecordings();
+ } catch (error) {
+ console.error('Failed to refresh recordings after delete', error);
+ }
});
};
@@ -690,20 +975,27 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
posterUrl = `${window.location.protocol}//${window.location.hostname}:5656${posterUrl}`;
}
- const start = dayjs(recording.start_time);
- const end = dayjs(recording.end_time);
- const now = dayjs();
+ const start = toUserTime(recording.start_time);
+ const end = toUserTime(recording.end_time);
+ const now = userNow();
const status = customProps.status;
const isTimeActive = now.isAfter(start) && now.isBefore(end);
const isInterrupted = status === 'interrupted';
const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
const isUpcoming = now.isBefore(start);
- const isSeriesGroup = Boolean(recording._group_count && recording._group_count > 1);
+ const isSeriesGroup = Boolean(
+ recording._group_count && recording._group_count > 1
+ );
// Season/Episode display if present
const season = customProps.season ?? program?.custom_properties?.season;
const episode = customProps.episode ?? program?.custom_properties?.episode;
- const onscreen = customProps.onscreen_episode ?? program?.custom_properties?.onscreen_episode;
- const seLabel = season && episode ? `S${String(season).padStart(2,'0')}E${String(episode).padStart(2,'0')}` : (onscreen || null);
+ const onscreen =
+ customProps.onscreen_episode ??
+ program?.custom_properties?.onscreen_episode;
+ const seLabel =
+ season && episode
+ ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
+ : onscreen || null;
const handleWatchLive = () => {
if (!channel) return;
@@ -721,14 +1013,22 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
if (env_mode === 'dev' && fileUrl.startsWith('/')) {
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
}
- showVideo(fileUrl, 'vod', { name: recordingName, logo: { url: posterUrl } });
+ showVideo(fileUrl, 'vod', {
+ name: recordingName,
+ logo: { url: posterUrl },
+ });
};
const handleRunComskip = async (e) => {
e?.stopPropagation?.();
try {
await API.runComskip(recording.id);
- notifications.show({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', autoClose: 2000 });
+ notifications.show({
+ title: 'Removing commercials',
+ message: 'Queued comskip for this recording',
+ color: 'blue.5',
+ autoClose: 2000,
+ });
} catch (error) {
console.error('Failed to queue comskip for recording', error);
}
@@ -763,7 +1063,11 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
} finally {
setBusy(false);
setCancelOpen(false);
- try { await fetchRecordings(); } catch (error) { console.error('Failed to refresh recordings', error); }
+ try {
+ await fetchRecordings();
+ } catch (error) {
+ console.error('Failed to refresh recordings', error);
+ }
}
};
@@ -772,13 +1076,32 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
setBusy(true);
const { tvg_id, title } = seriesInfo;
if (tvg_id) {
- 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); }
+ 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 (error) { console.error('Failed to refresh recordings after series removal', error); }
+ try {
+ await fetchRecordings();
+ } catch (error) {
+ console.error(
+ 'Failed to refresh recordings after series removal',
+ error
+ );
+ }
}
};
@@ -805,8 +1128,24 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
>
-
- {isInterrupted ? 'Interrupted' : isInProgress ? 'Recording' : isUpcoming ? 'Scheduled' : 'Completed'}
+
+ {isInterrupted
+ ? 'Interrupted'
+ : isInProgress
+ ? 'Recording'
+ : isUpcoming
+ ? 'Scheduled'
+ : 'Completed'}
{isInterrupted && }
@@ -815,13 +1154,19 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
{recordingName}
{isSeriesGroup && (
- Series
+
+ Series
+
)}
{isRecurringRule && (
- Recurring
+
+ Recurring
+
)}
{seLabel && !isSeriesGroup && (
- {seLabel}
+
+ {seLabel}
+
)}
@@ -854,8 +1199,12 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
{!isSeriesGroup && subTitle && (
- Episode
- {subTitle}
+
+ Episode
+
+
+ {subTitle}
+
)}
@@ -871,47 +1220,85 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
{isSeriesGroup ? 'Next recording' : 'Time'}
- {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')}
+
+ {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')}
+
{!isSeriesGroup && description && (
- onOpenDetails?.(recording)} />
+ onOpenDetails?.(recording)}
+ />
)}
{isInterrupted && customProps.interrupted_reason && (
- {customProps.interrupted_reason}
+
+ {customProps.interrupted_reason}
+
)}
{isInProgress && (
- { e.stopPropagation(); handleWatchLive(); }}>
+ {
+ e.stopPropagation();
+ handleWatchLive();
+ }}
+ >
Watch Live
)}
{!isUpcoming && (
-
+
{ e.stopPropagation(); handleWatchRecording(); }}
- disabled={customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleWatchRecording();
+ }}
+ disabled={
+ customProps.status === 'recording' ||
+ !(customProps.file_url || customProps.output_file_url)
+ }
>
Watch
)}
- {!isUpcoming && customProps?.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && (
-
- Remove commercials
-
- )}
+ {!isUpcoming &&
+ customProps?.status === 'completed' &&
+ (!customProps?.comskip ||
+ customProps?.comskip?.status !== 'completed') && (
+
+ Remove commercials
+
+ )}
{/* 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?
- Only this upcoming
- Entire series + rule
+
+ Only this upcoming
+
+
+ Entire series + rule
+
@@ -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 = () => {
},
]}
/>
+