From 700d0d23833653b9e01f5c30beed46e9d6f4cd4d Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:38:28 -0800 Subject: [PATCH 01/28] Moved error logic to separate component --- frontend/src/pages/ContentSources.jsx | 50 ++++++++++++++++----------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx index cede4047..310abb7f 100644 --- a/frontend/src/pages/ContentSources.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -3,29 +3,37 @@ import M3UsTable from '../components/tables/M3UsTable'; import EPGsTable from '../components/tables/EPGsTable'; import { Box, Stack } from '@mantine/core'; -const M3UPage = () => { +const ErrorBoundary = ({ children }) => { const error = useUserAgentsStore((state) => state.error); - if (error) return
Error: {error}
; - return ( - - - - + if (error) { + return
Error: {error}
; + } + return children; +} - - - -
+const M3UPage = () => { + return ( + + + + + + + + + + + ); -}; +} export default M3UPage; From aea888238ad2aed6c0f8b1aee30a27bd8300cc28 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:38:46 -0800 Subject: [PATCH 02/28] Removed unused pages --- frontend/src/pages/Dashboard.jsx | 27 --------------------------- frontend/src/pages/Home.jsx | 14 -------------- 2 files changed, 41 deletions(-) delete mode 100644 frontend/src/pages/Dashboard.jsx delete mode 100644 frontend/src/pages/Home.jsx diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx deleted file mode 100644 index c3c0fb61..00000000 --- a/frontend/src/pages/Dashboard.jsx +++ /dev/null @@ -1,27 +0,0 @@ -// src/components/Dashboard.js -import React, { useState } from 'react'; - -const Dashboard = () => { - const [newStream, setNewStream] = useState(''); - - return ( -
-

Dashboard Page

- setNewStream(e.target.value)} - placeholder="Enter Stream" - /> - -

Streams:

- -
- ); -}; - -export default Dashboard; diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx deleted file mode 100644 index e9751d8d..00000000 --- a/frontend/src/pages/Home.jsx +++ /dev/null @@ -1,14 +0,0 @@ -// src/components/Home.js -import React, { useState } from 'react'; - -const Home = () => { - const [newChannel, setNewChannel] = useState(''); - - return ( -
-

Home Page

