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.title - - - - {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', - }, - }); - }} - /> - )} - - ) : ( - - {recordingName} - - - - {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} - /> - -
- -