Dispatcharr/frontend/src/components/forms/RecurringRuleModal.jsx
None 759688390b fix(dvr): fix series episode list, card layout, rule cleanup, and recording resilience
Backend:
- Add terminal status guard to stop() endpoint -prevents overwriting completed/interrupted/failed with "stopped" (returns 409 Conflict)
- Include currently-airing programs in series rule evaluation by filtering on end_time instead of start_time
- Clean up future recordings when deleting a series rule while preserving previously recorded episodes
- Add logo proxy negative cache to prevent repeated requests to unreachable hosts from blocking Daphne workers
- Add URL validation with HEAD-then-GET fallback and per-worker cache
- Add EPG time-slot matching (80% overlap threshold) to enrich manual recordings with program metadata
- Add multi-source poster resolution pipeline (EPG, VOD, TMDB, OMDb, TVMaze, iTunes) with URL validation at each stage
- Add editable recording metadata endpoint with user_edited flag to prevent EPG auto-overwrite
- Add refresh-artwork endpoint to re-run poster resolution on demand
- Batch-fetch PeriodicTask names in schedule_upcoming to avoid N+1

Frontend:
- Fix series modal showing only one episode — preserve _group_count from categorized prop when merging with store version so the episode list renders correctly
- Fix recording card button positioning — use flex column layout with marginTop auto so buttons align at the bottom regardless of content
- Add delete fallback in RecurringRuleModal for orphaned recordings from deleted rules
- Use end_time filter in upcoming episodes list to include currently-airing episodes
- Add inline metadata editing (title/description) in details modal
- Add refresh artwork button in details modal
- Replace hardcoded /logo.png fallbacks with imported default logo
- Add React.memo with custom comparator to RecordingCard

Tests:
- Add 21 EPG time-slot matching tests (overlap threshold, edge cases)
- Add 16 recording metadata tests (validation, artwork, logo cache)
- Add 17 URL validation tests (HEAD/GET fallback, caching, eviction)
2026-03-04 10:21:03 -06:00