-
- ); -}; - -export default Home; From 0070d9e50025783b3a625e94bd24a2c02e684d4a Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:29:48 -0800 Subject: [PATCH 03/28] Added ErrorBoundary component --- frontend/src/components/ErrorBoundary.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 frontend/src/components/ErrorBoundary.jsx diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..60c4ba38 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + state = { hasError: false }; + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return
Something went wrong
; + } + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file From dd5ae8450dc9a1548ea41a3fd2b44d75ea78d6d7 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:31:04 -0800 Subject: [PATCH 04/28] Updated pages to utilize error boundary --- frontend/src/pages/Channels.jsx | 57 +++++++++++++++------------ frontend/src/pages/ContentSources.jsx | 48 +++++++++++----------- frontend/src/pages/Users.jsx | 54 ++++++------------------- 3 files changed, 69 insertions(+), 90 deletions(-) diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index 7663276d..d09f0c41 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -1,14 +1,17 @@ -import React from 'react'; +import React, { lazy, Suspense } from 'react'; import ChannelsTable from '../components/tables/ChannelsTable'; -import StreamsTable from '../components/tables/StreamsTable'; -import { Box } from '@mantine/core'; +const StreamsTable = lazy(() => import('../components/tables/StreamsTable')); +import { Box, Text } from '@mantine/core'; import { Allotment } from 'allotment'; import { USER_LEVELS } from '../constants'; import useAuthStore from '../store/auth'; import useLocalStorage from '../hooks/useLocalStorage'; +import ErrorBoundary from '../components/ErrorBoundary'; -const ChannelsPage = () => { +const PageContent = () => { const authUser = useAuthStore((s) => s.user); + if (!authUser.id) throw new Error() + const [allotmentSizes, setAllotmentSizes] = useLocalStorage( 'channels-splitter-sizes', [50, 50] @@ -22,9 +25,6 @@ const ChannelsPage = () => { setAllotmentSizes(sizes); }; - if (!authUser.id) { - return <>; - } if (authUser.user_level <= USER_LEVELS.STANDARD) { return ( @@ -34,34 +34,41 @@ const ChannelsPage = () => { } return ( -
-
-
+ + -
-
-
-
- -
-
+ + + + + + Loading...}> + + + + +
-
+
+ ); +}; + +const ChannelsPage = () => { + return ( + + + ); }; diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx index 310abb7f..c9eaaffc 100644 --- a/frontend/src/pages/ContentSources.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -2,36 +2,38 @@ import useUserAgentsStore from '../store/userAgents'; import M3UsTable from '../components/tables/M3UsTable'; import EPGsTable from '../components/tables/EPGsTable'; import { Box, Stack } from '@mantine/core'; +import ErrorBoundary from '../components/ErrorBoundary' -const ErrorBoundary = ({ children }) => { +const PageContent = () => { const error = useUserAgentsStore((state) => state.error); - if (error) { - return
Error: {error}
; - } - return children; + if (error) throw new Error(error); + + return ( + + + + + + + + + + ); } const M3UPage = () => { return ( - - - - - - - - - + ); } diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index 570e49c1..e69f07f8 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -1,55 +1,25 @@ -import React, { useState } from 'react'; import UsersTable from '../components/tables/UsersTable'; import { Box } from '@mantine/core'; import useAuthStore from '../store/auth'; -import { USER_LEVELS } from '../constants'; +import ErrorBoundary from '../components/ErrorBoundary'; -const UsersPage = () => { +const PageContent = () => { const authUser = useAuthStore((s) => s.user); - - const [selectedUser, setSelectedUser] = useState(null); - const [userModalOpen, setUserModalOpen] = useState(false); - const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [userToDelete, setUserToDelete] = useState(null); - - if (!authUser.id) { - return <>; - } - - const closeUserModal = () => { - setSelectedUser(null); - setUserModalOpen(false); - }; - const editUser = (user) => { - setSelectedUser(user); - setUserModalOpen(true); - }; - - const deleteUser = (id) => { - // Get user details for the confirmation dialog - const user = users.find((u) => u.id === id); - setUserToDelete(user); - setDeleteTarget(id); - - // Skip warning if it's been suppressed - if (isWarningSuppressed('delete-user')) { - return executeDeleteUser(id); - } - - setConfirmDeleteOpen(true); - }; - - const executeDeleteUser = async (id) => { - await API.deleteUser(id); - setConfirmDeleteOpen(false); - }; + if (!authUser.id) throw new Error(); return ( - + ); +} + +const UsersPage = () => { + return ( + + + + ); }; export default UsersPage; From bfcc47c33137aae34352a0b727c29f8f3f1c06e8 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:31:56 -0800 Subject: [PATCH 05/28] Extracted DVR components --- frontend/src/components/RecordingSynopsis.jsx | 22 + .../src/components/cards/RecordingCard.jsx | 458 ++++++++++++++++++ .../forms/RecordingDetailsModal.jsx | 429 ++++++++++++++++ .../components/forms/RecurringRuleModal.jsx | 396 +++++++++++++++ 4 files changed, 1305 insertions(+) create mode 100644 frontend/src/components/RecordingSynopsis.jsx create mode 100644 frontend/src/components/cards/RecordingCard.jsx create mode 100644 frontend/src/components/forms/RecordingDetailsModal.jsx create mode 100644 frontend/src/components/forms/RecurringRuleModal.jsx diff --git a/frontend/src/components/RecordingSynopsis.jsx b/frontend/src/components/RecordingSynopsis.jsx new file mode 100644 index 00000000..aa870258 --- /dev/null +++ b/frontend/src/components/RecordingSynopsis.jsx @@ -0,0 +1,22 @@ +// Short preview that triggers the details modal when clicked +export 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} + + ); +}; \ 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..1a0fe307 --- /dev/null +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -0,0 +1,458 @@ +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 API from '../../api.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.jsx'; + +export 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} + + ); +}; \ 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..9b01945c --- /dev/null +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -0,0 +1,429 @@ +import useChannelsStore from '../../store/channels.jsx'; +import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js'; +import React from 'react'; +import API from '../../api.js'; +import { + Badge, + Button, + Card, + Flex, + Group, + Image, + Modal, + Stack, + Text, +} from '@mantine/core'; +import useVideoStore from '../../store/useVideoStore.jsx'; +import { notifications } from '@mantine/notifications'; + +export 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} + + ))} + + )} + + + )} + + ); +}; \ 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..590d4641 --- /dev/null +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -0,0 +1,396 @@ +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 API from '../../api.js'; +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'; + +export 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} + /> + +
+ + - - ({ - 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 { + 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 ( - + ; + } + + const WatchRecording = () => { + return + + ; + } + const MainCard = ( height: '100%', cursor: 'pointer', }} - onClick={() => { - if (isRecurringRule) { - onOpenRecurring?.(recording, false); - } else { - onOpenDetails?.(recording); - } - }} + onClick={handleOnMainCardClick} > - - + + : 'Completed'} {isInterrupted && } - + {recordingName} @@ -289,7 +285,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => alt={recordingName} fallbackSrc="/logo.png" /> - + {!isSeriesGroup && subTitle && ( @@ -332,43 +328,9 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => )} - {isInProgress && ( - - )} + {isInProgress && } - {!isUpcoming && ( - - - - )} + {!isUpcoming && } {!isUpcoming && customProps?.status === 'completed' && (!customProps?.comskip || diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index 9b01945c..36410b6f 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,20 +1,19 @@ import useChannelsStore from '../../store/channels.jsx'; import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js'; import React from 'react'; -import API from '../../api.js'; -import { - Badge, - Button, - Card, - Flex, - Group, - Image, - Modal, - Stack, - Text, -} from '@mantine/core'; +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'; export const RecordingDetailsModal = ({ opened, @@ -43,26 +42,10 @@ export const RecordingDetailsModal = ({ 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 !== ''); + const statRows = getStatRows(stats); // Rating (if available) - const rating = - customProps.rating || - customProps.rating_value || - (program && program.custom_properties && program.custom_properties.rating); + const rating = getRating(customProps, program); const ratingSystem = customProps.rating_system || 'MPAA'; const fileUrl = customProps.file_url || customProps.output_file_url; @@ -71,61 +54,11 @@ export const RecordingDetailsModal = ({ 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) - ); + return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow); }, [ allRecordings, isSeriesGroup, @@ -146,27 +79,14 @@ export const RecordingDetailsModal = ({ 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 se = getSeasonLabel(season, episode, onscreen); 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 purl = getPosterUrl(posterLogoId, cp, posterUrl); + const onRemove = async (e) => { e?.stopPropagation?.(); try { - await API.deleteRecording(rec.id); + await deleteRecordingById(rec.id); } catch (error) { console.error('Failed to delete upcoming recording', error); } @@ -176,16 +96,18 @@ export const RecordingDetailsModal = ({ console.error('Failed to refresh recordings after delete', error); } }; + + const handleOnMainCardClick = () => { + setChildRec(rec); + setChildOpen(true); + } return ( { - setChildRec(rec); - setChildOpen(true); - }} + onClick={handleOnMainCardClick} > {pr.title - + { + 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 WatchLive = () => { + return ; + } + + const WatchRecording = () => { + return ; + } + + const Edit = () => { + return ; + } + + 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); + } + } return ( 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' - } + posterUrl={getPosterUrl( + childRec.custom_properties?.poster_logo_id, + childRec.custom_properties, + channelMap[childRec.channel]?.logo?.cache_url + )} 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', - }, - }); - }} + onWatchLive={handleOnWatchLive} + onWatchRecording={handleOnWatchRecording} /> )} ) : ( - {onWatchLive && ( - - )} - {onWatchRecording && ( - - )} - {onEdit && start.isAfter(userNow()) && ( - - )} + {onWatchLive && } + {onWatchRecording && } + {onEdit && start.isAfter(userNow()) && } {customProps.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && ( @@ -371,20 +307,7 @@ export const RecordingDetailsModal = ({ size="xs" variant="light" color="teal" - onClick={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 run comskip', error); - } - }} + onClick={handleRunComskip} > Remove commercials diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js new file mode 100644 index 00000000..65b3da3a --- /dev/null +++ b/frontend/src/utils/cards/RecordingCardUtils.js @@ -0,0 +1,92 @@ +import API from '../../api.js'; +import useChannelsStore from '../../store/channels.jsx'; + +export const removeRecording = (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); + } + }); +}; + +export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => { + let purl = posterLogoId + ? `/api/channels/logos/${posterLogoId}/cache/` + : customProperties?.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}`; + } + return purl; +}; + +export const getShowVideoUrl = (channel, env_mode) => { + let url = `/proxy/ts/stream/${channel.uuid}`; + if (env_mode === 'dev') { + url = `${window.location.protocol}//${window.location.hostname}:5656${url}`; + } + return url; +}; + +export const runComSkip = async (recording) => { + await API.runComskip(recording.id); +}; + +export const deleteRecordingById = async (recordingId) => { + await API.deleteRecording(recordingId); +}; + +export const deleteSeriesAndRule = async (seriesInfo) => { + 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); + } + } +}; + +export const getRecordingUrl = (customProps, env_mode) => { + let fileUrl = customProps?.file_url || customProps?.output_file_url; + if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) { + fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; + } + return fileUrl; +}; + +export const getSeasonLabel = (season, episode, onscreen) => { + return season && episode + ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` + : onscreen || null; +}; + +export const getSeriesInfo = (customProps) => { + const cp = customProps || {}; + const pr = cp.program || {}; + return { tvg_id: pr.tvg_id, title: pr.title }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js new file mode 100644 index 00000000..805bc006 --- /dev/null +++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js @@ -0,0 +1,87 @@ +export const getStatRows = (stats) => { + return [ + ['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 !== ''); +}; + +export const getRating = (customProps, program) => { + return ( + customProps.rating || + customProps.rating_value || + (program && program.custom_properties && program.custom_properties.rating) + ); +}; + +const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => { + return 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()); + }); +} + +const dedupeByProgram = (filtered) => { + // 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; +} + +export const getUpcomingEpisodes = ( + isSeriesGroup, + allRecordings, + program, + toUserTime, + userNow +) => { + if (!isSeriesGroup) return []; + + const arr = Array.isArray(allRecordings) + ? allRecordings + : Object.values(allRecordings || {}); + const tvid = program.tvg_id || ''; + const titleKey = (program.title || '').toLowerCase(); + + const filtered = filterByUpcoming(arr, tvid, titleKey, toUserTime, userNow); + + return dedupeByProgram(filtered).sort( + (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time) + ); +}; From 7c4554233211f2eb937e1b9457f5f6c40e21b28b Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:43:39 -0800 Subject: [PATCH 14/28] Fixed cache_url fallback --- frontend/src/components/cards/RecordingCard.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx index 96dcea11..6a25259b 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -51,8 +51,8 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => const isRecurringRule = customProps?.rule?.type === 'recurring'; // Poster or channel logo - const posterLogoId = customProps.poster_logo_id; - const posterUrl = getPosterUrl(posterLogoId, customProps, channel, env_mode); + 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); From 38033da90f1e56aaad05bcf70567d5f58f7cda35 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:43:55 -0800 Subject: [PATCH 15/28] Fixed component syntax --- frontend/src/pages/DVR.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 023457e3..711c9ba1 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -93,7 +93,7 @@ const DVRPage = () => { return categorizeRecordings(recordings, toUserTime, now); }, [recordings, now, toUserTime]); - const RecordingList = (list) => { + const RecordingList = ({ list }) => { return list.map((rec) => ( { onOpenRecurring={openRuleModal} /> )); - } + }; const handleOnWatchLive = () => { const rec = detailsRecording; From dd75b5b21aedec5e62ed5bc2b5d20577e99b9909 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:44:11 -0800 Subject: [PATCH 16/28] Added correct import for Text component --- frontend/src/components/RecordingSynopsis.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/RecordingSynopsis.jsx b/frontend/src/components/RecordingSynopsis.jsx index aa870258..1b6ec5ab 100644 --- a/frontend/src/components/RecordingSynopsis.jsx +++ b/frontend/src/components/RecordingSynopsis.jsx @@ -1,3 +1,5 @@ +import { Text, } from '@mantine/core'; + // Short preview that triggers the details modal when clicked export const RecordingSynopsis = ({ description, onOpen }) => { const truncated = description?.length > 140; From 36ec2fb1b02c79fc600f4634db34ab81a77d8a27 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:44:55 -0800 Subject: [PATCH 17/28] Extracted component and util logic --- .../components/forms/RecurringRuleModal.jsx | 199 ++++++++---------- .../utils/forms/RecurringRuleModalUtils.js | 66 ++++++ 2 files changed, 157 insertions(+), 108 deletions(-) create mode 100644 frontend/src/utils/forms/RecurringRuleModalUtils.js diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx index 590d4641..1fbdd549 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -9,10 +9,17 @@ import { import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from '@mantine/form'; import dayjs from 'dayjs'; -import API from '../../api.js'; 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'; export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { const channels = useChannelsStore((s) => s.channels); @@ -30,19 +37,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } 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}`, - })); + return getChannelOptions(channels); }, [channels]); const form = useForm({ @@ -109,41 +104,14 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } }, [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() - ); + return getUpcomingOccurrences(recordings, userNow, ruleId, toUserTime); }, [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 updateRecurringRule(ruleId, values); await Promise.all([fetchRecurringRules(), fetchRecordings()]); notifications.show({ title: 'Recurring rule updated', @@ -163,7 +131,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } if (!rule) return; setDeleting(true); try { - await API.deleteRecurringRule(ruleId); + await deleteRecurringRuleById(ruleId); await Promise.all([fetchRecurringRules(), fetchRecordings()]); notifications.show({ title: 'Recurring rule removed', @@ -183,7 +151,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } if (!rule) return; setSaving(true); try { - await API.updateRecurringRule(ruleId, { enabled: checked }); + await updateRecurringRuleEnabled(ruleId, checked); await Promise.all([fetchRecurringRules(), fetchRecordings()]); notifications.show({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', @@ -204,7 +172,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } const handleCancelOccurrence = async (occurrence) => { setBusyOccurrence(occurrence.id); try { - await API.deleteRecording(occurrence.id); + await deleteRecordingById(occurrence.id); await fetchRecordings(); notifications.show({ title: 'Occurrence cancelled', @@ -227,6 +195,77 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } ); } + 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 ( { - form.setFieldValue('enabled', event.currentTarget.checked); - handleToggleEnabled(event.currentTarget.checked); - }} + onChange={handleEnableChange} label={form.values.enabled ? 'Enabled' : 'Paused'} disabled={saving} /> @@ -278,15 +314,13 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } - form.setFieldValue('start_date', value || dayjs().toDate()) - } + onChange={handleStartDateChange} valueFormat="MMM D, YYYY" /> form.setFieldValue('end_date', value)} + onChange={handleEndDateChange} valueFormat="MMM D, YYYY" minDate={form.values.start_date || undefined} /> @@ -295,9 +329,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } - form.setFieldValue('start_time', toTimeString(value)) - } + onChange={handleStartTimeChange} withSeconds={false} format="12" amLabel="AM" @@ -306,9 +338,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } - form.setFieldValue('end_time', toTimeString(value)) - } + onChange={handleEndTimeChange} withSeconds={false} format="12" amLabel="AM" @@ -341,54 +371,7 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } 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)} - - - - - - - - - ); - })} - - )} + ) : } diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js new file mode 100644 index 00000000..1eb9194a --- /dev/null +++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js @@ -0,0 +1,66 @@ +import API from '../../api.js'; +import { toTimeString } from '../dateTimeUtils.js'; +import dayjs from 'dayjs'; + +export const getChannelOptions = (channels) => { + return Object.values(channels || {}) + .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; + }) + .map((item) => ({ + value: `${item.id}`, + label: item.name || `Channel ${item.id}`, + })); +}; + +export const getUpcomingOccurrences = ( + recordings, + userNow, + ruleId, + toUserTime +) => { + 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() + ); +}; + +export const updateRecurringRule = async (ruleId, values) => { + 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), + }); +}; + +export const deleteRecurringRuleById = async (ruleId) => { + await API.deleteRecurringRule(ruleId); +}; + +export const updateRecurringRuleEnabled = async (ruleId, checked) => { + await API.updateRecurringRule(ruleId, { enabled: checked }); +}; \ No newline at end of file From 4c60ce0c2899617215cc63b99827663220888b98 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:34:20 -0800 Subject: [PATCH 18/28] Extracted Series and Movie components --- .../forms/RecordingDetailsModal.jsx | 284 +++++++++--------- 1 file changed, 146 insertions(+), 138 deletions(-) diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index 36410b6f..31c5c28e 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -6,8 +6,11 @@ import useVideoStore from '../../store/useVideoStore.jsx'; import { notifications } from '@mantine/notifications'; import { deleteRecordingById, - getPosterUrl, getRecordingUrl, - getSeasonLabel, getShowVideoUrl, runComSkip, + getPosterUrl, + getRecordingUrl, + getSeasonLabel, + getShowVideoUrl, + runComSkip, } from '../../utils/cards/RecordingCardUtils.js'; import { getRating, @@ -68,6 +71,50 @@ export const RecordingDetailsModal = ({ 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 }) => { @@ -149,35 +196,6 @@ export const RecordingDetailsModal = ({ ); }; - 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 WatchLive = () => { return + )} + + + + {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 ? ( - - {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} - /> - )} - - ) : ( - - {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} - - ))} - - )} - - - )} + {isSeriesGroup ? : } ); }; \ No newline at end of file From 1906c9955eb4283e9e96e084db9a257ea3ad5fc2 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:34:53 -0800 Subject: [PATCH 19/28] Updated to default export --- frontend/src/components/RecordingSynopsis.jsx | 6 ++++-- frontend/src/components/cards/RecordingCard.jsx | 8 +++++--- frontend/src/components/forms/RecordingDetailsModal.jsx | 6 ++++-- frontend/src/components/forms/RecurringRuleModal.jsx | 6 ++++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/RecordingSynopsis.jsx b/frontend/src/components/RecordingSynopsis.jsx index 1b6ec5ab..bf668afe 100644 --- a/frontend/src/components/RecordingSynopsis.jsx +++ b/frontend/src/components/RecordingSynopsis.jsx @@ -1,7 +1,7 @@ import { Text, } from '@mantine/core'; // Short preview that triggers the details modal when clicked -export const RecordingSynopsis = ({ description, onOpen }) => { +const RecordingSynopsis = ({ description, onOpen }) => { const truncated = description?.length > 140; const preview = truncated ? `${description.slice(0, 140).trim()}...` @@ -21,4 +21,6 @@ export const RecordingSynopsis = ({ description, onOpen }) => { {preview} ); -}; \ No newline at end of file +}; + +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 index 6a25259b..6f90e0f5 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -20,7 +20,7 @@ import { Tooltip, } from '@mantine/core'; import { AlertTriangle, SquareX } from 'lucide-react'; -import { RecordingSynopsis } from '../RecordingSynopsis.jsx'; +import RecordingSynopsis from '../RecordingSynopsis'; import { deleteRecordingById, deleteSeriesAndRule, @@ -33,7 +33,7 @@ import { runComSkip, } from './../../utils/cards/RecordingCardUtils.js'; -export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { +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); @@ -417,4 +417,6 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {MainCard} ); -}; \ No newline at end of file +}; + +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 index 31c5c28e..1abc6f3b 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -18,7 +18,7 @@ import { getUpcomingEpisodes, } from '../../utils/forms/RecordingDetailsModalUtils.js'; -export const RecordingDetailsModal = ({ +const RecordingDetailsModal = ({ opened, onClose, recording, @@ -357,4 +357,6 @@ export const RecordingDetailsModal = ({ {isSeriesGroup ? : } ); -}; \ No newline at end of file +}; + +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 index 1fbdd549..d574c8c0 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -21,7 +21,7 @@ import { updateRecurringRuleEnabled, } from '../../utils/forms/RecurringRuleModalUtils.js'; -export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { +const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => { const channels = useChannelsStore((s) => s.channels); const recurringRules = useChannelsStore((s) => s.recurringRules); const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); @@ -376,4 +376,6 @@ export const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence } ); -}; \ No newline at end of file +}; + +export default RecurringRuleModal; \ No newline at end of file From 2a0df81c5950fbab14965135900a16417fdec10c Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:35:12 -0800 Subject: [PATCH 20/28] Lazy loading components --- frontend/src/pages/DVR.jsx | 49 ++++++++++++++++++++---------------- frontend/src/pages/Login.jsx | 14 ++++++++--- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 711c9ba1..8e39cf2c 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, lazy, Suspense } from 'react'; import { Box, Button, @@ -20,11 +20,12 @@ import RecordingForm from '../components/forms/Recording'; import { 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'; +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(); @@ -253,24 +254,28 @@ const DVRPage = () => { {/* Details Modal */} {detailsRecording && ( - { - setEditRecording(rec); - closeDetails(); - }} - /> + + Loading...}> + { + setEditRecording(rec); + closeDetails(); + }} + /> + + )} ); diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 262d4c35..3c2cf869 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,13 +1,21 @@ -import React from 'react'; +import React, { lazy, Suspense } from 'react'; import LoginForm from '../components/forms/LoginForm'; -import SuperuserForm from '../components/forms/SuperuserForm'; +const SuperuserForm = lazy(() => import('../components/forms/SuperuserForm')); import useAuthStore from '../store/auth'; +import ErrorBoundary from '../components/ErrorBoundary.jsx'; +import { Text } from '@mantine/core'; const Login = ({}) => { const superuserExists = useAuthStore((s) => s.superuserExists); if (!superuserExists) { - return ; + return ( + + Loading...}> + + + + ); } return ; From bd148a7f140bc0e29de3ecf2b5b8b253ea5d36dd Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Thu, 18 Dec 2025 07:46:21 -0800 Subject: [PATCH 21/28] Reverted Channels change for initial render --- frontend/src/pages/Channels.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index d09f0c41..8f5cae26 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -10,7 +10,6 @@ import ErrorBoundary from '../components/ErrorBoundary'; const PageContent = () => { const authUser = useAuthStore((s) => s.user); - if (!authUser.id) throw new Error() const [allotmentSizes, setAllotmentSizes] = useLocalStorage( 'channels-splitter-sizes', @@ -25,6 +24,8 @@ const PageContent = () => { setAllotmentSizes(sizes); }; + if (!authUser.id) return <>; + if (authUser.user_level <= USER_LEVELS.STANDARD) { return ( From 2b1d5622a64378d1d3c50dd1649aa00ab1cb2b71 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Thu, 18 Dec 2025 07:47:18 -0800 Subject: [PATCH 22/28] Setting User before fetch settings completes --- frontend/src/store/auth.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index b1d60a1a..7f92f669 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -43,6 +43,8 @@ const useAuthStore = create((set, get) => ({ throw new Error('Unauthorized'); } + set({ user, isAuthenticated: true }); + // Ensure settings are loaded first await useSettingsStore.getState().fetchSettings(); @@ -62,8 +64,6 @@ const useAuthStore = create((set, get) => ({ if (user.user_level >= USER_LEVELS.ADMIN) { await Promise.all([useUsersStore.getState().fetchUsers()]); } - - set({ user, isAuthenticated: true }); } catch (error) { console.error('Error initializing data:', error); } From 22527b085d94816b95c7a1087ff784ee0e540423 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:09:17 -0800 Subject: [PATCH 23/28] Checking if data has been fetched before displaying empty channels --- frontend/src/components/tables/ChannelsTable.jsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 9b9958f7..eb16146d 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -47,6 +47,7 @@ import { Select, NumberInput, Tooltip, + LoadingOverlay, } from '@mantine/core'; import { getCoreRowModel, flexRender } from '@tanstack/react-table'; import './table.css'; @@ -289,6 +290,7 @@ const ChannelsTable = ({}) => { const [selectedProfile, setSelectedProfile] = useState( profiles[selectedProfileId] ); + const [hasFetchedData, setHasFetchedData] = useState(false); const [paginationString, setPaginationString] = useState(''); const [filters, setFilters] = useState({ @@ -361,10 +363,14 @@ const ChannelsTable = ({}) => { }); }); + const channelsTableLength = hasFetchedData ? Object.keys(data).length : undefined; + /** * Functions */ const fetchData = useCallback(async () => { + setIsLoading(true); + const params = new URLSearchParams(); params.append('page', pagination.pageIndex + 1); params.append('page_size', pagination.pageSize); @@ -397,6 +403,9 @@ const ChannelsTable = ({}) => { await API.getAllChannelIds(params), ]); + setIsLoading(false); + setHasFetchedData(true); + setTablePrefs({ pageSize: pagination.pageSize, }); @@ -1330,12 +1339,12 @@ const ChannelsTable = ({}) => { {/* Table or ghost empty state inside Paper */} - {Object.keys(channels).length === 0 && ( + {channelsTableLength === 0 && ( )} - {Object.keys(channels).length > 0 && ( + {channelsTableLength > 0 && ( { borderRadius: 'var(--mantine-radius-default)', }} > + From 4cd63bc8984551021c7d5cd3428449b9f6c27531 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:33:21 -0800 Subject: [PATCH 24/28] Reverted LoadingOverlay --- frontend/src/components/tables/ChannelsTable.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 57f524f7..b025d2d5 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -47,7 +47,6 @@ import { Select, NumberInput, Tooltip, - LoadingOverlay, } from '@mantine/core'; import { getCoreRowModel, flexRender } from '@tanstack/react-table'; import './table.css'; @@ -1389,7 +1388,6 @@ const ChannelsTable = ({}) => { borderRadius: 'var(--mantine-radius-default)', }} > - From 1029eb5b5c3c806d16b6ca5a9e5f8912a78d3e6f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:19:04 -0800 Subject: [PATCH 25/28] Table length checking if data is already set --- frontend/src/components/tables/ChannelsTable.jsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 1b2e73d7..b60ada56 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1,4 +1,10 @@ -import React, { useEffect, useMemo, useState, useCallback } from 'react'; +import React, { + useEffect, + useMemo, + useState, + useCallback, + useRef, +} from 'react'; import useChannelsStore from '../../store/channels'; import useLogosStore from '../../store/logos'; import { notifications } from '@mantine/notifications'; @@ -289,7 +295,6 @@ const ChannelsTable = ({}) => { const [selectedProfile, setSelectedProfile] = useState( profiles[selectedProfileId] ); - const [hasFetchedData, setHasFetchedData] = useState(false); const [showDisabled, setShowDisabled] = useState(true); const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] = useState(false); @@ -311,6 +316,8 @@ const ChannelsTable = ({}) => { const [isBulkDelete, setIsBulkDelete] = useState(false); const [channelToDelete, setChannelToDelete] = useState(null); + const hasFetchedData = useRef(false); + // Column sizing state for resizable columns // Store in localStorage but with empty object as default const [columnSizing, setColumnSizing] = useLocalStorage( @@ -365,7 +372,8 @@ const ChannelsTable = ({}) => { }); }); - const channelsTableLength = hasFetchedData ? Object.keys(data).length : undefined; + const channelsTableLength = (Object.keys(data).length > 0 || hasFetchedData.current) ? + Object.keys(data).length : undefined; /** * Functions @@ -406,7 +414,7 @@ const ChannelsTable = ({}) => { ]); setIsLoading(false); - setHasFetchedData(true); + hasFetchedData.current = true; setTablePrefs({ pageSize: pagination.pageSize, From 9c9cbab94cf0caaf58b96a20150335ef539d498d Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:27:29 -0800 Subject: [PATCH 26/28] Reverted lazy load of StreamsTable --- frontend/src/pages/Channels.jsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index 8f5cae26..26ed77fa 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -1,7 +1,7 @@ -import React, { lazy, Suspense } from 'react'; +import React from 'react'; import ChannelsTable from '../components/tables/ChannelsTable'; -const StreamsTable = lazy(() => import('../components/tables/StreamsTable')); -import { Box, Text } from '@mantine/core'; +import StreamsTable from '../components/tables/StreamsTable'; +import { Box, } from '@mantine/core'; import { Allotment } from 'allotment'; import { USER_LEVELS } from '../constants'; import useAuthStore from '../store/auth'; @@ -53,11 +53,7 @@ const PageContent = () => { - - Loading...}> - - - + From f5c6d2b576f0b6c050b704ef63cc38bbac406215 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Dec 2025 12:30:08 -0600 Subject: [PATCH 27/28] Enhancement: Implement event-driven logo loading orchestration on Channels page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce gated logo loading system that ensures logos render after both ChannelsTable and StreamsTable have completed their initial data fetch, preventing visual race conditions and ensuring proper paint order. Changes: - Add `allowLogoRendering` flag to logos store to gate logo fetching - Implement `onReady` callbacks in ChannelsTable and StreamsTable - Add orchestration logic in Channels.jsx to coordinate table readiness - Use double requestAnimationFrame to defer logo loading until after browser paint - Remove background logo loading from App.jsx (now page-specific) - Simplify fetchChannelAssignableLogos to reuse fetchAllLogos - Remove logos dependency from ChannelsTable columns to prevent re-renders This ensures visual loading order: Channels → EPG → Streams → Logos, regardless of network speed or data size, without timer-based hacks. --- frontend/src/App.jsx | 11 +-- frontend/src/components/LazyLogo.jsx | 20 ++++-- .../src/components/tables/ChannelsTable.jsx | 24 +++++-- .../src/components/tables/StreamsTable.jsx | 18 ++++- frontend/src/pages/Channels.jsx | 58 ++++++++++++--- frontend/src/store/auth.jsx | 4 +- frontend/src/store/logos.jsx | 72 +++++-------------- 7 files changed, 118 insertions(+), 89 deletions(-) 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 ( { 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 ChannelsTable = ({ onReady }) => { // EPG data lookup const tvgsById = useEPGsStore((s) => s.tvgsById); const epgs = useEPGsStore((s) => s.epgs); @@ -235,6 +234,7 @@ const ChannelsTable = ({}) => { const canDeleteChannelGroup = useChannelsStore( (s) => s.canDeleteChannelGroup ); + const hasSignaledReady = useRef(false); /** * STORES @@ -260,7 +260,6 @@ const ChannelsTable = ({}) => { const channels = useChannelsStore((s) => s.channels); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); - const logos = useLogosStore((s) => s.logos); const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', { pageSize: 50, }); @@ -372,8 +371,10 @@ const ChannelsTable = ({}) => { }); }); - const channelsTableLength = (Object.keys(data).length > 0 || hasFetchedData.current) ? - Object.keys(data).length : undefined; + const channelsTableLength = + Object.keys(data).length > 0 || hasFetchedData.current + ? Object.keys(data).length + : undefined; /** * Functions @@ -420,7 +421,14 @@ const ChannelsTable = ({}) => { pageSize: pagination.pageSize, }); setAllRowIds(ids); - }, [pagination, sorting, debouncedFilters]); + + // Signal ready after first successful data fetch + // EPG data is already loaded in initData before this component mounts + if (!hasSignaledReady.current && onReady) { + hasSignaledReady.current = true; + onReady(); + } + }, [pagination, sorting, debouncedFilters, onReady]); const stopPropagation = useCallback((e) => { e.stopPropagation(); @@ -907,8 +915,10 @@ const ChannelsTable = ({}) => { // columns from being recreated during drag operations (which causes infinite loops). // The column.size values are only used for INITIAL sizing - TanStack Table manages // the actual sizes through its own state after initialization. + // Note: logos is intentionally excluded - LazyLogo components handle their own logo data + // from the store, so we don't need to recreate columns when logos load. // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedProfileId, channelGroups, logos, theme] + [selectedProfileId, channelGroups, theme] ); const renderHeaderCell = (header) => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index f3f4dc20..ef652864 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -1,4 +1,10 @@ -import React, { useEffect, useMemo, useCallback, useState } from 'react'; +import React, { + useEffect, + useMemo, + useCallback, + useState, + useRef, +} from 'react'; import API from '../../api'; import StreamForm from '../forms/Stream'; import usePlaylistsStore from '../../store/playlists'; @@ -167,8 +173,9 @@ const StreamRowActions = ({ ); }; -const StreamsTable = () => { +const StreamsTable = ({ onReady }) => { const theme = useMantineTheme(); + const hasSignaledReady = useRef(false); /** * useState @@ -430,6 +437,12 @@ const StreamsTable = () => { // Generate the string setPaginationString(`${startItem} to ${endItem} of ${result.count}`); + + // Signal that initial data load is complete + if (!hasSignaledReady.current && onReady) { + hasSignaledReady.current = true; + onReady(); + } } catch (error) { console.error('Error fetching data:', error); } @@ -442,6 +455,7 @@ const StreamsTable = () => { groupsLoaded, channelGroups, fetchChannelGroups, + onReady, ]); // Bulk creation: create channels from selected streams asynchronously diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index 26ed77fa..0fe4f7a7 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -1,21 +1,59 @@ -import React from 'react'; +import React, { useCallback, useRef } from 'react'; import ChannelsTable from '../components/tables/ChannelsTable'; import StreamsTable from '../components/tables/StreamsTable'; -import { Box, } from '@mantine/core'; +import { Box } from '@mantine/core'; import { Allotment } from 'allotment'; import { USER_LEVELS } from '../constants'; import useAuthStore from '../store/auth'; +import useLogosStore from '../store/logos'; import useLocalStorage from '../hooks/useLocalStorage'; import ErrorBoundary from '../components/ErrorBoundary'; const PageContent = () => { const authUser = useAuthStore((s) => s.user); + const fetchChannelAssignableLogos = useLogosStore( + (s) => s.fetchChannelAssignableLogos + ); + const enableLogoRendering = useLogosStore((s) => s.enableLogoRendering); + + const channelsReady = useRef(false); + const streamsReady = useRef(false); + const logosTriggered = useRef(false); const [allotmentSizes, setAllotmentSizes] = useLocalStorage( 'channels-splitter-sizes', [50, 50] ); + // Only load logos when BOTH tables are ready + const tryLoadLogos = useCallback(() => { + if ( + channelsReady.current && + streamsReady.current && + !logosTriggered.current + ) { + logosTriggered.current = true; + // Use requestAnimationFrame to defer logo loading until after browser paint + // This ensures EPG column is fully rendered before logos start loading + requestAnimationFrame(() => { + requestAnimationFrame(() => { + enableLogoRendering(); + fetchChannelAssignableLogos(); + }); + }); + } + }, [fetchChannelAssignableLogos, enableLogoRendering]); + + const handleChannelsReady = useCallback(() => { + channelsReady.current = true; + tryLoadLogos(); + }, [tryLoadLogos]); + + const handleStreamsReady = useCallback(() => { + streamsReady.current = true; + tryLoadLogos(); + }, [tryLoadLogos]); + const handleSplitChange = (sizes) => { setAllotmentSizes(sizes); }; @@ -29,18 +67,18 @@ const PageContent = () => { if (authUser.user_level <= USER_LEVELS.STANDARD) { return ( - + ); } return ( - + { > - + - + @@ -64,7 +102,7 @@ const PageContent = () => { const ChannelsPage = () => { return ( - + ); }; diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 7f92f669..8fe943b7 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -7,7 +7,6 @@ import useEPGsStore from './epgs'; import useStreamProfilesStore from './streamProfiles'; import useUserAgentsStore from './userAgents'; import useUsersStore from './users'; -import useLogosStore from './logos'; import API from '../api'; import { USER_LEVELS } from '../constants'; @@ -64,6 +63,9 @@ const useAuthStore = create((set, get) => ({ if (user.user_level >= USER_LEVELS.ADMIN) { await Promise.all([useUsersStore.getState().fetchUsers()]); } + + // Note: Logos are loaded after the Channels page tables finish loading + // This is handled by the tables themselves signaling completion } catch (error) { console.error('Error initializing data:', error); } diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 4634f672..5843b113 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -9,16 +9,10 @@ const useLogosStore = create((set, get) => ({ hasLoadedAll: false, // Track if we've loaded all logos hasLoadedChannelLogos: false, // Track if we've loaded channel logos error: null, + allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready - // Basic CRUD operations - setLogos: (logos) => { - set({ - logos: logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - }); - }, + // Enable logo rendering (call this after tables have loaded and painted) + enableLogoRendering: () => set({ allowLogoRendering: true }), addLogo: (newLogo) => set((state) => { @@ -73,6 +67,9 @@ const useLogosStore = create((set, get) => ({ // Smart loading methods fetchLogos: async (pageSize = 100) => { + // Don't fetch if logo fetching is not allowed yet + if (!get().allowLogoFetching) return []; + set({ isLoading: true, error: null }); try { const response = await api.getLogos({ page_size: pageSize }); @@ -163,59 +160,28 @@ const useLogosStore = create((set, get) => ({ }, fetchChannelAssignableLogos: async () => { - const { backgroundLoading, hasLoadedChannelLogos, channelLogos } = get(); + const { hasLoadedChannelLogos, channelLogos } = get(); - // Prevent concurrent calls - if ( - backgroundLoading || - (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) - ) { + // Return cached if already loaded + if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) { return Object.values(channelLogos); } - set({ backgroundLoading: true, error: null }); - try { - // Load all channel logos (no special filtering needed - all Logo entries are for channels) - const response = await api.getLogos({ - no_pagination: 'true', // Get all channel logos - }); + // Fetch all logos and cache them as channel logos + const logos = await get().fetchAllLogos(); - // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + set({ + channelLogos: logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + hasLoadedChannelLogos: true, + }); - console.log(`Fetched ${logos.length} channel logos`); - - // Store in both places, but this is intentional and only when specifically requested - set({ - logos: { - ...get().logos, // Keep existing logos - ...logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - }, - channelLogos: logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - hasLoadedChannelLogos: true, - backgroundLoading: false, - }); - - return logos; - } catch (error) { - console.error('Failed to fetch channel logos:', error); - set({ - error: 'Failed to load channel logos.', - backgroundLoading: false, - }); - throw error; - } + return logos; }, fetchLogosByIds: async (logoIds) => { - if (!logoIds || logoIds.length === 0) return []; - try { // Filter out logos we already have const missingIds = logoIds.filter((id) => !get().logos[id]); From 46413b7e3ade1c4edbe11117e6c043db13c593d6 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Dec 2025 12:44:26 -0600 Subject: [PATCH 28/28] changelog: Update changelog for code refactoring and logo changes. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f46781f..01a2ee72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,19 @@ 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 - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) - 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