From d97f0c907f78aac4b0fc9c84444dab42fb21caae Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Sat, 13 Dec 2025 06:33:28 -0800
Subject: [PATCH] Updated DVR for extracted logic
---
frontend/src/pages/DVR.jsx | 1551 ++----------------------------------
1 file changed, 74 insertions(+), 1477 deletions(-)
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
index 4ed6aca6..f300899d 100644
--- a/frontend/src/pages/DVR.jsx
+++ b/frontend/src/pages/DVR.jsx
@@ -5,7 +5,6 @@ import {
Button,
Card,
Center,
- Container,
Flex,
Badge,
Group,
@@ -23,15 +22,9 @@ import {
useMantineTheme,
} from '@mantine/core';
import {
- Gauge,
- HardDriveDownload,
- HardDriveUpload,
AlertTriangle,
SquarePlus,
SquareX,
- Timer,
- Users,
- Video,
} from 'lucide-react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
@@ -47,1335 +40,17 @@ import { notifications } from '@mantine/notifications';
import API from '../api';
import { DatePickerInput, TimeInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
-
-dayjs.extend(duration);
-dayjs.extend(relativeTime);
-dayjs.extend(utc);
-dayjs.extend(timezone);
-
-const useUserTimeZone = () => {
- const settings = useSettingsStore((s) => s.settings);
- const [timeZone, setTimeZone] = useLocalStorage(
- 'time-zone',
- dayjs.tz?.guess
- ? dayjs.tz.guess()
- : Intl.DateTimeFormat().resolvedOptions().timeZone
- );
-
- useEffect(() => {
- const tz = settings?.['system-time-zone']?.value;
- if (tz && tz !== timeZone) {
- setTimeZone(tz);
- }
- }, [settings, timeZone, setTimeZone]);
-
- return timeZone;
-};
-
-const useTimeHelpers = () => {
- const timeZone = useUserTimeZone();
-
- const toUserTime = useCallback(
- (value) => {
- if (!value) return dayjs.invalid();
- try {
- return dayjs(value).tz(timeZone);
- } catch (error) {
- return dayjs(value);
- }
- },
- [timeZone]
- );
-
- const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]);
-
- return { timeZone, toUserTime, userNow };
-};
-
-const RECURRING_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 useDateTimeFormat = () => {
- const [timeFormatSetting] = useLocalStorage('time-format', '12h');
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- // Use user preference for time format
- const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
- const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
-
- return [timeFormat, dateFormat]
-};
-
-// Short preview that triggers the details modal when clicked
-const RecordingSynopsis = ({ description, onOpen }) => {
- const truncated = description?.length > 140;
- const preview = truncated
- ? `${description.slice(0, 140).trim()}...`
- : description;
- if (!description) return null;
- return (
- onOpen?.()}
- style={{ cursor: 'pointer' }}
- >
- {preview}
-
- );
-};
-
-const RecordingDetailsModal = ({
- opened,
- onClose,
- recording,
- channel,
- posterUrl,
- onWatchLive,
- onWatchRecording,
- env_mode,
- onEdit,
-}) => {
- const allRecordings = useChannelsStore((s) => s.recordings);
- const channelMap = useChannelsStore((s) => s.channels);
- const { toUserTime, userNow } = useTimeHelpers();
- const [childOpen, setChildOpen] = React.useState(false);
- const [childRec, setChildRec] = React.useState(null);
- const [timeformat, dateformat] = useDateTimeFormat();
-
- const safeRecording = recording || {};
- const customProps = safeRecording.custom_properties || {};
- const program = customProps.program || {};
- const recordingName = program.title || 'Custom Recording';
- const description = program.description || customProps.description || '';
- const start = toUserTime(safeRecording.start_time);
- const end = toUserTime(safeRecording.end_time);
- const stats = customProps.stream_info || {};
-
- const statRows = [
- ['Video Codec', stats.video_codec],
- [
- 'Resolution',
- stats.resolution ||
- (stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
- ],
- ['FPS', stats.source_fps],
- ['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
- ['Audio Codec', stats.audio_codec],
- ['Audio Channels', stats.audio_channels],
- ['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
- ['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
- ].filter(([, v]) => v !== null && v !== undefined && v !== '');
-
- // Rating (if available)
- const rating =
- customProps.rating ||
- customProps.rating_value ||
- (program && program.custom_properties && program.custom_properties.rating);
- const ratingSystem = customProps.rating_system || 'MPAA';
-
- const fileUrl = customProps.file_url || customProps.output_file_url;
- const canWatchRecording =
- (customProps.status === 'completed' ||
- customProps.status === 'interrupted') &&
- Boolean(fileUrl);
-
- // Prefix in dev (Vite) if needed
- let resolvedPosterUrl = posterUrl;
- if (
- typeof import.meta !== 'undefined' &&
- import.meta.env &&
- import.meta.env.DEV
- ) {
- if (resolvedPosterUrl && resolvedPosterUrl.startsWith('/')) {
- resolvedPosterUrl = `${window.location.protocol}//${window.location.hostname}:5656${resolvedPosterUrl}`;
- }
- }
-
- const isSeriesGroup = Boolean(
- safeRecording._group_count && safeRecording._group_count > 1
- );
- const upcomingEpisodes = React.useMemo(() => {
- if (!isSeriesGroup) return [];
- const arr = Array.isArray(allRecordings)
- ? allRecordings
- : Object.values(allRecordings || {});
- const tvid = program.tvg_id || '';
- const titleKey = (program.title || '').toLowerCase();
- const filtered = arr.filter((r) => {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- if ((pr.tvg_id || '') !== tvid) return false;
- if ((pr.title || '').toLowerCase() !== titleKey) return false;
- const st = toUserTime(r.start_time);
- return st.isAfter(userNow());
- });
- // Deduplicate by program.id if present, else by time+title
- const seen = new Set();
- const deduped = [];
- for (const r of filtered) {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- // Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
- const season = cp.season ?? pr?.custom_properties?.season;
- const episode = cp.episode ?? pr?.custom_properties?.episode;
- const onscreen =
- cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- let key = null;
- if (season != null && episode != null) key = `se:${season}:${episode}`;
- else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
- else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
- else if (pr.id != null) key = `id:${pr.id}`;
- else
- key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
- if (seen.has(key)) continue;
- seen.add(key);
- deduped.push(r);
- }
- return deduped.sort(
- (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
- );
- }, [
- allRecordings,
- isSeriesGroup,
- program.tvg_id,
- program.title,
- toUserTime,
- userNow,
- ]);
-
- if (!recording) return null;
-
- const EpisodeRow = ({ rec }) => {
- const cp = rec.custom_properties || {};
- const pr = cp.program || {};
- const start = toUserTime(rec.start_time);
- const end = toUserTime(rec.end_time);
- const season = cp.season ?? pr?.custom_properties?.season;
- const episode = cp.episode ?? pr?.custom_properties?.episode;
- const onscreen =
- cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- const se =
- season && episode
- ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
- : onscreen || null;
- const posterLogoId = cp.poster_logo_id;
- let purl = posterLogoId
- ? `/api/channels/logos/${posterLogoId}/cache/`
- : cp.poster_url || posterUrl || '/logo.png';
- if (
- typeof import.meta !== 'undefined' &&
- import.meta.env &&
- import.meta.env.DEV &&
- purl &&
- purl.startsWith('/')
- ) {
- purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
- }
- const onRemove = async (e) => {
- e?.stopPropagation?.();
- try {
- await API.deleteRecording(rec.id);
- } catch (error) {
- console.error('Failed to delete upcoming recording', error);
- }
- try {
- await useChannelsStore.getState().fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings after delete', error);
- }
- };
- return (
- {
- setChildRec(rec);
- setChildOpen(true);
- }}
- >
-
-
-
-
-
- {pr.sub_title || pr.title}
-
- {se && (
-
- {se}
-
- )}
-
-
- {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
-
-
-
-
-
-
-
- );
- };
-
- return (
-
- {isSeriesGroup ? (
-
- {upcomingEpisodes.length === 0 && (
-
- No upcoming episodes found
-
- )}
- {upcomingEpisodes.map((ep) => (
-
- ))}
- {childOpen && childRec && (
- setChildOpen(false)}
- recording={childRec}
- channel={channelMap[childRec.channel]}
- posterUrl={
- (childRec.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
- : childRec.custom_properties?.poster_url ||
- channelMap[childRec.channel]?.logo?.cache_url) ||
- '/logo.png'
- }
- env_mode={env_mode}
- onWatchLive={() => {
- const rec = childRec;
- const now = userNow();
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
- const ch = channelMap[rec.channel];
- if (!ch) return;
- let url = `/proxy/ts/stream/${ch.uuid}`;
- if (env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- useVideoStore.getState().showVideo(url, 'live');
- }
- }}
- onWatchRecording={() => {
- let fileUrl =
- childRec.custom_properties?.file_url ||
- childRec.custom_properties?.output_file_url;
- if (!fileUrl) return;
- if (env_mode === 'dev' && fileUrl.startsWith('/')) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- useVideoStore.getState().showVideo(fileUrl, 'vod', {
- name:
- childRec.custom_properties?.program?.title || 'Recording',
- logo: {
- url:
- (childRec.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
- : channelMap[childRec.channel]?.logo?.cache_url) ||
- '/logo.png',
- },
- });
- }}
- />
- )}
-
- ) : (
-
-
-
-
-
- {channel ? `${channel.channel_number} • ${channel.name}` : '—'}
-
-
- {onWatchLive && (
-
- )}
- {onWatchRecording && (
-
- )}
- {onEdit && start.isAfter(userNow()) && (
-
- )}
- {customProps.status === 'completed' &&
- (!customProps?.comskip ||
- customProps?.comskip?.status !== 'completed') && (
-
- )}
-
-
-
- {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
-
- {rating && (
-
-
- {rating}
-
-
- )}
- {description && (
-
- {description}
-
- )}
- {statRows.length > 0 && (
-
-
- Stream Stats
-
- {statRows.map(([k, v]) => (
-
-
- {k}
-
- {v}
-
- ))}
-
- )}
-
-
- )}
-
- );
-};
-
-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');
- return value;
- }
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
-};
-
-const parseDate = (value) => {
- if (!value) return null;
- const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
- return parsed.isValid() ? parsed.toDate() : null;
-};
-
-const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
- const channels = useChannelsStore((s) => s.channels);
- const recurringRules = useChannelsStore((s) => s.recurringRules);
- const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
- const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
- const recordings = useChannelsStore((s) => s.recordings);
- const { toUserTime, userNow } = useTimeHelpers();
- const [timeformat, 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(() => {
- const list = Object.values(channels || {});
- 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.name || `Channel ${item.id}`,
- }));
- }, [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(() => {
- const list = Array.isArray(recordings)
- ? recordings
- : Object.values(recordings || {});
- const now = userNow();
- return list
- .filter(
- (rec) =>
- rec?.custom_properties?.rule?.id === ruleId &&
- toUserTime(rec.start_time).isAfter(now)
- )
- .sort(
- (a, b) =>
- toUserTime(a.start_time).valueOf() -
- toUserTime(b.start_time).valueOf()
- );
- }, [recordings, ruleId, toUserTime, userNow]);
-
- const handleSave = async (values) => {
- if (!rule) return;
- setSaving(true);
- try {
- await API.updateRecurringRule(ruleId, {
- 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: values.start_date
- ? dayjs(values.start_date).format('YYYY-MM-DD')
- : null,
- end_date: values.end_date
- ? dayjs(values.end_date).format('YYYY-MM-DD')
- : null,
- name: values.rule_name?.trim() || '',
- enabled: Boolean(values.enabled),
- });
- await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- 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 API.deleteRecurringRule(ruleId);
- await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- 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 API.updateRecurringRule(ruleId, { enabled: checked });
- await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- 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 API.deleteRecording(occurrence.id);
- await fetchRecordings();
- 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 (
-
- Recurring rule not found.
-
- );
- }
-
- return (
-
-
-
-
- {channels?.[rule.channel]?.name || `Channel ${rule.channel}`}
-
- {
- form.setFieldValue('enabled', event.currentTarget.checked);
- handleToggleEnabled(event.currentTarget.checked);
- }}
- label={form.values.enabled ? 'Enabled' : 'Paused'}
- disabled={saving}
- />
-
-
-
-
-
- Upcoming occurrences
-
- {upcomingOccurrences.length}
-
- {upcomingOccurrences.length === 0 ? (
-
- No future airings currently scheduled.
-
- ) : (
-
- {upcomingOccurrences.map((occ) => {
- const occStart = toUserTime(occ.start_time);
- const occEnd = toUserTime(occ.end_time);
- return (
-
-
-
-
- {occStart.format(`${dateformat}, YYYY`)}
-
-
- {occStart.format(timeformat)} – {occEnd.format(timeformat)}
-
-
-
-
-
-
-
-
- );
- })}
-
- )}
-
-
-
- );
-};
-
-const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
- const channels = useChannelsStore((s) => s.channels);
- const env_mode = useSettingsStore((s) => s.environment.env_mode);
- const showVideo = useVideoStore((s) => s.showVideo);
- const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
- const { toUserTime, userNow } = useTimeHelpers();
- const [timeformat, dateformat] = useDateTimeFormat();
-
- const channel = channels?.[recording.channel];
-
- const deleteRecording = (id) => {
- // Optimistically remove immediately from UI
- try {
- useChannelsStore.getState().removeRecording(id);
- } catch (error) {
- console.error('Failed to optimistically remove recording', error);
- }
- // Fire-and-forget server delete; websocket will keep others in sync
- API.deleteRecording(id).catch(() => {
- // On failure, fallback to refetch to restore state
- try {
- useChannelsStore.getState().fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings after delete', error);
- }
- });
- };
-
- const customProps = recording.custom_properties || {};
- const program = customProps.program || {};
- const recordingName = program.title || 'Custom Recording';
- const subTitle = program.sub_title || '';
- const description = program.description || customProps.description || '';
- const isRecurringRule = customProps?.rule?.type === 'recurring';
-
- // Poster or channel logo
- const posterLogoId = customProps.poster_logo_id;
- let posterUrl = posterLogoId
- ? `/api/channels/logos/${posterLogoId}/cache/`
- : customProps.poster_url || channel?.logo?.cache_url || '/logo.png';
- // Prefix API host in dev if using a relative path
- if (env_mode === 'dev' && posterUrl && posterUrl.startsWith('/')) {
- posterUrl = `${window.location.protocol}//${window.location.hostname}:5656${posterUrl}`;
- }
-
- const start = toUserTime(recording.start_time);
- const end = toUserTime(recording.end_time);
- const now = userNow();
- const status = customProps.status;
- const isTimeActive = now.isAfter(start) && now.isBefore(end);
- const isInterrupted = status === 'interrupted';
- const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
- const isUpcoming = now.isBefore(start);
- const isSeriesGroup = Boolean(
- recording._group_count && recording._group_count > 1
- );
- // Season/Episode display if present
- const season = customProps.season ?? program?.custom_properties?.season;
- const episode = customProps.episode ?? program?.custom_properties?.episode;
- const onscreen =
- customProps.onscreen_episode ??
- program?.custom_properties?.onscreen_episode;
- const seLabel =
- season && episode
- ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
- : onscreen || null;
-
- const handleWatchLive = () => {
- if (!channel) return;
- let url = `/proxy/ts/stream/${channel.uuid}`;
- if (env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- showVideo(url, 'live');
- };
-
- const handleWatchRecording = () => {
- // Only enable if backend provides a playable file URL in custom properties
- let fileUrl = customProps.file_url || customProps.output_file_url;
- if (!fileUrl) return;
- if (env_mode === 'dev' && fileUrl.startsWith('/')) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- showVideo(fileUrl, 'vod', {
- name: recordingName,
- logo: { url: posterUrl },
- });
- };
-
- const handleRunComskip = async (e) => {
- e?.stopPropagation?.();
- try {
- await API.runComskip(recording.id);
- notifications.show({
- title: 'Removing commercials',
- message: 'Queued comskip for this recording',
- color: 'blue.5',
- autoClose: 2000,
- });
- } catch (error) {
- console.error('Failed to queue comskip for recording', error);
- }
- };
-
- // Cancel handling for series groups
- const [cancelOpen, setCancelOpen] = React.useState(false);
- const [busy, setBusy] = React.useState(false);
- const handleCancelClick = (e) => {
- e.stopPropagation();
- if (isRecurringRule) {
- onOpenRecurring?.(recording, true);
- return;
- }
- if (isSeriesGroup) {
- setCancelOpen(true);
- } else {
- deleteRecording(recording.id);
- }
- };
-
- const seriesInfo = (() => {
- const cp = customProps || {};
- const pr = cp.program || {};
- return { tvg_id: pr.tvg_id, title: pr.title };
- })();
-
- const removeUpcomingOnly = async () => {
- try {
- setBusy(true);
- await API.deleteRecording(recording.id);
- } finally {
- setBusy(false);
- setCancelOpen(false);
- try {
- await fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings', error);
- }
- }
- };
-
- const removeSeriesAndRule = async () => {
- try {
- setBusy(true);
- const { tvg_id, title } = seriesInfo;
- if (tvg_id) {
- try {
- await API.bulkRemoveSeriesRecordings({
- tvg_id,
- title,
- scope: 'title',
- });
- } catch (error) {
- console.error('Failed to remove series recordings', error);
- }
- try {
- await API.deleteSeriesRule(tvg_id);
- } catch (error) {
- console.error('Failed to delete series rule', error);
- }
- }
- } finally {
- setBusy(false);
- setCancelOpen(false);
- try {
- await fetchRecordings();
- } catch (error) {
- console.error(
- 'Failed to refresh recordings after series removal',
- error
- );
- }
- }
- };
-
- const MainCard = (
- {
- if (isRecurringRule) {
- onOpenRecurring?.(recording, false);
- } else {
- onOpenDetails?.(recording);
- }
- }}
- >
-
-
-
- {isInterrupted
- ? 'Interrupted'
- : isInProgress
- ? 'Recording'
- : isUpcoming
- ? 'Scheduled'
- : 'Completed'}
-
- {isInterrupted && }
-
-
-
- {recordingName}
-
- {isSeriesGroup && (
-
- Series
-
- )}
- {isRecurringRule && (
-
- Recurring
-
- )}
- {seLabel && !isSeriesGroup && (
-
- {seLabel}
-
- )}
-
-
-
-
-
-
- e.stopPropagation()}
- onClick={handleCancelClick}
- >
-
-
-
-
-
-
-
-
-
- {!isSeriesGroup && subTitle && (
-
-
- Episode
-
-
- {subTitle}
-
-
- )}
-
-
- Channel
-
-
- {channel ? `${channel.channel_number} • ${channel.name}` : '—'}
-
-
-
-
-
- {isSeriesGroup ? 'Next recording' : 'Time'}
-
-
- {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
-
-
-
- {!isSeriesGroup && description && (
- onOpenDetails?.(recording)}
- />
- )}
-
- {isInterrupted && customProps.interrupted_reason && (
-
- {customProps.interrupted_reason}
-
- )}
-
-
- {isInProgress && (
-
- )}
-
- {!isUpcoming && (
-
-
-
- )}
- {!isUpcoming &&
- customProps?.status === 'completed' &&
- (!customProps?.comskip ||
- customProps?.comskip?.status !== 'completed') && (
-
- )}
-
-
-
- {/* If this card is a grouped upcoming series, show count */}
- {recording._group_count > 1 && (
-
- Next of {recording._group_count}
-
- )}
-
- );
- if (!isSeriesGroup) return MainCard;
-
- // Stacked look for series groups: render two shadow layers behind the main card
- return (
-
- setCancelOpen(false)}
- title="Cancel Series"
- centered
- size="md"
- zIndex={9999}
- >
-
- This is a series rule. What would you like to cancel?
-
-
-
-
-
-
-
-
- {MainCard}
-
- );
-};
+import {
+ parseDate,
+ RECURRING_DAY_OPTIONS,
+ toTimeString,
+ useDateTimeFormat,
+ useTimeHelpers,
+} from '../utils/dateTimeUtils.js';
+import { RecordingDetailsModal } from '../components/forms/RecordingDetailsModal.jsx';
+import { RecurringRuleModal } from '../components/forms/RecurringRuleModal.jsx';
+import { RecordingCard } from '../components/cards/RecordingCard.jsx';
+import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
const DVRPage = () => {
const theme = useMantineTheme();
@@ -1441,86 +116,67 @@ const DVRPage = () => {
// Categorize recordings
const { inProgress, upcoming, completed } = useMemo(() => {
- const inProgress = [];
- const upcoming = [];
- const completed = [];
- const list = Array.isArray(recordings)
- ? recordings
- : Object.values(recordings || {});
-
- // ID-based dedupe guard in case store returns duplicates
- const seenIds = new Set();
- for (const rec of list) {
- if (rec && rec.id != null) {
- const k = String(rec.id);
- if (seenIds.has(k)) continue;
- seenIds.add(k);
- }
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- const status = rec.custom_properties?.status;
- if (status === 'interrupted' || status === 'completed') {
- completed.push(rec);
- } else {
- if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
- else if (now.isBefore(s)) upcoming.push(rec);
- else completed.push(rec);
- }
- }
-
- // Deduplicate in-progress and upcoming by program id or channel+slot
- const dedupeByProgramOrSlot = (arr) => {
- const out = [];
- const sigs = new Set();
- for (const r of arr) {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- const sig =
- pr?.id != null
- ? `id:${pr.id}`
- : `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
- if (sigs.has(sig)) continue;
- sigs.add(sig);
- out.push(r);
- }
- return out;
- };
-
- const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort(
- (a, b) => toUserTime(b.start_time) - toUserTime(a.start_time)
- );
-
- // Group upcoming by series title+tvg_id (keep only next episode)
- const grouped = new Map();
- const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort(
- (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
- );
- for (const rec of upcomingDedup) {
- const cp = rec.custom_properties || {};
- const prog = cp.program || {};
- const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
- if (!grouped.has(key)) {
- grouped.set(key, { rec, count: 1 });
- } else {
- const entry = grouped.get(key);
- entry.count += 1;
- }
- }
- const upcomingGrouped = Array.from(grouped.values()).map((e) => {
- const item = { ...e.rec };
- item._group_count = e.count;
- return item;
- });
- completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time));
- return {
- inProgress: inProgressDedup,
- upcoming: upcomingGrouped,
- completed,
- };
+ return categorizeRecordings(recordings, toUserTime, now);
}, [recordings, now, toUserTime]);
+ const RecordingList = (list) => {
+ return list.map((rec) => (
+
+ ));
+ }
+
+ const getOnWatchLive = () => {
+ return () => {
+ const rec = detailsRecording;
+ const now = userNow();
+ const s = toUserTime(rec.start_time);
+ const e = toUserTime(rec.end_time);
+ if (now.isAfter(s) && now.isBefore(e)) {
+ // call into child RecordingCard behavior by constructing a URL like there
+ const channel = channels[rec.channel];
+ if (!channel) return;
+ let url = `/proxy/ts/stream/${channel.uuid}`;
+ if (useSettingsStore.getState().environment.env_mode === 'dev') {
+ url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
+ }
+ useVideoStore.getState().showVideo(url, 'live');
+ }
+ };
+ }
+
+ const getOnWatchRecording = () => {
+ return () => {
+ let fileUrl =
+ detailsRecording.custom_properties?.file_url ||
+ detailsRecording.custom_properties?.output_file_url;
+ if (!fileUrl) return;
+ if (
+ useSettingsStore.getState().environment.env_mode === 'dev' &&
+ fileUrl.startsWith('/')
+ ) {
+ fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
+ }
+ useVideoStore.getState().showVideo(fileUrl, 'vod', {
+ name:
+ detailsRecording.custom_properties?.program?.title ||
+ 'Recording',
+ logo: {
+ url:
+ (detailsRecording.custom_properties?.poster_logo_id
+ ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/`
+ : channels[detailsRecording.channel]?.logo?.cache_url) ||
+ '/logo.png',
+ },
+ });
+ };
+ }
return (
-
+
}
variant="light"
@@ -1550,14 +206,7 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {inProgress.map((rec) => (
-
- ))}
+ {RecordingList(inProgress)}
{inProgress.length === 0 && (
Nothing recording right now.
@@ -1579,14 +228,7 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {upcoming.map((rec) => (
-
- ))}
+ {RecordingList(upcoming)}
{upcoming.length === 0 && (
No upcoming recordings.
@@ -1608,14 +250,7 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {completed.map((rec) => (
-
- ))}
+ {RecordingList(completed)}
{completed.length === 0 && (
No completed recordings yet.
@@ -1661,46 +296,8 @@ const DVRPage = () => {
'/logo.png'
}
env_mode={useSettingsStore.getState().environment.env_mode}
- onWatchLive={() => {
- const rec = detailsRecording;
- const now = userNow();
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
- // call into child RecordingCard behavior by constructing a URL like there
- const channel = channels[rec.channel];
- if (!channel) return;
- let url = `/proxy/ts/stream/${channel.uuid}`;
- if (useSettingsStore.getState().environment.env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- useVideoStore.getState().showVideo(url, 'live');
- }
- }}
- onWatchRecording={() => {
- let fileUrl =
- detailsRecording.custom_properties?.file_url ||
- detailsRecording.custom_properties?.output_file_url;
- if (!fileUrl) return;
- if (
- useSettingsStore.getState().environment.env_mode === 'dev' &&
- fileUrl.startsWith('/')
- ) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- useVideoStore.getState().showVideo(fileUrl, 'vod', {
- name:
- detailsRecording.custom_properties?.program?.title ||
- 'Recording',
- logo: {
- url:
- (detailsRecording.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/`
- : channels[detailsRecording.channel]?.logo?.cache_url) ||
- '/logo.png',
- },
- });
- }}
+ onWatchLive={getOnWatchLive()}
+ onWatchRecording={getOnWatchRecording()}
onEdit={(rec) => {
setEditRecording(rec);
closeDetails();