From 6536f35dc0e7d01b060d646f4dcb2ddb4b3f5ca8 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Fri, 19 Sep 2025 19:47:59 -0500 Subject: [PATCH] FIxed bug Fixed bug that stopped stream from ending --- apps/channels/serializers.py | 14 ++- apps/channels/tasks.py | 11 +- frontend/src/components/forms/Recording.jsx | 120 +++++++++++--------- frontend/src/pages/DVR.jsx | 19 ++-- 4 files changed, 93 insertions(+), 71 deletions(-) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index d41bebed..1fa2b68a 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -528,26 +528,28 @@ class RecurringRecordingRuleSerializer(serializers.ModelSerializer): return sorted(set(cleaned)) def validate(self, attrs): - from django.utils import timezone start = attrs.get("start_time") or getattr(self.instance, "start_time", None) end = attrs.get("end_time") or getattr(self.instance, "end_time", None) if start and end and end <= start: raise serializers.ValidationError("End time must be after start time") start_date = attrs.get("start_date") if "start_date" in attrs else getattr(self.instance, "start_date", None) end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None) + if start_date is None: + existing_start = getattr(self.instance, "start_date", None) + if existing_start is None: + raise serializers.ValidationError("Start date is required") if start_date and end_date and end_date < start_date: raise serializers.ValidationError("End date must be on or after start date") + if end_date is None: + existing_end = getattr(self.instance, "end_date", None) + if existing_end is None: + raise serializers.ValidationError("End date is required") # Normalize empty strings to None for dates if attrs.get("end_date") == "": attrs["end_date"] = None if attrs.get("start_date") == "": attrs["start_date"] = None - if attrs.get("start_date") is None and not getattr(self.instance, "start_date", None): - attrs["start_date"] = timezone.localdate() return super().validate(attrs) def create(self, validated_data): - from django.utils import timezone - if not validated_data.get("start_date"): - validated_data["start_date"] = timezone.localdate() return super().create(validated_data) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 540934fc..688dc79d 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1145,9 +1145,12 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d end_limit = rule.end_date horizon = now + timedelta(days=horizon_days) start_window = max(start_limit, now.date()) - end_window = horizon.date() - if end_limit and end_limit < end_window: + if drop_existing and end_limit: end_window = end_limit + else: + end_window = horizon.date() + if end_limit and end_limit < end_window: + end_window = end_limit if end_window < start_window: return 0 total_created = 0 @@ -1163,7 +1166,9 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d end_dt = timezone.make_aware(datetime.combine(target_date, rule.end_time), tz) except Exception: continue - if end_dt <= start_dt or start_dt <= now: + if end_dt <= start_dt: + end_dt = end_dt + timedelta(days=1) + if start_dt <= now: continue exists = Recording.objects.filter( channel=rule.channel, diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 8203cc20..90080676 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -10,7 +10,6 @@ import { SegmentedControl, MultiSelect, Group, - Text, TextInput, } from '@mantine/core'; import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; @@ -41,13 +40,12 @@ const toIsoIfDate = (value) => { return dt ? dt.toISOString() : value; }; +// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" const toTimeString = (value) => { if (!value) return '00:00'; if (typeof value === 'string') { - const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true); - if (parsed.isValid()) { - return parsed.format('HH:mm'); - } + const parsed = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], true); + if (parsed.isValid()) return parsed.format('HH:mm'); return value; } const dt = asDate(value); @@ -69,12 +67,16 @@ const createRoundedDate = (minutesAhead = 0) => { dt.setSeconds(0); dt.setMilliseconds(0); dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) { - dt.setMinutes(dt.getMinutes() + minutesAhead); - } + if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); return dt; }; +// robust onChange for TimeInput (string or event) +const timeChange = (setter) => (valOrEvent) => { + if (typeof valOrEvent === 'string') setter(valOrEvent); + else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); +}; + const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => { const channels = useChannelsStore((s) => s.channels); const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); @@ -87,14 +89,11 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = const defaultEnd = createRoundedDate(60); const defaultDate = new Date(); + // One-time form const singleForm = useForm({ mode: 'controlled', initialValues: { - channel_id: recording - ? `${recording.channel}` - : channel - ? `${channel.id}` - : '', + channel_id: recording ? `${recording.channel}` : channel ? `${channel.id}` : '', start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart, end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd, }, @@ -111,8 +110,11 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = }, }); + // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', + validateInputOnChange: false, + validateInputOnBlur: true, initialValues: { channel_id: channel ? `${channel.id}` : '', days_of_week: [], @@ -120,34 +122,38 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = end_time: dayjs(defaultEnd).format('HH:mm'), rule_name: '', start_date: defaultDate, - end_date: null, + end_date: defaultDate, }, validate: { channel_id: isNotEmpty('Select a channel'), days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'), - start_time: isNotEmpty('Select a start time'), + start_time: (value) => (value ? null : 'Select a start time'), end_time: (value, values) => { - const start = asDate(values.start_time); - const end = asDate(value); - if (!end) return 'Select an end time'; - if (start && end <= start) return 'End time must be after start time'; + if (!value) return 'Select an end time'; + const start = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + if (start.isValid() && end.isValid() && end.diff(start, 'minute') === 0) { + return 'End time must differ from start time'; + } return null; }, end_date: (value, values) => { const end = asDate(value); const start = asDate(values.start_date); - if (end && start && end < start) { - return 'End date cannot be before start date'; - } + if (!end) return 'Select an end date'; + if (start && end < start) return 'End date cannot be before start date'; return null; }, }, }); useEffect(() => { - if (!isOpen) { - return; - } + if (!isOpen) return; + + const freshStart = createRoundedDate(); + const freshEnd = createRoundedDate(60); + const freshDate = new Date(); + if (recording && recording.id) { setMode('single'); singleForm.setValues({ @@ -156,23 +162,22 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = end_time: asDate(recording.end_time) || defaultEnd, }); } else { - // Reset forms to defaults when opening fresh - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); + // Reset forms for fresh open singleForm.setValues({ channel_id: channel ? `${channel.id}` : '', start_time: freshStart, end_time: freshEnd, }); + + const startStr = dayjs(freshStart).format('HH:mm'); recurringForm.setValues({ channel_id: channel ? `${channel.id}` : '', days_of_week: [], - start_time: dayjs(freshStart).format('HH:mm'), + start_time: startStr, end_time: dayjs(freshEnd).format('HH:mm'), rule_name: channel?.name || '', start_date: freshDate, - end_date: null, + end_date: freshDate, }); setMode('single'); } @@ -184,9 +189,7 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = list.sort((a, b) => { const aNum = Number(a.channel_number) || 0; const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) { - return (a.name || '').localeCompare(b.name || ''); - } + if (aNum === bNum) return (a.name || '').localeCompare(b.name || ''); return aNum - bNum; }); return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` })); @@ -252,6 +255,7 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = end_date: toDateString(values.end_date), name: values.rule_name?.trim() || '', }); + await Promise.all([fetchRecurringRules(), fetchRecordings()]); notifications.show({ title: 'Recurring rule saved', @@ -267,13 +271,12 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = } }; - const onSubmit = mode === 'single' - ? singleForm.onSubmit(handleSingleSubmit) - : recurringForm.onSubmit(handleRecurringSubmit); + const onSubmit = + mode === 'single' + ? singleForm.onSubmit(handleSingleSubmit) + : recurringForm.onSubmit(handleRecurringSubmit); - if (!isOpen) { - return null; - } + if (!isOpen) return null; return ( @@ -326,15 +329,15 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = {...singleForm.getInputProps('start_time')} key={singleForm.key('start_time')} label="Start" - valueFormat="MMM D, YYYY hh:mm A" - timeInputProps={{ format: '12', amLabel: 'AM', pmLabel: 'PM', withSeconds: false }} + valueFormat="MMM D, YYYY h:mm A" + timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }} /> ) : ( @@ -353,41 +356,52 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) = data={DAY_OPTIONS} searchable clearable - nothingFound="No match" + nothingFoundMessage="No match" /> + recurringForm.setFieldValue('start_date', value || new Date())} + onChange={(value) => + recurringForm.setFieldValue('start_date', value || new Date()) + } valueFormat="MMM D, YYYY" /> recurringForm.setFieldValue('end_date', value)} valueFormat="MMM D, YYYY" minDate={recurringForm.values.start_date || undefined} /> + recurringForm.setFieldValue('start_time', value)} label="Start time" + value={recurringForm.values.start_time} + onChange={timeChange((val) => + recurringForm.setFieldValue('start_time', toTimeString(val)) + )} + onBlur={() => recurringForm.validateField('start_time')} withSeconds={false} - format="12" + format="12" // shows 12-hour (so "00:00" renders "12:00 AM") + inputMode="numeric" amLabel="AM" pmLabel="PM" /> + recurringForm.setFieldValue('end_time', value)} label="End time" + value={recurringForm.values.end_time} + onChange={timeChange((val) => + recurringForm.setFieldValue('end_time', toTimeString(val)) + )} + onBlur={() => recurringForm.validateField('end_time')} withSeconds={false} format="12" + inputMode="numeric" amLabel="AM" pmLabel="PM" /> diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index ec6ebbff..83faae06 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -375,23 +375,26 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { start_time: dayjs().startOf('hour').format('HH:mm'), end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), start_date: dayjs().toDate(), - end_date: null, + end_date: dayjs().toDate(), enabled: true, }, validate: { channel_id: (value) => (value ? null : 'Select a channel'), days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'), end_time: (value, values) => { - const startValue = dayjs(values.start_time); - const endValue = dayjs(value); if (!value) return 'Select an end time'; - if (endValue.isSameOrBefore(startValue)) return 'End time must be after start time'; + const startValue = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); + if (startValue.isValid() && endValue.isValid() && endValue.diff(startValue, 'minute') === 0) { + return 'End time must differ from start time'; + } return null; }, end_date: (value, values) => { const endDate = dayjs(value); const startDate = dayjs(values.start_date); - if (value && startDate.isValid() && endDate.isBefore(startDate, 'day')) { + if (!value) return 'Select an end date'; + if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { return 'End date cannot be before start date'; } return null; @@ -564,8 +567,6 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { /> form.setFieldValue('end_date', value)} valueFormat="MMM D, YYYY" @@ -576,7 +577,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { form.setFieldValue('start_time', value)} + onChange={(value) => form.setFieldValue('start_time', toTimeString(value))} withSeconds={false} format="12" amLabel="AM" @@ -585,7 +586,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { form.setFieldValue('end_time', value)} + onChange={(value) => form.setFieldValue('end_time', toTimeString(value))} withSeconds={false} format="12" amLabel="AM"