mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Time Zones
- Added time zone settings
This commit is contained in:
parent
6536f35dc0
commit
dea6411e1c
6 changed files with 948 additions and 207 deletions
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue