diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx
index 23f78867..163a43a7 100644
--- a/frontend/src/components/forms/Recording.jsx
+++ b/frontend/src/components/forms/Recording.jsx
@@ -1,86 +1,40 @@
import React, { useEffect, useMemo, useState } from 'react';
-import dayjs from 'dayjs';
-import API from '../../api';
import {
Alert,
Button,
+ Group,
+ Loader,
Modal,
+ MultiSelect,
+ SegmentedControl,
Select,
Stack,
- SegmentedControl,
- MultiSelect,
- Group,
TextInput,
- Loader,
} from '@mantine/core';
-import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates';
+import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates';
import { CircleAlert } from 'lucide-react';
-import { isNotEmpty, useForm } from '@mantine/form';
+import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
-import { notifications } from '@mantine/notifications';
-
-const DAY_OPTIONS = [
- { value: '6', label: 'Sun' },
- { value: '0', label: 'Mon' },
- { value: '1', label: 'Tue' },
- { value: '2', label: 'Wed' },
- { value: '3', label: 'Thu' },
- { value: '4', label: 'Fri' },
- { value: '5', label: 'Sat' },
-];
-
-const asDate = (value) => {
- if (!value) return null;
- if (value instanceof Date) return value;
- const parsed = new Date(value);
- return Number.isNaN(parsed.getTime()) ? null : parsed;
-};
-
-const toIsoIfDate = (value) => {
- const dt = asDate(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 A', 'h:mm A', 'HH:mm:ss'],
- true
- );
- if (parsed.isValid()) return parsed.format('HH:mm');
- return value;
- }
- const dt = asDate(value);
- if (!dt) return '00:00';
- return dayjs(dt).format('HH:mm');
-};
-
-const toDateString = (value) => {
- const dt = asDate(value);
- if (!dt) return null;
- const year = dt.getFullYear();
- const month = String(dt.getMonth() + 1).padStart(2, '0');
- const day = String(dt.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
-};
-
-const createRoundedDate = (minutesAhead = 0) => {
- const dt = new Date();
- dt.setSeconds(0);
- dt.setMilliseconds(0);
- dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30);
- 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);
-};
+import {
+ RECURRING_DAY_OPTIONS,
+ toTimeString,
+} from '../../utils/dateTimeUtils.js';
+import { showNotification } from '../../utils/notificationUtils.js';
+import {
+ buildRecurringPayload,
+ buildSinglePayload,
+ createRecording,
+ createRecurringRule,
+ getChannelsSummary,
+ getRecurringFormDefaults,
+ getSingleFormDefaults,
+ numberedChannelLabel,
+ recurringFormValidators,
+ singleFormValidators,
+ sortedChannelOptions,
+ timeChange,
+ updateRecording,
+} from '../../utils/forms/RecordingUtils.js';
const RecordingModal = ({
recording = null,
@@ -98,117 +52,29 @@ const RecordingModal = ({
const [mode, setMode] = useState('single');
const [submitting, setSubmitting] = useState(false);
- const defaultStart = createRoundedDate();
- 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}`
- : '',
- start_time: recording
- ? asDate(recording.start_time) || defaultStart
- : defaultStart,
- end_time: recording
- ? asDate(recording.end_time) || defaultEnd
- : defaultEnd,
- },
- validate: {
- channel_id: isNotEmpty('Select a channel'),
- start_time: isNotEmpty('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';
- return null;
- },
- },
+ initialValues: getSingleFormDefaults(recording, channel),
+ validate: singleFormValidators,
});
- // 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: [],
- start_time: dayjs(defaultStart).format('HH:mm'),
- end_time: dayjs(defaultEnd).format('HH:mm'),
- rule_name: '',
- start_date: defaultDate,
- 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: (value) => (value ? null : 'Select a start time'),
- end_time: (value, values) => {
- 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) return 'Select an end date';
- if (start && end < start) return 'End date cannot be before start date';
- return null;
- },
- },
+ initialValues: getRecurringFormDefaults(channel),
+ validate: recurringFormValidators,
});
useEffect(() => {
if (!isOpen) return;
- const freshStart = createRoundedDate();
- const freshEnd = createRoundedDate(60);
- const freshDate = new Date();
-
- if (recording && recording.id) {
+ if (recording?.id) {
setMode('single');
- singleForm.setValues({
- channel_id: `${recording.channel}`,
- start_time: asDate(recording.start_time) || defaultStart,
- end_time: asDate(recording.end_time) || defaultEnd,
- });
+ singleForm.setValues(getSingleFormDefaults(recording, channel));
} else {
- // 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: startStr,
- end_time: dayjs(freshEnd).format('HH:mm'),
- rule_name: channel?.name || '',
- start_date: freshDate,
- end_date: freshDate,
- });
+ singleForm.setValues(getSingleFormDefaults(null, channel));
+ recurringForm.setValues(getRecurringFormDefaults(channel));
setMode('single');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -221,7 +87,7 @@ const RecordingModal = ({
if (!isOpen) return;
try {
setIsChannelsLoading(true);
- const chans = await API.getChannelsSummary();
+ const chans = await getChannelsSummary();
if (cancelled) return;
setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
@@ -238,19 +104,7 @@ const RecordingModal = ({
}, [isOpen]);
const channelOptions = useMemo(() => {
- const list = Array.isArray(allChannels) ? [...allChannels] : [];
- 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 || '');
- return aNum - bNum;
- });
- return list.map((item) => ({
- value: `${item.id}`,
- label: item.channel_number
- ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
- : item.name || `Channel ${item.id}`,
- }));
+ return sortedChannelOptions(allChannels, numberedChannelLabel);
}, [allChannels]);
const resetForms = () => {
@@ -267,25 +121,18 @@ const RecordingModal = ({
const handleSingleSubmit = async (values) => {
try {
setSubmitting(true);
+ const payload = buildSinglePayload(values);
if (recording && recording.id) {
- await API.updateRecording(recording.id, {
- channel: values.channel_id,
- start_time: toIsoIfDate(values.start_time),
- end_time: toIsoIfDate(values.end_time),
- });
- notifications.show({
+ await updateRecording(recording.id, payload);
+ showNotification({
title: 'Recording updated',
message: 'Recording schedule updated successfully',
color: 'green',
autoClose: 2500,
});
} else {
- await API.createRecording({
- channel: values.channel_id,
- start_time: toIsoIfDate(values.start_time),
- end_time: toIsoIfDate(values.end_time),
- });
- notifications.show({
+ await createRecording(payload);
+ showNotification({
title: 'Recording scheduled',
message: 'One-time recording added to DVR queue',
color: 'green',
@@ -304,18 +151,10 @@ const RecordingModal = ({
const handleRecurringSubmit = async (values) => {
try {
setSubmitting(true);
- await API.createRecurringRule({
- channel: values.channel_id,
- 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: toDateString(values.start_date),
- end_date: toDateString(values.end_date),
- name: values.rule_name?.trim() || '',
- });
+ await createRecurringRule(buildRecurringPayload(values));
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- notifications.show({
+ showNotification({
title: 'Recurring rule saved',
message: 'Future slots will be scheduled automatically',
color: 'green',
@@ -427,7 +266,10 @@ const RecordingModal = ({
key={recurringForm.key('days_of_week')}
label="Every"
placeholder="Select days"
- data={DAY_OPTIONS}
+ data={RECURRING_DAY_OPTIONS.map((opt) => ({
+ value: String(opt.value),
+ label: opt.label,
+ }))}
searchable
clearable
nothingFoundMessage="No match"
diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx
index bc8b245f..43351765 100644
--- a/frontend/src/components/forms/RecordingDetailsModal.jsx
+++ b/frontend/src/components/forms/RecordingDetailsModal.jsx
@@ -1,11 +1,13 @@
import useChannelsStore from '../../store/channels.jsx';
-import API from '../../api';
import {
+ format,
+ isAfter,
+ isBefore,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
-import React from 'react';
-import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Check, Pencil, RefreshCcw, X } from 'lucide-react';
import {
ActionIcon,
Badge,
@@ -21,7 +23,6 @@ import {
TextInput,
} from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
-import { notifications } from '@mantine/notifications';
import defaultLogo from '../../images/logo.png';
import {
deleteRecordingById,
@@ -33,10 +34,14 @@ import {
runComSkip,
} from '../../utils/cards/RecordingCardUtils.js';
import {
+ getChannel,
getRating,
getStatRows,
getUpcomingEpisodes,
+ refreshArtwork,
+ updateRecordingMetadata,
} from '../../utils/forms/RecordingDetailsModalUtils.js';
+import { showNotification } from '../../utils/notificationUtils.js';
const RecordingDetailsModal = ({
opened,
@@ -51,21 +56,21 @@ const RecordingDetailsModal = ({
}) => {
const allRecordings = useChannelsStore((s) => s.recordings);
// Local channel cache to avoid the global channels map
- const [channelsById, setChannelsById] = React.useState({});
+ const [channelsById, setChannelsById] = useState({});
const { toUserTime, userNow } = useTimeHelpers();
- const [childOpen, setChildOpen] = React.useState(false);
- const [childRec, setChildRec] = React.useState(null);
+ const [childOpen, setChildOpen] = useState(false);
+ const [childRec, setChildRec] = useState(null);
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
- const [editing, setEditing] = React.useState(false);
+ const [editing, setEditing] = useState(false);
// Prefer the store version of the recording for live updates
// (e.g., after artwork refresh or metadata edit via WebSocket).
// Preserve _group_count from the categorized prop — the store version
// doesn't carry this client-side field, so without merging it back
// isSeriesGroup would always be false and the episode list hidden.
- const safeRecording = React.useMemo(() => {
+ const safeRecording = useMemo(() => {
if (recording?.id && Array.isArray(allRecordings)) {
const found = allRecordings.find((r) => r.id === recording.id);
if (found) {
@@ -81,7 +86,7 @@ const RecordingDetailsModal = ({
const program = customProps.program || {};
// Derive poster URL from live store data instead of the stale prop snapshot.
- const livePosterUrl = React.useMemo(
+ const livePosterUrl = useMemo(
() =>
getPosterUrl(
customProps.poster_logo_id,
@@ -93,17 +98,17 @@ const RecordingDetailsModal = ({
// Optimistic overrides — show saved values immediately without waiting
// for the WebSocket round-trip to refresh the store.
- const [savedTitle, setSavedTitle] = React.useState(null);
- const [savedDescription, setSavedDescription] = React.useState(null);
+ const [savedTitle, setSavedTitle] = useState(null);
+ const [savedDescription, setSavedDescription] = useState(null);
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
const description =
savedDescription ?? (program.description || customProps.description || '');
- const [editTitle, setEditTitle] = React.useState('');
- const [editDescription, setEditDescription] = React.useState('');
+ const [editTitle, setEditTitle] = useState('');
+ const [editDescription, setEditDescription] = useState('');
// Reset optimistic state when the recording changes
- React.useEffect(() => {
+ useEffect(() => {
setSavedTitle(null);
setSavedDescription(null);
setEditing(false);
@@ -129,7 +134,7 @@ const RecordingDetailsModal = ({
const isSeriesGroup = Boolean(
safeRecording._group_count && safeRecording._group_count > 1
);
- const upcomingEpisodes = React.useMemo(() => {
+ const upcomingEpisodes = useMemo(() => {
return getUpcomingEpisodes(
isSeriesGroup,
allRecordings,
@@ -147,7 +152,7 @@ const RecordingDetailsModal = ({
]);
// Ensure channel is available for a given id
- const loadChannel = React.useCallback(
+ const loadChannel = useCallback(
async (id) => {
if (!id) {
return null;
@@ -159,7 +164,7 @@ const RecordingDetailsModal = ({
}
try {
- const ch = await API.getChannel(id);
+ const ch = await getChannel(id);
if (ch && ch.id === id) {
setChannelsById((prev) => ({ ...prev, [id]: ch }));
return ch;
@@ -177,7 +182,7 @@ const RecordingDetailsModal = ({
);
// When opening a child episode, fetch that episode's channel
- React.useEffect(() => {
+ useEffect(() => {
if (!childOpen || !childRec) return;
loadChannel(childRec.channel);
}, [childOpen, childRec, loadChannel]);
@@ -188,7 +193,7 @@ const RecordingDetailsModal = ({
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
+ if (isAfter(now, s) && isBefore(now, e)) {
const ch =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
@@ -228,14 +233,11 @@ const RecordingDetailsModal = ({
const saveMetadata = async () => {
try {
- await API.updateRecordingMetadata(recording.id, {
- title: editTitle || 'Custom Recording',
- description: editDescription,
- });
+ await updateRecordingMetadata(recording, editTitle, editDescription);
setSavedTitle(editTitle || 'Custom Recording');
setSavedDescription(editDescription);
setEditing(false);
- notifications.show({
+ showNotification({
title: 'Saved',
message: 'Recording metadata updated',
color: 'green',
@@ -249,8 +251,8 @@ const RecordingDetailsModal = ({
const handleRefreshArtwork = async (e) => {
e.stopPropagation?.();
try {
- await API.refreshArtwork(recording.id);
- notifications.show({
+ await refreshArtwork(recording.id);
+ showNotification({
title: 'Refreshing artwork',
message: 'Poster resolution started',
color: 'blue.5',
@@ -265,7 +267,7 @@ const RecordingDetailsModal = ({
e.stopPropagation?.();
try {
await runComSkip(recording);
- notifications.show({
+ showNotification({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
color: 'blue.5',
@@ -344,8 +346,8 @@ const RecordingDetailsModal = ({
)}
- {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
- {end.format(timeformat)}
+ {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
+ {format(end, timeformat)}
@@ -468,7 +470,7 @@ const RecordingDetailsModal = ({
{onWatchLive && }
{onWatchRecording && }
- {onEdit && start.isAfter(userNow()) && }
+ {onEdit && isAfter(start, userNow()) && }
{(customProps.status === 'completed' ||
customProps.status === 'stopped' ||
customProps.status === 'interrupted') &&
@@ -486,8 +488,8 @@ const RecordingDetailsModal = ({
- {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
- {end.format(timeformat)}
+ {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
+ {format(end, timeformat)}
{rating && (
@@ -599,7 +601,7 @@ const RecordingDetailsModal = ({
title: { color: 'white' },
}}
>
- {isSeriesGroup ? Series() : Movie()}
+ {isSeriesGroup ? : }
);
};
diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx
index 9fe49220..d9007d34 100644
--- a/frontend/src/components/forms/RecurringRuleModal.jsx
+++ b/frontend/src/components/forms/RecurringRuleModal.jsx
@@ -1,16 +1,15 @@
import useChannelsStore from '../../store/channels.jsx';
-import API from '../../api.js';
import {
- parseDate,
+ format,
+ getNow,
RECURRING_DAY_OPTIONS,
+ toDate,
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,
@@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import {
deleteRecurringRuleById,
- getChannelOptions,
+ getFormDefaults,
getUpcomingOccurrences,
updateRecurringRule,
updateRecurringRuleEnabled,
} from '../../utils/forms/RecurringRuleModalUtils.js';
+import { showNotification } from '../../utils/notificationUtils.js';
+import {
+ getChannelsSummary,
+ getRecurringFormDefaults,
+ recurringFormValidators,
+ sortedChannelOptions,
+} from '../../utils/forms/RecordingUtils.js';
const RecurringRuleModal = ({
opened,
@@ -56,66 +62,18 @@ const RecurringRuleModal = ({
const rule = recurringRules.find((r) => r.id === ruleId);
const channelOptions = useMemo(() => {
- return getChannelOptions(allChannels);
+ return sortedChannelOptions(allChannels);
}, [allChannels]);
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;
- },
- },
+ initialValues: { ...getRecurringFormDefaults(), enabled: true },
+ validate: recurringFormValidators,
});
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),
- });
+ form.setValues(getFormDefaults(rule));
} else {
form.reset();
}
@@ -127,7 +85,7 @@ const RecurringRuleModal = ({
let cancelled = false;
(async () => {
try {
- const chans = await API.getChannelsSummary();
+ const chans = await getChannelsSummary();
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
console.warn('Failed to load channels for recurring rule modal', e);
@@ -149,7 +107,7 @@ const RecurringRuleModal = ({
try {
await updateRecurringRule(ruleId, values);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
- notifications.show({
+ showNotification({
title: 'Recurring rule updated',
message: 'Schedule adjustments saved',
color: 'green',
@@ -169,7 +127,7 @@ const RecurringRuleModal = ({
try {
await deleteRecurringRuleById(ruleId);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
- notifications.show({
+ showNotification({
title: 'Recurring rule removed',
message: 'All future occurrences were cancelled',
color: 'red',
@@ -189,7 +147,7 @@ const RecurringRuleModal = ({
try {
await updateRecurringRuleEnabled(ruleId, checked);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
- notifications.show({
+ showNotification({
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
message: checked
? 'Future occurrences will resume'
@@ -210,7 +168,7 @@ const RecurringRuleModal = ({
try {
await deleteRecordingById(occurrence.id);
// recording_cancelled WS event handles recording list update
- notifications.show({
+ showNotification({
title: 'Occurrence cancelled',
message: 'The selected airing was removed',
color: 'yellow',
@@ -246,7 +204,7 @@ const RecurringRuleModal = ({
setDeleting(true);
try {
await deleteRecordingById(sourceRecording.id);
- notifications.show({
+ showNotification({
title: 'Recording deleted',
color: 'green',
autoClose: 2500,
@@ -275,7 +233,7 @@ const RecurringRuleModal = ({
};
const handleStartDateChange = (value) => {
- form.setFieldValue('start_date', value || dayjs().toDate());
+ form.setFieldValue('start_date', value || toDate(getNow()));
};
const handleEndDateChange = (value) => {
@@ -302,10 +260,11 @@ const RecurringRuleModal = ({
- {occStart.format(`${dateformat}, YYYY`)}
+ {format(occStart, `${dateformat}, YYYY`)}
- {occStart.format(timeformat)} – {occEnd.format(timeformat)}
+ {format(occStart, timeformat)} –{' '}
+ {format(occEnd, timeformat)}
diff --git a/frontend/src/components/forms/ScheduleInput.jsx b/frontend/src/components/forms/ScheduleInput.jsx
index eb4aa70d..a0cbb854 100644
--- a/frontend/src/components/forms/ScheduleInput.jsx
+++ b/frontend/src/components/forms/ScheduleInput.jsx
@@ -38,8 +38,8 @@ import {
Popover,
ActionIcon,
Group,
- Divider,
- SimpleGrid,
+ PopoverTarget,
+ PopoverDropdown,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { validateCronExpression } from '../../utils/cronUtils';
@@ -118,12 +118,12 @@ export default function ScheduleInput({
Cron Expression
-
+
-
-
+
+
COMMON EXAMPLES
@@ -169,7 +169,7 @@ export default function ScheduleInput({
30 14 1 * *
-
+
}
diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx
index 67aaced5..6ec6cea4 100644
--- a/frontend/src/components/forms/SeriesRecordingModal.jsx
+++ b/frontend/src/components/forms/SeriesRecordingModal.jsx
@@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = {
regex: 'Title regex',
};
+const renderRuleSummary = (r) => {
+ const titleMode = (r.title_mode || 'exact').toLowerCase();
+ const parts = [];
+ parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
+ if (r.title) {
+ parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
+ }
+ if (r.description) {
+ parts.push(`Description: "${r.description}"`);
+ }
+ return parts.join(' | ');
+};
+
export default function SeriesRecordingModal({
opened,
onClose,
@@ -59,19 +72,6 @@ export default function SeriesRecordingModal({
onRulesUpdate(updated);
};
- const renderRuleSummary = (r) => {
- const titleMode = (r.title_mode || 'exact').toLowerCase();
- const parts = [];
- parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
- if (r.title) {
- parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
- }
- if (r.description) {
- parts.push(`Description: "${r.description}"`);
- }
- return parts.join(' | ');
- };
-
return (
<>
${e.toLocaleString()}`;
- } catch {
- return `${start} - ${end}`;
- }
-}
+import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js';
+import {
+ createSeriesRule,
+ evaluateSeriesRulesByTvgId,
+} from '../../utils/guideUtils.js';
+import {
+ DESCRIPTION_MODES,
+ EPISODE_MODES,
+ formatRange,
+ getChannelOptions,
+ getTvgOptions,
+ previewSeriesRule,
+ TITLE_MODES,
+} from '../../utils/forms/SeriesRuleEditorModalUtils.js';
export default function SeriesRuleEditorModal({
opened,
@@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({
useEffect(() => {
if (!opened) return;
let cancelled = false;
- API.getChannelsSummary()
+ getChannelsSummary()
.then((chans) => {
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
})
@@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({
setPreviewLoading(true);
setPreviewError(null);
- API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal })
+ previewSeriesRule(debouncedPreviewKey, controller)
.then((resp) => {
if (controller.signal.aborted) return;
setPreview(resp || { matches: [], total: 0 });
@@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({
// EPG channel options for the tvg_id selector. Deduplicate by tvg_id value
// since the same channel can appear across multiple EPG sources.
const tvgOptions = useMemo(() => {
- const seen = new Set();
- const options = [];
- for (const t of tvgs || []) {
- if (!t.tvg_id || seen.has(t.tvg_id)) continue;
- seen.add(t.tvg_id);
- options.push({
- value: t.tvg_id,
- label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
- });
- }
- return options.sort((a, b) => a.label.localeCompare(b.label));
+ return getTvgOptions(tvgs);
}, [tvgs]);
- // Channel select options: prefer channels matching the selected tvg_id.
const channelOptions = useMemo(() => {
- const sorted = [...allChannels].sort((a, b) => {
- const aNum = Number(a.channel_number) || 0;
- const bNum = Number(b.channel_number) || 0;
- if (aNum !== bNum) return aNum - bNum;
- return (a.name || '').localeCompare(b.name || '');
- });
- const matching = [];
- const others = [];
- for (const c of sorted) {
- const item = {
- value: String(c.id),
- label: c.channel_number
- ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}`
- : c.name || `Channel ${c.id}`,
- };
- const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null;
- if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
- matching.push(item);
- } else {
- others.push(item);
- }
- }
- return [...matching, ...others];
+ return getChannelOptions(allChannels, tvgsById, tvgId);
}, [allChannels, tvgsById, tvgId]);
const canSave = !!(payload.title || payload.description);
@@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({
const handleSave = async () => {
setSaving(true);
try {
- await API.createSeriesRule(payload);
+ await createSeriesRule(payload);
// Trigger evaluation so matching upcoming programs get scheduled.
try {
- await API.evaluateSeriesRules(payload.tvg_id);
+ await evaluateSeriesRulesByTvgId(payload.tvg_id);
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to evaluate after save', e);
@@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({
showNotification({ title: 'Series rule saved' });
if (onSaved) await onSaved();
onClose();
+ } catch (e) {
+ console.error('Failed to save series rule', e);
} finally {
setSaving(false);
}
@@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({
)}
-
+
{(preview.matches || []).map((p) => (
@@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({
)}
-
+
-
+
-
+
{
)}
-
+
diff --git a/frontend/src/components/forms/UserAgent.jsx b/frontend/src/components/forms/UserAgent.jsx
index 97544b57..b6c237b1 100644
--- a/frontend/src/components/forms/UserAgent.jsx
+++ b/frontend/src/components/forms/UserAgent.jsx
@@ -1,26 +1,8 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
-import { yupResolver } from '@hookform/resolvers/yup';
-import * as Yup from 'yup';
-import API from '../../api';
-import {
- LoadingOverlay,
- TextInput,
- Button,
- Checkbox,
- Modal,
- Flex,
- NativeSelect,
- FileInput,
- Space,
-} from '@mantine/core';
-import { NETWORK_ACCESS_OPTIONS } from '../../constants';
-
-const schema = Yup.object({
- name: Yup.string().required('Name is required'),
- user_agent: Yup.string().required('User-Agent is required'),
-});
+import { Button, Checkbox, Flex, Modal, Space, TextInput, } from '@mantine/core';
+import { addUserAgent, getResolver, updateUserAgent } from '../../utils/forms/UserAgentUtils.js';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(
@@ -42,14 +24,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
- resolver: yupResolver(schema),
+ resolver: getResolver(),
});
const onSubmit = async (values) => {
if (userAgent?.id) {
- await API.updateUserAgent({ id: userAgent.id, ...values });
+ await updateUserAgent(userAgent.id, values);
} else {
- await API.addUserAgent(values);
+ await addUserAgent(values);
}
reset();