Time Zones

- Added time zone settings
This commit is contained in:
Dispatcharr 2025-10-06 07:46:23 -05:00
parent 6536f35dc0
commit dea6411e1c
6 changed files with 948 additions and 207 deletions

View file

@ -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),
),
]

View file

@ -1,4 +1,6 @@
import json import json
from datetime import datetime
from rest_framework import serializers from rest_framework import serializers
from .models import ( from .models import (
Stream, Stream,
@ -530,8 +532,6 @@ class RecurringRecordingRuleSerializer(serializers.ModelSerializer):
def validate(self, attrs): def validate(self, attrs):
start = attrs.get("start_time") or getattr(self.instance, "start_time", None) start = attrs.get("start_time") or getattr(self.instance, "start_time", None)
end = attrs.get("end_time") or getattr(self.instance, "end_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) 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) end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None)
if start_date is None: if start_date is None:
@ -544,6 +544,13 @@ class RecurringRecordingRuleSerializer(serializers.ModelSerializer):
existing_end = getattr(self.instance, "end_date", None) existing_end = getattr(self.instance, "end_date", None)
if existing_end is None: if existing_end is None:
raise serializers.ValidationError("End date is required") 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 # Normalize empty strings to None for dates
if attrs.get("end_date") == "": if attrs.get("end_date") == "":
attrs["end_date"] = None attrs["end_date"] = None

View file

@ -8,6 +8,7 @@ import time
import json import json
import subprocess import subprocess
import signal import signal
from zoneinfo import ZoneInfo
from datetime import datetime, timedelta from datetime import datetime, timedelta
import gc import gc
@ -1140,7 +1141,12 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d
if not days: if not days:
return 0 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() start_limit = rule.start_date or now.date()
end_limit = rule.end_date end_limit = rule.end_date
horizon = now + timedelta(days=horizon_days) 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") list_path = os.path.join(workdir, "concat_list.txt")
with open(list_path, "w") as lf: with open(list_path, "w") as lf:
for pth in parts: 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") output_path = os.path.join(workdir, f"{os.path.splitext(os.path.basename(file_path))[0]}.cut.mkv")
subprocess.run([ subprocess.run([

View file

@ -1,4 +1,5 @@
# core/models.py # core/models.py
from django.conf import settings
from django.db import models from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.core.exceptions import ValidationError 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_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path")
DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes")
DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes")
SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone")
class CoreSettings(models.Model): class CoreSettings(models.Model):
@ -324,6 +326,30 @@ class CoreSettings(models.Model):
except Exception: except Exception:
return 0 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 @classmethod
def get_dvr_series_rules(cls): def get_dvr_series_rules(cls):
"""Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}"""

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import API from '../api'; import API from '../api';
import useSettingsStore from '../store/settings'; import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents'; import useUserAgentsStore from '../store/userAgents';
@ -35,6 +41,140 @@ import {
import ConfirmationDialog from '../components/ConfirmationDialog'; import ConfirmationDialog from '../components/ConfirmationDialog';
import useWarningsStore from '../store/warnings'; 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 SettingsPage = () => {
const settings = useSettingsStore((s) => s.settings); const settings = useSettingsStore((s) => s.settings);
const userAgents = useUserAgentsStore((s) => s.userAgents); const userAgents = useUserAgentsStore((s) => s.userAgents);
@ -61,12 +201,49 @@ const SettingsPage = () => {
const [comskipFile, setComskipFile] = useState(null); const [comskipFile, setComskipFile] = useState(null);
const [comskipUploadLoading, setComskipUploadLoading] = useState(false); const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
const [comskipConfig, setComskipConfig] = useState({ path: '', exists: false }); const [comskipConfig, setComskipConfig] = useState({
path: '',
exists: false,
});
// UI / local storage settings // UI / local storage settings
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h'); const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy'); 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; const regionChoices = REGION_CHOICES;
@ -187,8 +364,19 @@ const SettingsPage = () => {
console.error('Error parsing proxy settings:', error); 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(() => { useEffect(() => {
const loadComskipConfig = async () => { const loadComskipConfig = async () => {
@ -357,13 +545,19 @@ const SettingsPage = () => {
const onUISettingsChange = (name, value) => { const onUISettingsChange = (name, value) => {
switch (name) { switch (name) {
case 'table-size': case 'table-size':
setTableSize(value); if (value) setTableSize(value);
break; break;
case 'time-format': case 'time-format':
setTimeFormat(value); if (value) setTimeFormat(value);
break; break;
case 'date-format': case 'date-format':
setDateFormat(value); if (value) setDateFormat(value);
break;
case 'time-zone':
if (value) {
setTimeZone(value);
persistTimeZoneSetting(value);
}
break; break;
} }
}; };
@ -490,6 +684,14 @@ const SettingsPage = () => {
}, },
]} ]}
/> />
<Select
label="Time zone"
searchable
nothingFoundMessage="No matches"
value={timeZone}
onChange={(val) => onUISettingsChange('time-zone', val)}
data={timeZoneOptions}
/>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
@ -545,7 +747,9 @@ const SettingsPage = () => {
onClick={onComskipUpload} onClick={onComskipUpload}
disabled={!comskipFile || comskipUploadLoading} disabled={!comskipFile || comskipUploadLoading}
> >
{comskipUploadLoading ? 'Uploading...' : 'Upload comskip.ini'} {comskipUploadLoading
? 'Uploading...'
: 'Upload comskip.ini'}
</Button> </Button>
</Group> </Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">