430 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import useChannelsStore from '../../store/channels.jsx';
import {
parseDate,
RECURRING_DAY_OPTIONS,
toTimeString,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from '@mantine/form';
import dayjs from 'dayjs';
import { notifications } from '@mantine/notifications';
import {
Badge,
Button,
Card,
Group,
Modal,
MultiSelect,
Select,
Stack,
Switch,
Text,
TextInput,
} from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import {
deleteRecurringRuleById,
getChannelOptions,
getUpcomingOccurrences,
updateRecurringRule,
updateRecurringRuleEnabled,
} from '../../utils/forms/RecurringRuleModalUtils.js';
const RecurringRuleModal = ({ opened, onClose, ruleId, recording: sourceRecording, onEditOccurrence }) => {
const channels = useChannelsStore((s) => s.channels);
const recurringRules = useChannelsStore((s) => s.recurringRules);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
const recordings = useChannelsStore((s) => s.recordings);
const { toUserTime, userNow } = useTimeHelpers();
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [busyOccurrence, setBusyOccurrence] = useState(null);
const rule = recurringRules.find((r) => r.id === ruleId);
const channelOptions = useMemo(() => {
return getChannelOptions(channels);
}, [channels]);
const form = useForm({
mode: 'controlled',
initialValues: {
channel_id: '',
days_of_week: [],
rule_name: '',
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: 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) => {
if (!value) return 'Select an end 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) return 'Select an end date';
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
return 'End date cannot be before start date';
}
return null;
},
},
});
useEffect(() => {
if (opened && rule) {
form.setValues({
channel_id: `${rule.channel}`,
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
rule_name: rule.name || '',
start_time: toTimeString(rule.start_time),
end_time: toTimeString(rule.end_time),
start_date: parseDate(rule.start_date) || dayjs().toDate(),
end_date: parseDate(rule.end_date),
enabled: Boolean(rule.enabled),
});
} else {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, ruleId, rule]);
const upcomingOccurrences = useMemo(() => {
return getUpcomingOccurrences(recordings, userNow, ruleId, toUserTime);
}, [recordings, ruleId, toUserTime, userNow]);
const handleSave = async (values) => {
if (!rule) return;
setSaving(true);
try {
await updateRecurringRule(ruleId, values);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
title: 'Recurring rule updated',
message: 'Schedule adjustments saved',
color: 'green',
autoClose: 2500,
});
onClose();
} catch (error) {
console.error('Failed to update recurring rule', error);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!rule) return;
setDeleting(true);
try {
await deleteRecurringRuleById(ruleId);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
title: 'Recurring rule removed',
message: 'All future occurrences were cancelled',
color: 'red',
autoClose: 2500,
});
onClose();
} catch (error) {
console.error('Failed to delete recurring rule', error);
} finally {
setDeleting(false);
}
};
const handleToggleEnabled = async (checked) => {
if (!rule) return;
setSaving(true);
try {
await updateRecurringRuleEnabled(ruleId, checked);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
message: checked
? 'Future occurrences will resume'
: 'Upcoming occurrences were removed',
color: checked ? 'green' : 'yellow',
autoClose: 2500,
});
} catch (error) {
console.error('Failed to toggle recurring rule', error);
form.setFieldValue('enabled', !checked);
} finally {
setSaving(false);
}
};
const handleCancelOccurrence = async (occurrence) => {
setBusyOccurrence(occurrence.id);
try {
await deleteRecordingById(occurrence.id);
// recording_cancelled WS event handles recording list update
notifications.show({
title: 'Occurrence cancelled',
message: 'The selected airing was removed',
color: 'yellow',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to cancel occurrence', error);
} finally {
setBusyOccurrence(null);
}
};
if (!rule) {
return (
<Modal opened={opened} onClose={onClose} title="Recurring Rule" centered>
<Stack gap="md">
<Text size="sm">
The recurring rule for this recording no longer exists.
</Text>
{sourceRecording && (
<>
<Text size="sm" c="dimmed">
Would you like to delete this recording?
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="red"
loading={deleting}
onClick={async () => {
setDeleting(true);
try {
await deleteRecordingById(sourceRecording.id);
notifications.show({
title: 'Recording deleted',
color: 'green',
autoClose: 2500,
});
onClose();
} catch (e) {
console.error('Failed to delete orphaned recording', e);
} finally {
setDeleting(false);
}
}}
>
Delete Recording
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
}
const handleEnableChange = (event) => {
form.setFieldValue('enabled', event.currentTarget.checked);
handleToggleEnabled(event.currentTarget.checked);
};
const handleStartDateChange = (value) => {
form.setFieldValue('start_date', value || dayjs().toDate());
};
const handleEndDateChange = (value) => {
form.setFieldValue('end_date', value);
};
const handleStartTimeChange = (value) => {
form.setFieldValue('start_time', toTimeString(value));
};
const handleEndTimeChange = (value) => {
form.setFieldValue('end_time', toTimeString(value));
};
const UpcomingList = () => {
return (
<Stack gap="xs">
{upcomingOccurrences.map((occ) => {
const occStart = toUserTime(occ.start_time);
const occEnd = toUserTime(occ.end_time);
return (
<Card key={`occ-${occ.id}`} withBorder padding="sm" radius="md">
<Group justify="space-between" align="center">
<Stack gap={2} flex={1}>
<Text fw={600} size="sm">
{occStart.format(`${dateformat}, YYYY`)}
</Text>
<Text size="xs" c="dimmed">
{occStart.format(timeformat)} {occEnd.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button
size="xs"
variant="subtle"
onClick={() => {
onClose();
onEditOccurrence?.(occ);
}}
>
Edit
</Button>
<Button
size="xs"
color="red"
variant="light"
loading={busyOccurrence === occ.id}
onClick={() => handleCancelOccurrence(occ)}
>
Cancel
</Button>
</Group>
</Group>
</Card>
);
})}
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={onClose}
title={rule.name || 'Recurring Rule'}
size="lg"
centered
>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>
{channels?.[rule.channel]?.name || `Channel ${rule.channel}`}
</Text>
<Switch
size="sm"
checked={form.values.enabled}
onChange={handleEnableChange}
label={form.values.enabled ? 'Enabled' : 'Paused'}
disabled={saving}
/>
</Group>
<form onSubmit={form.onSubmit(handleSave)}>
<Stack gap="md">
<Select
{...form.getInputProps('channel_id')}
label="Channel"
data={channelOptions}
searchable
/>
<TextInput
{...form.getInputProps('rule_name')}
label="Rule name"
placeholder="Morning News, Football Sundays, ..."
/>
<MultiSelect
{...form.getInputProps('days_of_week')}
label="Every"
data={RECURRING_DAY_OPTIONS.map((opt) => ({
value: String(opt.value),
label: opt.label,
}))}
searchable
clearable
/>
<Group grow>
<DatePickerInput
label="Start date"
value={form.values.start_date}
onChange={handleStartDateChange}
valueFormat="MMM D, YYYY"
/>
<DatePickerInput
label="End date"
value={form.values.end_date}
onChange={handleEndDateChange}
valueFormat="MMM D, YYYY"
minDate={form.values.start_date || undefined}
/>
</Group>
<Group grow>
<TimeInput
label="Start time"
value={form.values.start_time}
onChange={handleStartTimeChange}
withSeconds={false}
format="12"
amLabel="AM"
pmLabel="PM"
/>
<TimeInput
label="End time"
value={form.values.end_time}
onChange={handleEndTimeChange}
withSeconds={false}
format="12"
amLabel="AM"
pmLabel="PM"
/>
</Group>
<Group justify="space-between">
<Button type="submit" loading={saving}>
Save changes
</Button>
<Button
color="red"
variant="light"
loading={deleting}
onClick={handleDelete}
>
Delete rule
</Button>
</Group>
</Stack>
</form>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
Upcoming occurrences
</Text>
<Badge color="blue.6">{upcomingOccurrences.length}</Badge>
</Group>
{upcomingOccurrences.length === 0 ? (
<Text size="sm" c="dimmed">
No future airings currently scheduled.
</Text>
) : (
<UpcomingList />
)}
</Stack>
</Stack>
</Modal>
);
};
export default RecurringRuleModal;