diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db835e6..c8c21631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - VLC log parsing for stream information: Detects video/audio codecs from TS demux output, supports both stream-copy and transcode modes with resolution/FPS extraction from transcode output - Locked, read-only VLC stream profile configured for headless operation with intelligent audio/video codec detection - VLC and required plugins installed in Docker environment with headless configuration +- ErrorBoundary component for handling frontend errors gracefully with generic error message ### Changed @@ -24,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Channel number inputs in stream-to-channel creation modals no longer have a maximum value restriction, allowing users to enter any valid channel number supported by the database - Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed) - Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink) +- Frontend component refactoring for improved code organization and maintainability - Thanks [@nick4810](https://github.com/nick4810) + - Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis) + - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils) + - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal) with loading fallbacks + - Removed unused Dashboard and Home pages +- Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements ### Fixed diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c7c3877..f22d408f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,7 +19,6 @@ import Users from './pages/Users'; import LogosPage from './pages/Logos'; import VODsPage from './pages/VODs'; import useAuthStore from './store/auth'; -import useLogosStore from './store/logos'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; import { Box, AppShell, MantineProvider } from '@mantine/core'; @@ -40,8 +39,6 @@ const defaultRoute = '/channels'; const App = () => { const [open, setOpen] = useState(true); - const [backgroundLoadingStarted, setBackgroundLoadingStarted] = - useState(false); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); const logout = useAuthStore((s) => s.logout); @@ -81,11 +78,7 @@ const App = () => { const loggedIn = await initializeAuth(); if (loggedIn) { await initData(); - // Start background logo loading after app is fully initialized (only once) - if (!backgroundLoadingStarted) { - setBackgroundLoadingStarted(true); - useLogosStore.getState().startBackgroundLoading(); - } + // Logos are now loaded at the end of initData, no need for background loading } else { await logout(); } @@ -96,7 +89,7 @@ const App = () => { }; checkAuth(); - }, [initializeAuth, initData, logout, backgroundLoadingStarted]); + }, [initializeAuth, initData, logout]); return ( Something went wrong; + } + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/frontend/src/components/LazyLogo.jsx b/frontend/src/components/LazyLogo.jsx index 2b7ae5c9..5fbdc3da 100644 --- a/frontend/src/components/LazyLogo.jsx +++ b/frontend/src/components/LazyLogo.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Skeleton } from '@mantine/core'; import useLogosStore from '../store/logos'; import logo from '../images/logo.png'; // Default logo @@ -16,15 +16,16 @@ const LazyLogo = ({ }) => { const [isLoading, setIsLoading] = useState(false); const [hasError, setHasError] = useState(false); - const fetchAttempted = useRef(new Set()); // Track which IDs we've already tried to fetch + const fetchAttempted = useRef(new Set()); const isMountedRef = useRef(true); const logos = useLogosStore((s) => s.logos); const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds); + const allowLogoRendering = useLogosStore((s) => s.allowLogoRendering); // Determine the logo source const logoData = logoId && logos[logoId]; - const logoSrc = logoData?.cache_url || fallbackSrc; // Only use cache URL if we have logo data + const logoSrc = logoData?.cache_url || fallbackSrc; // Cleanup on unmount useEffect(() => { @@ -34,6 +35,9 @@ const LazyLogo = ({ }, []); useEffect(() => { + // Don't start fetching until logo rendering is allowed + if (!allowLogoRendering) return; + // If we have a logoId but no logo data, add it to the batch request queue if ( logoId && @@ -44,7 +48,7 @@ const LazyLogo = ({ isMountedRef.current ) { setIsLoading(true); - fetchAttempted.current.add(logoId); // Mark this ID as attempted + fetchAttempted.current.add(logoId); logoRequestQueue.add(logoId); // Clear existing timer and set new one to batch requests @@ -82,7 +86,7 @@ const LazyLogo = ({ setIsLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [logoId, fetchLogosByIds, logoData]); // Include logoData to detect when it becomes available + }, [logoId, fetchLogosByIds, logoData, allowLogoRendering]); // Reset error state when logoId changes useEffect(() => { @@ -91,8 +95,10 @@ const LazyLogo = ({ } }, [logoId]); - // Show skeleton while loading - if (isLoading && !logoData) { + // Show skeleton if: + // 1. Logo rendering is not allowed yet, OR + // 2. We don't have logo data yet (regardless of loading state) + if (logoId && (!allowLogoRendering || !logoData)) { return ( { + const truncated = description?.length > 140; + const preview = truncated + ? `${description.slice(0, 140).trim()}...` + : description; + + if (!description) return null; + + return ( + onOpen?.()} + style={{ cursor: 'pointer' }} + > + {preview} + + ); +}; + +export default RecordingSynopsis; \ No newline at end of file diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx new file mode 100644 index 00000000..6f90e0f5 --- /dev/null +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -0,0 +1,422 @@ +import useChannelsStore from '../../store/channels.jsx'; +import useSettingsStore from '../../store/settings.jsx'; +import useVideoStore from '../../store/useVideoStore.jsx'; +import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js'; +import { notifications } from '@mantine/notifications'; +import React from 'react'; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Center, + Flex, + Group, + Image, + Modal, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { AlertTriangle, SquareX } from 'lucide-react'; +import RecordingSynopsis from '../RecordingSynopsis'; +import { + deleteRecordingById, + deleteSeriesAndRule, + getPosterUrl, + getRecordingUrl, + getSeasonLabel, + getSeriesInfo, + getShowVideoUrl, + removeRecording, + runComSkip, +} from './../../utils/cards/RecordingCardUtils.js'; + +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 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 posterUrl = getPosterUrl( + customProps.poster_logo_id, customProps, channel?.logo?.cache_url, env_mode); + + 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 = getSeasonLabel(season, episode, onscreen); + + const handleWatchLive = () => { + if (!channel) return; + showVideo(getShowVideoUrl(channel, env_mode), 'live'); + }; + + const handleWatchRecording = () => { + // Only enable if backend provides a playable file URL in custom properties + const fileUrl = getRecordingUrl(customProps, env_mode); + if (!fileUrl) return; + + showVideo(fileUrl, 'vod', { + name: recordingName, + logo: { url: posterUrl }, + }); + }; + + const handleRunComskip = async (e) => { + e?.stopPropagation?.(); + try { + await runComSkip(recording); + 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 { + removeRecording(recording.id); + } + }; + + const seriesInfo = getSeriesInfo(customProps); + + const removeUpcomingOnly = async () => { + try { + setBusy(true); + await deleteRecordingById(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); + await deleteSeriesAndRule(seriesInfo); + } finally { + setBusy(false); + setCancelOpen(false); + try { + await fetchRecordings(); + } catch (error) { + console.error( + 'Failed to refresh recordings after series removal', + error + ); + } + } + }; + + const handleOnMainCardClick = () => { + if (isRecurringRule) { + onOpenRecurring?.(recording, false); + } else { + onOpenDetails?.(recording); + } + } + + const WatchLive = () => { + return ; + } + + const WatchRecording = () => { + return + + ; + } + + const MainCard = ( + + + + + {isInterrupted + ? 'Interrupted' + : isInProgress + ? 'Recording' + : isUpcoming + ? 'Scheduled' + : 'Completed'} + + {isInterrupted && } + + + + {recordingName} + + {isSeriesGroup && ( + + Series + + )} + {isRecurringRule && ( + + Recurring + + )} + {seLabel && !isSeriesGroup && ( + + {seLabel} + + )} + + + + +
+ + e.stopPropagation()} + onClick={handleCancelClick} + > + + + +
+
+ + + {recordingName} + + {!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} + + ); +}; + +export default RecordingCard; \ No newline at end of file diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx new file mode 100644 index 00000000..1abc6f3b --- /dev/null +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -0,0 +1,362 @@ +import useChannelsStore from '../../store/channels.jsx'; +import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js'; +import React from 'react'; +import { Badge, Button, Card, Flex, Group, Image, Modal, Stack, Text, } from '@mantine/core'; +import useVideoStore from '../../store/useVideoStore.jsx'; +import { notifications } from '@mantine/notifications'; +import { + deleteRecordingById, + getPosterUrl, + getRecordingUrl, + getSeasonLabel, + getShowVideoUrl, + runComSkip, +} from '../../utils/cards/RecordingCardUtils.js'; +import { + getRating, + getStatRows, + getUpcomingEpisodes, +} from '../../utils/forms/RecordingDetailsModalUtils.js'; + +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 = getStatRows(stats); + + // Rating (if available) + const rating = getRating(customProps, program); + 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); + + const isSeriesGroup = Boolean( + safeRecording._group_count && safeRecording._group_count > 1 + ); + const upcomingEpisodes = React.useMemo(() => { + return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow); + }, [ + allRecordings, + isSeriesGroup, + program.tvg_id, + program.title, + toUserTime, + userNow, + ]); + + const handleOnWatchLive = () => { + 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)) { + if (!channelMap[rec.channel]) return; + useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live'); + } + } + + const handleOnWatchRecording = () => { + let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode) + if (!fileUrl) return; + + useVideoStore.getState().showVideo(fileUrl, 'vod', { + name: + childRec.custom_properties?.program?.title || 'Recording', + logo: { + url: getPosterUrl( + childRec.custom_properties?.poster_logo_id, + undefined, + channelMap[childRec.channel]?.logo?.cache_url + ) + }, + }); + } + + const handleRunComskip = async (e) => { + e.stopPropagation?.(); + try { + await runComSkip(recording) + notifications.show({ + title: 'Removing commercials', + message: 'Queued comskip for this recording', + color: 'blue.5', + autoClose: 2000, + }); + } catch (error) { + console.error('Failed to run comskip', error); + } + } + + 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 = getSeasonLabel(season, episode, onscreen); + const posterLogoId = cp.poster_logo_id; + const purl = getPosterUrl(posterLogoId, cp, posterUrl); + + const onRemove = async (e) => { + e?.stopPropagation?.(); + try { + await deleteRecordingById(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); + } + }; + + const handleOnMainCardClick = () => { + setChildRec(rec); + setChildOpen(true); + } + return ( + + + {pr.title + + + + {pr.sub_title || pr.title} + + {se && ( + + {se} + + )} + + + {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} + + + + + + + + ); + }; + + const WatchLive = () => { + return ; + } + + const WatchRecording = () => { + return ; + } + + const Edit = () => { + return ; + } + + const Series = () => { + return + {upcomingEpisodes.length === 0 && ( + + No upcoming episodes found + + )} + {upcomingEpisodes.map((ep) => ( + + ))} + {childOpen && childRec && ( + setChildOpen(false)} + recording={childRec} + channel={channelMap[childRec.channel]} + posterUrl={getPosterUrl( + childRec.custom_properties?.poster_logo_id, + childRec.custom_properties, + channelMap[childRec.channel]?.logo?.cache_url + )} + env_mode={env_mode} + onWatchLive={handleOnWatchLive} + onWatchRecording={handleOnWatchRecording} + /> + )} + ; + } + + const Movie = () => { + return + {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} + + ))} + + )} + + ; + } + + return ( + + {isSeriesGroup ? : } + + ); +}; + +export default RecordingDetailsModal; \ No newline at end of file diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx new file mode 100644 index 00000000..d574c8c0 --- /dev/null +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -0,0 +1,381 @@ +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, 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(() => { + 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 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 deleteRecurringRuleById(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 updateRecurringRuleEnabled(ruleId, 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 deleteRecordingById(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. + + ); + } + + 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 + {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)} + + + + + + + + + ); + })} + ; + } + + return ( + + + + + {channels?.[rule.channel]?.name || `Channel ${rule.channel}`} + + + +
+ + - - ({ - value: String(opt.value), - label: opt.label, - }))} - searchable - clearable - /> - - - form.setFieldValue('start_date', value || dayjs().toDate()) - } - valueFormat="MMM D, YYYY" - /> - form.setFieldValue('end_date', value)} - valueFormat="MMM D, YYYY" - minDate={form.values.start_date || undefined} - /> - - - - form.setFieldValue('start_time', toTimeString(value)) - } - withSeconds={false} - format="12" - amLabel="AM" - pmLabel="PM" - /> - - form.setFieldValue('end_time', toTimeString(value)) - } - withSeconds={false} - format="12" - amLabel="AM" - pmLabel="PM" - /> - - - - - - -
- - - - 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} - > - - - -
-
- - - {recordingName} - - {!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 { + useTimeHelpers, +} from '../utils/dateTimeUtils.js'; +const RecordingDetailsModal = lazy(() => import('../components/forms/RecordingDetailsModal')); +import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx'; +import RecordingCard from '../components/cards/RecordingCard.jsx'; +import { categorizeRecordings } from '../utils/pages/DVRUtils.js'; +import { getPosterUrl } from '../utils/cards/RecordingCardUtils.js'; +import ErrorBoundary from '../components/ErrorBoundary.jsx'; const DVRPage = () => { const theme = useMantineTheme(); @@ -1441,86 +91,63 @@ 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 handleOnWatchLive = () => { + 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 handleOnWatchRecording = () => { + 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: getPosterUrl( + detailsRecording.custom_properties?.poster_logo_id, + undefined, + channels[detailsRecording.channel]?.logo?.cache_url + ) + }, + }); + } return ( - +