From 0dbc5221b2d602323de9fa938f07f1b1a4363126 Mon Sep 17 00:00:00 2001 From: BigPanda Date: Thu, 18 Sep 2025 21:20:47 +0100 Subject: [PATCH 01/80] Add 'UK' region I'm not sure if this was intentional, but the UK seems to be missing from the region list. --- frontend/src/constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 78f374d4..528c5f04 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -303,6 +303,7 @@ export const REGION_CHOICES = [ { value: 'tz', label: 'TZ' }, { value: 'ua', label: 'UA' }, { value: 'ug', label: 'UG' }, + { value: 'uk', label: 'UK' }, { value: 'um', label: 'UM' }, { value: 'us', label: 'US' }, { value: 'uy', label: 'UY' }, 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 02/80] 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 03/80] 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 04/80] 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 05/80] 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 06/80] 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 20/80] 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 21/80] 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 22/80] 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 23/80] 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 24/80] 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 25/80] 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 26/80] 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 27/80] 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 28/80] 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 29/80] 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 30/80] 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 31/80] 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 aa5db6c3f4e1dd74186ce36e90eea11ca51b2f94 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Mon, 22 Dec 2025 15:14:46 -0500 Subject: [PATCH 32/80] Squash: Log Parsing Refactor & Enhancing --- .../ts_proxy/services/channel_service.py | 205 ++------- apps/proxy/ts_proxy/services/log_parsers.py | 410 ++++++++++++++++++ apps/proxy/ts_proxy/stream_manager.py | 32 +- 3 files changed, 464 insertions(+), 183 deletions(-) create mode 100644 apps/proxy/ts_proxy/services/log_parsers.py diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index cea0d957..4c4a73ac 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -15,6 +15,7 @@ from ..redis_keys import RedisKeys from ..constants import EventType, ChannelState, ChannelMetadataField from ..url_utils import get_stream_info_for_switch from core.utils import log_system_event +from .log_parsers import LogParserFactory logger = logging.getLogger("ts_proxy") @@ -419,175 +420,51 @@ class ChannelService: @staticmethod def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None): - """Parse FFmpeg/VLC/streamlink stream info line and store in Redis metadata and database""" + """ + Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB. + Uses specialized parsers for each streaming tool. + """ try: - if stream_type == "input": - # Example lines: - # FFmpeg: Input #0, mpegts, from 'http://example.com/stream.ts': - # FFmpeg: Input #0, hls, from 'http://example.com/stream.m3u8': + # Use factory to parse the line based on stream type + parsed_data = LogParserFactory.parse(stream_type, stream_info_line) + + if not parsed_data: + return - # Extract input format (e.g., "mpegts", "hls", "flv", etc.) - input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line) - input_format = input_match.group(1).strip() if input_match else None + # Update Redis and database with parsed data + ChannelService._update_stream_info_in_redis( + channel_id, + parsed_data.get('video_codec'), + parsed_data.get('resolution'), + parsed_data.get('width'), + parsed_data.get('height'), + parsed_data.get('source_fps'), + parsed_data.get('pixel_format'), + parsed_data.get('video_bitrate'), + parsed_data.get('audio_codec'), + parsed_data.get('sample_rate'), + parsed_data.get('audio_channels'), + parsed_data.get('audio_bitrate'), + parsed_data.get('stream_type') + ) - # Store in Redis if we have valid data - if input_format: - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db(stream_id, stream_type=input_format) - - logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}") - - elif stream_type == "vlc": - # VLC parsing - extract codecs from TS demux output (no resolution/fps in stream-copy mode) - lower = stream_info_line.lower() - - # Video codec detection - video_codec_map = { - ('avc', 'h.264', 'type=0x1b'): "h264", - ('hevc', 'h.265', 'type=0x24'): "hevc", - ('mpeg-2', 'type=0x02'): "mpeg2video", - ('mpeg-4', 'type=0x10'): "mpeg4" - } - for patterns, codec in video_codec_map.items(): - if any(p in lower for p in patterns): - ChannelService._update_stream_info_in_redis(channel_id, codec, None, None, None, None, None, None, None, None, None, None, None) - if stream_id: - ChannelService._update_stream_stats_in_db(stream_id, video_codec=codec) - break - - # Audio codec detection - audio_codec_map = { - ('type=0xf', 'adts'): "aac", - ('type=0x03', 'type=0x04'): "mp3", - ('type=0x06', 'type=0x81'): "ac3", - ('type=0x0b', 'lpcm'): "pcm" - } - for patterns, codec in audio_codec_map.items(): - if any(p in lower for p in patterns): - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, codec, None, None, None, None) - if stream_id: - ChannelService._update_stream_stats_in_db(stream_id, audio_codec=codec) - break - - elif stream_type == "streamlink": - # Streamlink parsing - extract quality/resolution - quality_match = re.search(r'(\d+p|\d+x\d+)', stream_info_line) - if quality_match: - quality = quality_match.group(1) - if 'x' in quality: - resolution = quality - width, height = map(int, quality.split('x')) - else: - resolutions = { - '2160p': ('3840x2160', 3840, 2160), '1080p': ('1920x1080', 1920, 1080), - '720p': ('1280x720', 1280, 720), '480p': ('854x480', 854, 480), '360p': ('640x360', 640, 360) - } - resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080)) - - ChannelService._update_stream_info_in_redis(channel_id, "h264", resolution, width, height, None, "yuv420p", None, None, None, None, None, None) - if stream_id: - ChannelService._update_stream_stats_in_db(stream_id, video_codec="h264", resolution=resolution, pixel_format="yuv420p") - - elif stream_type == "video": - # Example line: - # Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn - - # Extract video codec (e.g., "h264", "mpeg2video", etc.) - codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line) - video_codec = codec_match.group(1) if codec_match else None - - # Extract resolution (e.g., "1280x720") - be more specific to avoid hex values - # Look for resolution patterns that are realistic video dimensions - resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line) - if resolution_match: - width = int(resolution_match.group(1)) - height = int(resolution_match.group(2)) - # Validate that these look like reasonable video dimensions - if 100 <= width <= 10000 and 100 <= height <= 10000: - resolution = f"{width}x{height}" - else: - width = height = resolution = None - else: - width = height = resolution = None - - # Extract source FPS (e.g., "29.97 fps") - fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', stream_info_line) - source_fps = float(fps_match.group(1)) if fps_match else None - - # Extract pixel format (e.g., "yuv420p") - pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', stream_info_line) - pixel_format = None - if pixel_format_match: - pf = pixel_format_match.group(1).strip() - # Clean up pixel format (remove extra info in parentheses) - if '(' in pf: - pf = pf.split('(')[0].strip() - pixel_format = pf - - # Extract bitrate if present (e.g., "2000 kb/s") - video_bitrate = None - bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line) - if bitrate_match: - video_bitrate = float(bitrate_match.group(1)) - - # Store in Redis if we have valid data - if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db( - stream_id, - video_codec=video_codec, - resolution=resolution, - source_fps=source_fps, - pixel_format=pixel_format, - video_bitrate=video_bitrate - ) - - logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, " - f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, " - f"Video Bitrate: {video_bitrate} kb/s") - - elif stream_type == "audio": - # Example line: - # Stream #0:1[0x101]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 64 kb/s - - # Extract audio codec (e.g., "aac", "mp3", etc.) - codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', stream_info_line) - audio_codec = codec_match.group(1) if codec_match else None - - # Extract sample rate (e.g., "48000 Hz") - sample_rate_match = re.search(r'(\d+)\s*Hz', stream_info_line) - sample_rate = int(sample_rate_match.group(1)) if sample_rate_match else None - - # Extract channel layout (e.g., "stereo", "5.1", "mono") - # Look for common channel layouts - channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', stream_info_line, re.IGNORECASE) - channels = channel_match.group(1) if channel_match else None - - # Extract audio bitrate if present (e.g., "64 kb/s") - audio_bitrate = None - bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line) - if bitrate_match: - audio_bitrate = float(bitrate_match.group(1)) - - # Store in Redis if we have valid data - if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db( - stream_id, - audio_codec=audio_codec, - sample_rate=sample_rate, - audio_channels=channels, - audio_bitrate=audio_bitrate - ) + if stream_id: + ChannelService._update_stream_stats_in_db( + stream_id, + video_codec=parsed_data.get('video_codec'), + resolution=parsed_data.get('resolution'), + source_fps=parsed_data.get('source_fps'), + pixel_format=parsed_data.get('pixel_format'), + video_bitrate=parsed_data.get('video_bitrate'), + audio_codec=parsed_data.get('audio_codec'), + sample_rate=parsed_data.get('sample_rate'), + audio_channels=parsed_data.get('audio_channels'), + audio_bitrate=parsed_data.get('audio_bitrate'), + stream_type=parsed_data.get('stream_type') + ) except Exception as e: - logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}") + logger.debug(f"Error parsing {stream_type} stream info: {e}") @staticmethod def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None): diff --git a/apps/proxy/ts_proxy/services/log_parsers.py b/apps/proxy/ts_proxy/services/log_parsers.py new file mode 100644 index 00000000..95ee7a06 --- /dev/null +++ b/apps/proxy/ts_proxy/services/log_parsers.py @@ -0,0 +1,410 @@ +"""Log parsers for FFmpeg, Streamlink, and VLC output.""" +import re +import logging +from abc import ABC, abstractmethod +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +class BaseLogParser(ABC): + """Base class for log parsers""" + + # Map of stream_type -> method_name that this parser handles + STREAM_TYPE_METHODS: Dict[str, str] = {} + + @abstractmethod + def can_parse(self, line: str) -> Optional[str]: + """ + Check if this parser can handle the line. + Returns the stream_type if it can parse, None otherwise. + e.g., 'video', 'audio', 'vlc_video', 'vlc_audio', 'streamlink' + """ + pass + + @abstractmethod + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + pass + + @abstractmethod + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + pass + + @abstractmethod + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + pass + + +class FFmpegLogParser(BaseLogParser): + """Parser for FFmpeg log output""" + + STREAM_TYPE_METHODS = { + 'input': 'parse_input_format', + 'video': 'parse_video_stream', + 'audio': 'parse_audio_stream' + } + + def can_parse(self, line: str) -> Optional[str]: + """Check if this is an FFmpeg line we can parse""" + lower = line.lower() + + # Input format detection + if lower.startswith('input #'): + return 'input' + + # Stream info (only during input phase, but we'll let stream_manager handle phase tracking) + if 'stream #' in lower: + if 'video:' in lower: + return 'video' + elif 'audio:' in lower: + return 'audio' + + return None + + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + """Parse FFmpeg input format (e.g., mpegts, hls)""" + try: + input_match = re.search(r'Input #\d+,\s*([^,]+)', line) + input_format = input_match.group(1).strip() if input_match else None + + if input_format: + logger.debug(f"Input format info - Format: {input_format}") + return {'stream_type': input_format} + except Exception as e: + logger.debug(f"Error parsing FFmpeg input format: {e}") + + return None + + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse FFmpeg video stream info""" + try: + result = {} + + # Extract codec, resolution, fps, pixel format, bitrate + codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', line) + if codec_match: + result['video_codec'] = codec_match.group(1) + + resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', line) + if resolution_match: + width = int(resolution_match.group(1)) + height = int(resolution_match.group(2)) + if 100 <= width <= 10000 and 100 <= height <= 10000: + result['resolution'] = f"{width}x{height}" + result['width'] = width + result['height'] = height + + fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', line) + if fps_match: + result['source_fps'] = float(fps_match.group(1)) + + pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', line) + if pixel_format_match: + pf = pixel_format_match.group(1).strip() + if '(' in pf: + pf = pf.split('(')[0].strip() + result['pixel_format'] = pf + + bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line) + if bitrate_match: + result['video_bitrate'] = float(bitrate_match.group(1)) + + if result: + logger.info(f"Video stream info - Codec: {result.get('video_codec')}, " + f"Resolution: {result.get('resolution')}, " + f"Source FPS: {result.get('source_fps')}, " + f"Pixel Format: {result.get('pixel_format')}, " + f"Video Bitrate: {result.get('video_bitrate')} kb/s") + return result + + except Exception as e: + logger.debug(f"Error parsing FFmpeg video stream info: {e}") + + return None + + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse FFmpeg audio stream info""" + try: + result = {} + + codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', line) + if codec_match: + result['audio_codec'] = codec_match.group(1) + + sample_rate_match = re.search(r'(\d+)\s*Hz', line) + if sample_rate_match: + result['sample_rate'] = int(sample_rate_match.group(1)) + + channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', line, re.IGNORECASE) + if channel_match: + result['audio_channels'] = channel_match.group(1) + + bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line) + if bitrate_match: + result['audio_bitrate'] = float(bitrate_match.group(1)) + + if result: + return result + + except Exception as e: + logger.debug(f"Error parsing FFmpeg audio stream info: {e}") + + return None + + +class VLCLogParser(BaseLogParser): + """Parser for VLC log output""" + + STREAM_TYPE_METHODS = { + 'vlc_video': 'parse_video_stream', + 'vlc_audio': 'parse_audio_stream' + } + + def can_parse(self, line: str) -> Optional[str]: + """Check if this is a VLC line we can parse""" + lower = line.lower() + + # VLC TS demux codec detection + if 'ts demux debug' in lower and 'type=' in lower: + if 'video' in lower: + return 'vlc_video' + elif 'audio' in lower: + return 'vlc_audio' + + # VLC decoder output + if 'decoder' in lower and ('channels:' in lower or 'samplerate:' in lower or 'x' in line or 'fps' in lower): + if 'audio' in lower or 'channels:' in lower or 'samplerate:' in lower: + return 'vlc_audio' + else: + return 'vlc_video' + + # VLC transcode output for resolution/FPS + if 'stream_out_transcode' in lower and ('source fps' in lower or ('source ' in lower and 'x' in line)): + return 'vlc_video' + + return None + + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + return None + + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse VLC TS demux output and decoder info for video""" + try: + lower = line.lower() + result = {} + + # Codec detection from TS demux + video_codec_map = { + ('avc', 'h.264', 'type=0x1b'): "h264", + ('hevc', 'h.265', 'type=0x24'): "hevc", + ('mpeg-2', 'type=0x02'): "mpeg2video", + ('mpeg-4', 'type=0x10'): "mpeg4" + } + + for patterns, codec in video_codec_map.items(): + if any(p in lower for p in patterns): + result['video_codec'] = codec + break + + # Extract FPS from transcode output: "source fps 30/1" + fps_fraction_match = re.search(r'source fps\s+(\d+)/(\d+)', lower) + if fps_fraction_match: + numerator = int(fps_fraction_match.group(1)) + denominator = int(fps_fraction_match.group(2)) + if denominator > 0: + result['source_fps'] = numerator / denominator + + # Extract resolution from transcode output: "source 1280x720" + source_res_match = re.search(r'source\s+(\d{3,4})x(\d{3,4})', lower) + if source_res_match: + width = int(source_res_match.group(1)) + height = int(source_res_match.group(2)) + if 100 <= width <= 10000 and 100 <= height <= 10000: + result['resolution'] = f"{width}x{height}" + result['width'] = width + result['height'] = height + else: + # Fallback: generic resolution pattern + resolution_match = re.search(r'(\d{3,4})x(\d{3,4})', line) + if resolution_match: + width = int(resolution_match.group(1)) + height = int(resolution_match.group(2)) + if 100 <= width <= 10000 and 100 <= height <= 10000: + result['resolution'] = f"{width}x{height}" + result['width'] = width + result['height'] = height + + # Fallback: try to extract FPS from generic format + if 'source_fps' not in result: + fps_match = re.search(r'(\d+\.?\d*)\s*fps', lower) + if fps_match: + result['source_fps'] = float(fps_match.group(1)) + + return result if result else None + + except Exception as e: + logger.debug(f"Error parsing VLC video stream info: {e}") + + return None + + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse VLC TS demux output and decoder info for audio""" + try: + lower = line.lower() + result = {} + + # Codec detection from TS demux + audio_codec_map = { + ('type=0xf', 'adts'): "aac", + ('type=0x03', 'type=0x04'): "mp3", + ('type=0x06', 'type=0x81'): "ac3", + ('type=0x0b', 'lpcm'): "pcm" + } + + for patterns, codec in audio_codec_map.items(): + if any(p in lower for p in patterns): + result['audio_codec'] = codec + break + + # VLC decoder format: "AAC channels: 2 samplerate: 48000" + if 'channels:' in lower: + channels_match = re.search(r'channels:\s*(\d+)', lower) + if channels_match: + num_channels = int(channels_match.group(1)) + # Convert number to name + channel_names = {1: 'mono', 2: 'stereo', 6: '5.1', 8: '7.1'} + result['audio_channels'] = channel_names.get(num_channels, str(num_channels)) + + if 'samplerate:' in lower: + samplerate_match = re.search(r'samplerate:\s*(\d+)', lower) + if samplerate_match: + result['sample_rate'] = int(samplerate_match.group(1)) + + # Try to extract sample rate (Hz format) + sample_rate_match = re.search(r'(\d+)\s*hz', lower) + if sample_rate_match and 'sample_rate' not in result: + result['sample_rate'] = int(sample_rate_match.group(1)) + + # Try to extract channels (word format) + if 'audio_channels' not in result: + channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', lower) + if channel_match: + result['audio_channels'] = channel_match.group(1) + + return result if result else None + + except Exception as e: + logger.error(f"[VLC AUDIO PARSER] Error parsing VLC audio stream info: {e}") + + return None + + +class StreamlinkLogParser(BaseLogParser): + """Parser for Streamlink log output""" + + STREAM_TYPE_METHODS = { + 'streamlink': 'parse_video_stream' + } + + def can_parse(self, line: str) -> Optional[str]: + """Check if this is a Streamlink line we can parse""" + lower = line.lower() + + if 'opening stream:' in lower or 'available streams:' in lower: + return 'streamlink' + + return None + + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + return None + + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse Streamlink quality/resolution""" + try: + quality_match = re.search(r'(\d+p|\d+x\d+)', line) + if quality_match: + quality = quality_match.group(1) + + if 'x' in quality: + resolution = quality + width, height = map(int, quality.split('x')) + else: + resolutions = { + '2160p': ('3840x2160', 3840, 2160), + '1080p': ('1920x1080', 1920, 1080), + '720p': ('1280x720', 1280, 720), + '480p': ('854x480', 854, 480), + '360p': ('640x360', 640, 360) + } + resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080)) + + return { + 'video_codec': 'h264', + 'resolution': resolution, + 'width': width, + 'height': height, + 'pixel_format': 'yuv420p' + } + + except Exception as e: + logger.debug(f"Error parsing Streamlink video info: {e}") + + return None + + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + return None + + +class LogParserFactory: + """Factory to get the appropriate log parser""" + + _parsers = { + 'ffmpeg': FFmpegLogParser(), + 'vlc': VLCLogParser(), + 'streamlink': StreamlinkLogParser() + } + + @classmethod + def _get_parser_and_method(cls, stream_type: str) -> Optional[tuple[BaseLogParser, str]]: + """Determine parser and method from stream_type""" + # Check each parser to see if it handles this stream_type + for parser in cls._parsers.values(): + method_name = parser.STREAM_TYPE_METHODS.get(stream_type) + if method_name: + return (parser, method_name) + + return None + + @classmethod + def parse(cls, stream_type: str, line: str) -> Optional[Dict[str, Any]]: + """ + Parse a log line based on stream type. + Returns parsed data or None if parsing fails. + """ + result = cls._get_parser_and_method(stream_type) + if not result: + return None + + parser, method_name = result + method = getattr(parser, method_name, None) + if method: + return method(line) + + return None + + @classmethod + def auto_parse(cls, line: str) -> Optional[tuple[str, Dict[str, Any]]]: + """ + Automatically detect which parser can handle this line and parse it. + Returns (stream_type, parsed_data) or None if no parser can handle it. + """ + # Try each parser to see if it can handle this line + for parser in cls._parsers.values(): + stream_type = parser.can_parse(line) + if stream_type: + # Parser can handle this line, now parse it + parsed_data = cls.parse(stream_type, line) + if parsed_data: + return (stream_type, parsed_data) + + return None diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index 9b0d9ada..da840f2d 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -645,26 +645,20 @@ class StreamManager: if content_lower.startswith('output #') or 'encoder' in content_lower: self.ffmpeg_input_phase = False - # Parse VLC-specific output - look for TS demux type info for codec detection - if 'ts demux debug' in content_lower and 'type=' in content_lower and ('video' in content_lower or 'audio' in content_lower): - from .services.channel_service import ChannelService - ChannelService.parse_and_store_stream_info(self.channel_id, content, "vlc", self.current_stream_id) + # Try to auto-parse with any available parser + from .services.log_parsers import LogParserFactory + from .services.channel_service import ChannelService - # Parse streamlink-specific output - if 'opening stream:' in content_lower or 'available streams:' in content_lower: - from .services.channel_service import ChannelService - ChannelService.parse_and_store_stream_info(self.channel_id, content, "streamlink", self.current_stream_id) - - # Only parse stream info if we're still in the input phase - if ("stream #" in content_lower and - ("video:" in content_lower or "audio:" in content_lower) and - self.ffmpeg_input_phase): - - from .services.channel_service import ChannelService - if "video:" in content_lower: - ChannelService.parse_and_store_stream_info(self.channel_id, content, "video", self.current_stream_id) - elif "audio:" in content_lower: - ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id) + parse_result = LogParserFactory.auto_parse(content) + if parse_result: + stream_type, parsed_data = parse_result + # For FFmpeg, only parse during input phase + if stream_type in ['video', 'audio', 'input']: + if self.ffmpeg_input_phase: + ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id) + else: + # VLC and Streamlink can be parsed anytime + ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id) # Determine log level based on content if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']): From eea84cfd8b8e9ccd69942f2a5f27536b88a2f8bd Mon Sep 17 00:00:00 2001 From: drnikcuk Date: Mon, 22 Dec 2025 23:33:26 +0000 Subject: [PATCH 33/80] Update Stats.jsx (#773) * Update Stats.jsx Adds fix for stats control arrows direction swap --- frontend/src/pages/Stats.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index e7e3043a..8ec576a8 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -481,8 +481,8 @@ const VODCard = ({ vodContent, stopVODClient }) => { size={16} style={{ transform: isClientExpanded - ? 'rotate(180deg)' - : 'rotate(0deg)', + ? 'rotate(0deg)' + : 'rotate(180deg)', transition: 'transform 0.2s', }} /> From 106ea72c9ddc5d1a62d7f9d9850ff595d9cd3796 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 22 Dec 2025 17:38:55 -0600 Subject: [PATCH 34/80] Changelog: Fix event viewer arrow direction for corrected UI behavior --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb610fa..2e2e9003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) + ## [0.15.1] - 2025-12-22 ### Fixed From 904500906ca0f5d843225a3751aa3bf40c3b47d3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 09:51:02 -0600 Subject: [PATCH 35/80] Bug Fix: Update stream validation to return original URL instead of redirected URL when using redirect profile. --- CHANGELOG.md | 4 ++++ apps/proxy/ts_proxy/url_utils.py | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2e9003..a36db70a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) +### Fixed + +- Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect + ## [0.15.1] - 2025-12-22 ### Fixed diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 3b05c9f2..2afe2871 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -471,7 +471,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): # If HEAD not supported, server will return 405 or other error if 200 <= head_response.status_code < 300: # HEAD request successful - return True, head_response.url, head_response.status_code, "Valid (HEAD request)" + return True, url, head_response.status_code, "Valid (HEAD request)" # Try a GET request with stream=True to avoid downloading all content get_response = session.get( @@ -484,7 +484,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): # IMPORTANT: Check status code first before checking content if not (200 <= get_response.status_code < 300): logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}") - return False, get_response.url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" + return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" # Only check content if status code is valid try: @@ -538,7 +538,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): get_response.close() # If we have content, consider it valid even with unrecognized content type - return is_valid, get_response.url, get_response.status_code, message + return is_valid, url, get_response.status_code, message except requests.exceptions.Timeout: return False, url, 0, "Timeout connecting to stream" 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 36/80] 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 ff7298a93e77f76f250908fb7c2db431a903789d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 15:07:25 -0600 Subject: [PATCH 37/80] Enhance StreamManager for efficient log parsing and update VLC stream profile naming --- apps/proxy/ts_proxy/stream_manager.py | 41 +++++++++++++++++-- core/fixtures/initial_data.json | 6 +-- .../migrations/0019_add_vlc_stream_profile.py | 10 ++--- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index da840f2d..cbaa0bc0 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -107,6 +107,10 @@ class StreamManager: # Add this flag for tracking transcoding process status self.transcode_process_active = False + # Track stream command for efficient log parser routing + self.stream_command = None + self.parser_type = None # Will be set when transcode process starts + # Add tracking for data throughput self.bytes_processed = 0 self.last_bytes_update = time.time() @@ -476,6 +480,21 @@ class StreamManager: # Build and start transcode command self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent) + # Store stream command for efficient log parser routing + self.stream_command = stream_profile.command + # Map actual commands to parser types for direct routing + command_to_parser = { + 'ffmpeg': 'ffmpeg', + 'cvlc': 'vlc', + 'vlc': 'vlc', + 'streamlink': 'streamlink' + } + self.parser_type = command_to_parser.get(self.stream_command.lower()) + if self.parser_type: + logger.debug(f"Using {self.parser_type} parser for log parsing (command: {self.stream_command})") + else: + logger.debug(f"Unknown stream command '{self.stream_command}', will use auto-detection for log parsing") + # For UDP streams, remove any user_agent parameters from the command if hasattr(self, 'stream_type') and self.stream_type == StreamType.UDP: # Filter out any arguments that contain the user_agent value or related headers @@ -645,11 +664,27 @@ class StreamManager: if content_lower.startswith('output #') or 'encoder' in content_lower: self.ffmpeg_input_phase = False - # Try to auto-parse with any available parser + # Route to appropriate parser based on known command type from .services.log_parsers import LogParserFactory from .services.channel_service import ChannelService - - parse_result = LogParserFactory.auto_parse(content) + + parse_result = None + + # If we know the parser type, use direct routing for efficiency + if self.parser_type: + # Get the appropriate parser and check what it can parse + parser = LogParserFactory._parsers.get(self.parser_type) + if parser: + stream_type = parser.can_parse(content) + if stream_type: + # Parser can handle this line, parse it directly + parsed_data = LogParserFactory.parse(stream_type, content) + if parsed_data: + parse_result = (stream_type, parsed_data) + else: + # Unknown command type - use auto-detection as fallback + parse_result = LogParserFactory.auto_parse(content) + if parse_result: stream_type, parsed_data = parse_result # For FFmpeg, only parse during input phase diff --git a/core/fixtures/initial_data.json b/core/fixtures/initial_data.json index 49ecf080..889f0d24 100644 --- a/core/fixtures/initial_data.json +++ b/core/fixtures/initial_data.json @@ -23,7 +23,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "name": "ffmpeg", + "name": "FFmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", "is_active": true, @@ -34,7 +34,7 @@ "model": "core.streamprofile", "pk": 2, "fields": { - "name": "streamlink", + "name": "Streamlink", "command": "streamlink", "parameters": "{streamUrl} best --stdout", "is_active": true, @@ -45,7 +45,7 @@ "model": "core.streamprofile", "pk": 3, "fields": { - "name": "vlc", + "name": "VLC", "command": "cvlc", "parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", "is_active": true, diff --git a/core/migrations/0019_add_vlc_stream_profile.py b/core/migrations/0019_add_vlc_stream_profile.py index 5d794647..c3f72592 100644 --- a/core/migrations/0019_add_vlc_stream_profile.py +++ b/core/migrations/0019_add_vlc_stream_profile.py @@ -5,9 +5,9 @@ from django.db import migrations def add_vlc_profile(apps, schema_editor): StreamProfile = apps.get_model("core", "StreamProfile") UserAgent = apps.get_model("core", "UserAgent") - + # Check if VLC profile already exists - if not StreamProfile.objects.filter(name="vlc").exists(): + if not StreamProfile.objects.filter(name="VLC").exists(): # Get the TiviMate user agent (should be pk=1) try: tivimate_ua = UserAgent.objects.get(pk=1) @@ -17,9 +17,9 @@ def add_vlc_profile(apps, schema_editor): if not tivimate_ua: # No user agents exist, skip creating profile return - + StreamProfile.objects.create( - name="vlc", + name="VLC", command="cvlc", parameters="-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", is_active=True, @@ -29,7 +29,7 @@ def add_vlc_profile(apps, schema_editor): def remove_vlc_profile(apps, schema_editor): StreamProfile = apps.get_model("core", "StreamProfile") - StreamProfile.objects.filter(name="vlc").delete() + StreamProfile.objects.filter(name="VLC").delete() class Migration(migrations.Migration): From 8f811f2ed3f9d9d9194e53fd7f8e8b8d4f524557 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 15:17:50 -0600 Subject: [PATCH 38/80] Correct profile name casing for FFmpeg, Streamlink, and VLC in fixtures.json --- fixtures.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fixtures.json b/fixtures.json index c0f5e0ea..3c31f926 100644 --- a/fixtures.json +++ b/fixtures.json @@ -36,7 +36,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "profile_name": "ffmpeg", + "profile_name": "FFmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:a copy -c:v copy -f mpegts pipe:1", "is_active": true, @@ -46,7 +46,7 @@ { "model": "core.streamprofile", "fields": { - "profile_name": "streamlink", + "profile_name": "Streamlink", "command": "streamlink", "parameters": "{streamUrl} best --stdout", "is_active": true, @@ -56,7 +56,7 @@ { "model": "core.streamprofile", "fields": { - "profile_name": "vlc", + "profile_name": "VLC", "command": "cvlc", "parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", "is_active": true, From daa919c76472d0a54522e67810e9cab4f021510a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 15:52:56 -0600 Subject: [PATCH 39/80] Refactor logging messages in StreamManager for clarity and consistency. Also removed redundant parsing. --- apps/proxy/ts_proxy/stream_manager.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index cbaa0bc0..e7f752d8 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -697,22 +697,18 @@ class StreamManager: # Determine log level based on content if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']): - logger.error(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.error(f"Stream process error for channel {self.channel_id}: {content}") elif any(keyword in content_lower for keyword in ['warning', 'deprecated', 'ignoring']): - logger.warning(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.warning(f"Stream process warning for channel {self.channel_id}: {content}") elif content.startswith('frame=') or 'fps=' in content or 'speed=' in content: # Stats lines - log at trace level to avoid spam - logger.trace(f"FFmpeg stats for channel {self.channel_id}: {content}") + logger.trace(f"Stream stats for channel {self.channel_id}: {content}") elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']): # Stream info - log at info level - logger.info(f"FFmpeg info for channel {self.channel_id}: {content}") - if content.startswith('Input #0'): - # If it's input 0, parse stream info - from .services.channel_service import ChannelService - ChannelService.parse_and_store_stream_info(self.channel_id, content, "input", self.current_stream_id) + logger.info(f"Stream info for channel {self.channel_id}: {content}") else: # Everything else at debug level - logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.debug(f"Stream process output for channel {self.channel_id}: {content}") except Exception as e: logger.error(f"Error logging stderr content for channel {self.channel_id}: {e}") From 48ebaffaddfbdeb1a10cf73d5c30c7e8a6ee7674 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 17:04:09 -0600 Subject: [PATCH 40/80] Cleanup dockerfile a bit. --- docker/DispatcharrBase | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 786ead1a..8bda1ed9 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -16,7 +16,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ libpcre3 libpcre3-dev libpq-dev procps \ build-essential gcc pciutils \ nginx streamlink comskip \ - vlc-bin vlc-plugin-base vlc-plugin-access-extra \ + vlc-bin vlc-plugin-base \ && apt-get clean && rm -rf /var/lib/apt/lists/* # --- Create Python virtual environment --- @@ -26,11 +26,6 @@ RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pi COPY requirements.txt /tmp/requirements.txt RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt -# --- Configure VLC for headless operation --- -# Set VLC environment variables for headless operation -ENV PULSE_SERVER=none \ - DBUS_SESSION_BUS_ADDRESS=/dev/null - # --- Set up Redis 7.x --- RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | \ From 44a122924fb98ca467d6c159d9708b0d187b40c8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 17:37:38 -0600 Subject: [PATCH 41/80] advanced filtering for hiding disabled channels and viewing only empty channels (cherry picked from commit ea38c0b4b88bac1d89c186f4d17cd9f1dde0ef6d) Closes #182 --- CHANGELOG.md | 4 ++ apps/channels/api_views.py | 33 ++++++++++- .../src/components/tables/ChannelsTable.jsx | 25 ++++++++- .../ChannelsTable/ChannelTableHeader.jsx | 55 ++++++++++++++++++- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a36db70a..99784402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Advanced filtering for Channels table: Filter menu now allows toggling disabled channels visibility (when a profile is selected) and filtering to show only empty channels without streams (Closes #182) + ### Changed - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 1f98358e..aebb74a3 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,6 +8,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction +from django.db.models import Q import os, json, requests, logging from urllib.parse import unquote from apps.accounts.permissions import ( @@ -420,10 +421,36 @@ class ChannelViewSet(viewsets.ModelViewSet): group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) - if self.request.user.user_level < 10: - qs = qs.filter(user_level__lte=self.request.user.user_level) + filters = {} + q_filters = Q() - return qs + channel_profile_id = self.request.query_params.get("channel_profile_id") + show_disabled_param = self.request.query_params.get("show_disabled", None) + only_streamless = self.request.query_params.get("only_streamless", None) + + if channel_profile_id: + try: + profile_id_int = int(channel_profile_id) + filters["channelprofilemembership__channel_profile_id"] = profile_id_int + + if show_disabled_param is None: + filters["channelprofilemembership__enabled"] = True + except (ValueError, TypeError): + # Ignore invalid profile id values + pass + + if only_streamless: + q_filters &= Q(streams__isnull=True) + + if self.request.user.user_level < 10: + filters["user_level__lte"] = self.request.user.user_level + + if filters: + qs = qs.filter(**filters) + if q_filters: + qs = qs.filter(q_filters) + + return qs.distinct() def get_serializer_context(self): context = super().get_serializer_context() diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 9b9958f7..ee57dabf 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -289,6 +289,9 @@ const ChannelsTable = ({}) => { const [selectedProfile, setSelectedProfile] = useState( profiles[selectedProfileId] ); + const [showDisabled, setShowDisabled] = useState(true); + const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] = + useState(false); const [paginationString, setPaginationString] = useState(''); const [filters, setFilters] = useState({ @@ -369,6 +372,15 @@ const ChannelsTable = ({}) => { params.append('page', pagination.pageIndex + 1); params.append('page_size', pagination.pageSize); params.append('include_streams', 'true'); + if (selectedProfileId !== '0') { + params.append('channel_profile_id', selectedProfileId); + } + if (showDisabled === true) { + params.append('show_disabled', true); + } + if (showOnlyStreamlessChannels === true) { + params.append('only_streamless', true); + } // Apply sorting if (sorting.length > 0) { @@ -401,7 +413,14 @@ const ChannelsTable = ({}) => { pageSize: pagination.pageSize, }); setAllRowIds(ids); - }, [pagination, sorting, debouncedFilters]); + }, [ + pagination, + sorting, + debouncedFilters, + showDisabled, + selectedProfileId, + showOnlyStreamlessChannels, + ]); const stopPropagation = useCallback((e) => { e.stopPropagation(); @@ -1326,6 +1345,10 @@ const ChannelsTable = ({}) => { deleteChannels={deleteChannels} selectedTableIds={table.selectedTableIds} table={table} + showDisabled={showDisabled} + setShowDisabled={setShowDisabled} + showOnlyStreamlessChannels={showOnlyStreamlessChannels} + setShowOnlyStreamlessChannels={setShowOnlyStreamlessChannels} /> {/* Table or ghost empty state inside Paper */} diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index b7e04d7d..460ab12a 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -12,20 +12,22 @@ import { Text, TextInput, Tooltip, - UnstyledButton, useMantineTheme, } from '@mantine/core'; import { ArrowDown01, Binary, - Check, CircleCheck, - Ellipsis, EllipsisVertical, SquareMinus, SquarePen, SquarePlus, Settings, + Eye, + EyeOff, + Filter, + Square, + SquareCheck, } from 'lucide-react'; import API from '../../../api'; import { notifications } from '@mantine/notifications'; @@ -102,6 +104,10 @@ const ChannelTableHeader = ({ editChannel, deleteChannels, selectedTableIds, + showDisabled, + setShowDisabled, + showOnlyStreamlessChannels, + setShowOnlyStreamlessChannels, }) => { const theme = useMantineTheme(); @@ -208,6 +214,14 @@ const ChannelTableHeader = ({ ); }; + const toggleShowDisabled = () => { + setShowDisabled(!showDisabled); + }; + + const toggleShowOnlyStreamlessChannels = () => { + setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels); + }; + return ( @@ -236,6 +250,41 @@ const ChannelTableHeader = ({ }} > + + + + + + + : + } + disabled={selectedProfileId === '0'} + > + + {showDisabled ? 'Hide Disabled' : 'Show Disabled'} + + + + + ) : ( + + ) + } + > + Only Empty Channels + + + + @@ -1477,34 +1473,34 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Guide container with headers and scrollable content */} {/* Logo header - Sticky, non-scrollable */} {/* Logo header cell - sticky in both directions */} {/* Timeline header with its own scrollbar */} @@ -1512,26 +1508,26 @@ export default function TVChannelGuide({ startDate, endDate }) { style={{ flex: 1, overflow: 'hidden', - position: 'relative', }} + pos={'relative'} > {' '} {hourTimeline.map((hourData) => { @@ -1541,15 +1537,15 @@ export default function TVChannelGuide({ startDate, endDate }) { handleTimeClick(time, e)} > {/* Remove the special day label for new days since we'll show day for all hours */} @@ -1558,25 +1554,23 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Show day above time for every hour using the same format */} {formatDayLabel(time)}{' '} {/* Use same formatDayLabel function for all hours */} @@ -1590,38 +1584,38 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Hour boundary marker - more visible */} {/* Quarter hour tick marks */} {[15, 30, 45].map((minute) => ( ))} @@ -1638,22 +1632,22 @@ export default function TVChannelGuide({ startDate, endDate }) { ref={guideContainerRef} style={{ flex: 1, - position: 'relative', overflow: 'hidden', }} + pos={'relative'} > {nowPosition >= 0 && ( )} @@ -1674,13 +1668,7 @@ export default function TVChannelGuide({ startDate, endDate }) { {GuideRow} ) : ( - + No channels match your filters + + + + + + {recording && ( + <> + + + + )} + + {existingRuleMode && ( + + )} + + + ); +} diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx new file mode 100644 index 00000000..1c10e4bd --- /dev/null +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import useChannelsStore from '../../store/channels.jsx'; +import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js'; +import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js'; + +export default function SeriesRecordingModal({ + opened, + onClose, + rules, + onRulesUpdate +}) { + const handleEvaluateNow = async (r) => { + await evaluateSeriesRulesByTvgId(r.tvg_id); + try { + await useChannelsStore.getState().fetchRecordings(); + } catch (error) { + console.warn('Failed to refresh recordings after evaluation', error); + } + notifications.show({ + title: 'Evaluated', + message: 'Checked for episodes', + }); + }; + + const handleRemoveSeries = async (r) => { + await deleteSeriesAndRule({ tvg_id: r.tvg_id, title: r.title }); + try { + await useChannelsStore.getState().fetchRecordings(); + } catch (error) { + console.warn('Failed to refresh recordings after bulk removal', error); + } + const updated = await fetchRules(); + onRulesUpdate(updated); + }; + + return ( + + + {(!rules || rules.length === 0) && ( + + No series rules configured + + )} + {rules && rules.map((r) => ( + + + {r.title || r.tvg_id} —{' '} + {r.mode === 'new' ? 'New episodes' : 'Every episode'} + + + + + + + ))} + + + ); +} diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 4a4ee71e..a382fffe 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -6,241 +6,80 @@ import React, { useRef, useCallback, } from 'react'; -import dayjs from 'dayjs'; -import API from '../api'; import useChannelsStore from '../store/channels'; import useLogosStore from '../store/logos'; -import logo from '../images/logo.png'; import useVideoStore from '../store/useVideoStore'; // NEW import import { notifications } from '@mantine/notifications'; import useSettingsStore from '../store/settings'; import { - Title, - Box, - Flex, - Button, - Text, - Paper, - Group, - TextInput, - Select, ActionIcon, + Box, + Button, + Flex, + Group, + Paper, + Select, + Text, + TextInput, + Title, Tooltip, - Transition, - Modal, - Stack, } from '@mantine/core'; -import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react'; +import { Calendar, Clock, Search, Video, X } from 'lucide-react'; import './guide.css'; import useEPGsStore from '../store/epgs'; -import useLocalStorage from '../hooks/useLocalStorage'; import { useElementSize } from '@mantine/hooks'; import { VariableSizeList } from 'react-window'; import { - PROGRAM_HEIGHT, - EXPANDED_PROGRAM_HEIGHT, buildChannelIdMap, - mapProgramsByChannel, + calculateDesiredScrollPosition, + calculateEarliestProgramStart, + calculateEnd, + calculateHourTimeline, + calculateLatestProgramEnd, + calculateLeftScrollPosition, + calculateNowPosition, + calculateScrollPosition, + calculateScrollPositionByTimeClick, + calculateStart, + CHANNEL_WIDTH, computeRowHeights, + createRecording, + createSeriesRule, + evaluateSeriesRule, + EXPANDED_PROGRAM_HEIGHT, + fetchPrograms, + fetchRules, + filterGuideChannels, + formatTime, + getGroupOptions, + getProfileOptions, + getRuleByProgram, + HOUR_WIDTH, + mapChannelsById, + mapProgramsByChannel, + mapRecordingsByProgramId, + matchChannelByTvgId, + MINUTE_BLOCK_WIDTH, + MINUTE_INCREMENT, + PROGRAM_HEIGHT, + sortChannels, } from './guideUtils'; - -/** Layout constants */ -const CHANNEL_WIDTH = 120; // Width of the channel/logo column -const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider -const MINUTE_INCREMENT = 15; // For positioning programs every 15 min -const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT); - -const GuideRow = React.memo(({ index, style, data }) => { - const { - filteredChannels, - programsByChannelId, - expandedProgramId, - rowHeights, - logos, - hoveredChannelId, - setHoveredChannelId, - renderProgram, - handleLogoClick, - contentWidth, - } = data; - - const channel = filteredChannels[index]; - if (!channel) { - return null; - } - - const channelPrograms = programsByChannelId.get(channel.id) || []; - const rowHeight = - rowHeights[index] ?? - (channelPrograms.some((program) => program.id === expandedProgramId) - ? EXPANDED_PROGRAM_HEIGHT - : PROGRAM_HEIGHT); - - return ( -
- - handleLogoClick(channel, event)} - onMouseEnter={() => setHoveredChannelId(channel.id)} - onMouseLeave={() => setHoveredChannelId(null)} - > - {hoveredChannelId === channel.id && ( - - - - )} - - - - {channel.name} - - - - {channel.channel_number || '-'} - - - - - - {channelPrograms.length > 0 ? ( - channelPrograms.map((program) => - renderProgram(program, undefined, channel) - ) - ) : ( - <> - {Array.from({ length: Math.ceil(24 / 2) }).map( - (_, placeholderIndex) => ( - - No program data - - ) - )} - - )} - - -
- ); -}); +import { + getShowVideoUrl, +} from '../utils/cards/RecordingCardUtils.js'; +import { + add, + convertToMs, + format, + getNow, + initializeTime, + startOfDay, + useDateTimeFormat, +} from '../utils/dateTimeUtils.js'; +import GuideRow from '../components/GuideRow.jsx'; +import HourTimeline from '../components/HourTimeline'; +import ProgramRecordingModal from '../components/forms/ProgramRecordingModal'; +import SeriesRecordingModal from '../components/forms/SeriesRecordingModal'; export default function TVChannelGuide({ startDate, endDate }) { const channels = useChannelsStore((s) => s.channels); @@ -254,8 +93,7 @@ export default function TVChannelGuide({ startDate, endDate }) { const [programs, setPrograms] = useState([]); const [guideChannels, setGuideChannels] = useState([]); - const [filteredChannels, setFilteredChannels] = useState([]); - const [now, setNow] = useState(dayjs()); + const [now, setNow] = useState(getNow()); const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program const [recordingForProgram, setRecordingForProgram] = useState(null); const [recordChoiceOpen, setRecordChoiceOpen] = useState(false); @@ -290,81 +128,29 @@ export default function TVChannelGuide({ startDate, endDate }) { // Load program data once useEffect(() => { - if (!Object.keys(channels).length === 0) { + if (Object.keys(channels).length === 0) { console.warn('No channels provided or empty channels array'); notifications.show({ title: 'No channels available', color: 'red.5' }); return; } - const fetchPrograms = async () => { - console.log('Fetching program grid...'); - const fetched = await API.getGrid(); // GETs your EPG grid - console.log(`Received ${fetched.length} programs`); + const sortedChannels = sortChannels(channels); - // Include ALL channels, sorted by channel number - don't filter by EPG data - const sortedChannels = Object.values(channels).sort( - (a, b) => - (a.channel_number || Infinity) - (b.channel_number || Infinity) - ); - - console.log(`Using all ${sortedChannels.length} available channels`); - - const processedPrograms = fetched.map((program) => { - const start = dayjs(program.start_time); - const end = dayjs(program.end_time); - return { - ...program, - startMs: start.valueOf(), - endMs: end.valueOf(), - }; - }); - - setGuideChannels(sortedChannels); - setFilteredChannels(sortedChannels); // Initialize filtered channels - setPrograms(processedPrograms); - }; - - fetchPrograms(); + setGuideChannels(sortedChannels); + fetchPrograms().then((data) => setPrograms(data)); }, [channels]); // Apply filters when search, group, or profile changes - useEffect(() => { - if (!guideChannels.length) return; + const filteredChannels = useMemo(() => { + if (!guideChannels.length) return []; - let result = [...guideChannels]; - - // Apply search filter - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter((channel) => - channel.name.toLowerCase().includes(query) - ); - } - - // Apply channel group filter - if (selectedGroupId !== 'all') { - result = result.filter( - (channel) => channel.channel_group_id === parseInt(selectedGroupId) - ); - } - - // Apply profile filter - if (selectedProfileId !== 'all') { - // Get the profile's enabled channels - const profileChannels = profiles[selectedProfileId]?.channels || []; - // Check if channels is a Set (from the error message, it likely is) - const enabledChannelIds = Array.isArray(profileChannels) - ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id) - : profiles[selectedProfileId]?.channels instanceof Set - ? Array.from(profiles[selectedProfileId].channels) - : []; - - result = result.filter((channel) => - enabledChannelIds.includes(channel.id) - ); - } - - setFilteredChannels(result); + return filterGuideChannels( + guideChannels, + searchQuery, + selectedGroupId, + selectedProfileId, + profiles + ); }, [ searchQuery, selectedGroupId, @@ -374,61 +160,44 @@ export default function TVChannelGuide({ startDate, endDate }) { ]); // Use start/end from props or default to "today at midnight" +24h - const defaultStart = dayjs(startDate || dayjs().startOf('day')); - const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour'); + const defaultStart = initializeTime(startDate || startOfDay(getNow())); + const defaultEnd = endDate + ? initializeTime(endDate) + : add(defaultStart, 24, 'hour'); // Expand timeline if needed based on actual earliest/ latest program - const earliestProgramStart = useMemo(() => { - if (!programs.length) return defaultStart; - return programs.reduce((acc, p) => { - const s = dayjs(p.start_time); - return s.isBefore(acc) ? s : acc; - }, defaultStart); - }, [programs, defaultStart]); + const earliestProgramStart = useMemo( + () => calculateEarliestProgramStart(programs, defaultStart), + [programs, defaultStart] + ); - const latestProgramEnd = useMemo(() => { - if (!programs.length) return defaultEnd; - return programs.reduce((acc, p) => { - const e = dayjs(p.end_time); - return e.isAfter(acc) ? e : acc; - }, defaultEnd); - }, [programs, defaultEnd]); + const latestProgramEnd = useMemo( + () => calculateLatestProgramEnd(programs, defaultEnd), + [programs, defaultEnd] + ); - const start = earliestProgramStart.isBefore(defaultStart) - ? earliestProgramStart - : defaultStart; - const end = latestProgramEnd.isAfter(defaultEnd) - ? latestProgramEnd - : defaultEnd; + const start = calculateStart(earliestProgramStart, defaultStart); + const end = calculateEnd(latestProgramEnd, defaultEnd); const channelIdByTvgId = useMemo( () => buildChannelIdMap(guideChannels, tvgsById, epgs), [guideChannels, tvgsById, epgs] ); - const channelById = useMemo(() => { - const map = new Map(); - guideChannels.forEach((channel) => { - map.set(channel.id, channel); - }); - return map; - }, [guideChannels]); + const channelById = useMemo( + () => mapChannelsById(guideChannels), + [guideChannels] + ); const programsByChannelId = useMemo( () => mapProgramsByChannel(programs, channelIdByTvgId), [programs, channelIdByTvgId] ); - const recordingsByProgramId = useMemo(() => { - const map = new Map(); - (recordings || []).forEach((recording) => { - const programId = recording?.custom_properties?.program?.id; - if (programId != null) { - map.set(programId, recording); - } - }); - return map; - }, [recordings]); + const recordingsByProgramId = useMemo( + () => mapRecordingsByProgramId(recordings), + [recordings] + ); const rowHeights = useMemo( () => @@ -445,62 +214,19 @@ export default function TVChannelGuide({ startDate, endDate }) { [rowHeights] ); - const [timeFormatSetting] = useLocalStorage('time-format', '12h'); - const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - // Use user preference for time format - const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm'; - const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM'; + const [timeFormat, dateFormat] = useDateTimeFormat(); // Format day label using relative terms when possible (Today, Tomorrow, etc) const formatDayLabel = useCallback( - (time) => { - const today = dayjs().startOf('day'); - const tomorrow = today.add(1, 'day'); - const weekLater = today.add(7, 'day'); - - const day = time.startOf('day'); - - if (day.isSame(today, 'day')) { - return 'Today'; - } else if (day.isSame(tomorrow, 'day')) { - return 'Tomorrow'; - } else if (day.isBefore(weekLater)) { - // Within a week, show day name - return time.format('dddd'); - } else { - // Beyond a week, show month and day - return time.format(dateFormat); - } - }, + (time) => formatTime(time, dateFormat), [dateFormat] ); // Hourly marks with day labels - const hourTimeline = useMemo(() => { - const hours = []; - let current = start; - let currentDay = null; - - while (current.isBefore(end)) { - // Check if we're entering a new day - const day = current.startOf('day'); - const isNewDay = !currentDay || !day.isSame(currentDay, 'day'); - - if (isNewDay) { - currentDay = day; - } - - // Add day information to our hour object - hours.push({ - time: current, - isNewDay, - dayLabel: formatDayLabel(current), - }); - - current = current.add(1, 'hour'); - } - return hours; - }, [start, end, formatDayLabel]); + const hourTimeline = useMemo( + () => calculateHourTimeline(start, end, formatDayLabel), + [start, end, formatDayLabel] + ); useEffect(() => { const node = guideRef.current; @@ -542,17 +268,16 @@ export default function TVChannelGuide({ startDate, endDate }) { // Update "now" every second useEffect(() => { const interval = setInterval(() => { - setNow(dayjs()); + setNow(getNow()); }, 1000); return () => clearInterval(interval); }, []); // Pixel offset for the "now" vertical line - const nowPosition = useMemo(() => { - if (now.isBefore(start) || now.isAfter(end)) return -1; - const minutesSinceStart = now.diff(start, 'minute'); - return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - }, [now, start, end]); + const nowPosition = useMemo( + () => calculateNowPosition(now, start, end), + [now, start, end] + ); useEffect(() => { const tvGuide = tvGuideRef.current; @@ -765,31 +490,14 @@ export default function TVChannelGuide({ startDate, endDate }) { // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { if (programs.length > 0 && !initialScrollComplete) { - const roundedNow = - now.minute() < 30 - ? now.startOf('hour') - : now.startOf('hour').add(30, 'minute'); - const nowOffset = roundedNow.diff(start, 'minute'); - const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - - MINUTE_BLOCK_WIDTH; - - const scrollPos = Math.max(scrollPosition, 0); - syncScrollLeft(scrollPos); + syncScrollLeft(calculateScrollPosition(now, start)); setInitialScrollComplete(true); } }, [programs, start, now, initialScrollComplete, syncScrollLeft]); const findChannelByTvgId = useCallback( - (tvgId) => { - const channelIds = channelIdByTvgId.get(String(tvgId)); - if (!channelIds || channelIds.length === 0) { - return null; - } - // Return the first channel that matches this TVG ID - return channelById.get(channelIds[0]) || null; - }, + (tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId), [channelById, channelIdByTvgId] ); @@ -798,19 +506,14 @@ export default function TVChannelGuide({ startDate, endDate }) { setRecordChoiceProgram(program); setRecordChoiceOpen(true); try { - const rules = await API.listSeriesRules(); - const rule = (rules || []).find( - (r) => - String(r.tvg_id) === String(program.tvg_id) && - (!r.title || r.title === program.title) - ); + const rules = await fetchRules(); + const rule = getRuleByProgram(rules, program); setExistingRuleMode(rule ? rule.mode : null); } catch (error) { console.warn('Failed to fetch series rules metadata', error); } - const existingRecording = recordingsByProgramId.get(program.id) || null; - setRecordingForProgram(existingRecording); + setRecordingForProgram(recordingsByProgramId.get(program.id) || null); }, [recordingsByProgramId] ); @@ -827,24 +530,15 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - await API.createRecording({ - channel: `${channel.id}`, - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); + await createRecording(channel, program); notifications.show({ title: 'Recording scheduled' }); }, [findChannelByTvgId] ); const saveSeriesRule = useCallback(async (program, mode) => { - await API.createSeriesRule({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); - await API.evaluateSeriesRules(program.tvg_id); + await createSeriesRule(program, mode); + await evaluateSeriesRule(program); try { await useChannelsStore.getState().fetchRecordings(); } catch (error) { @@ -861,7 +555,7 @@ export default function TVChannelGuide({ startDate, endDate }) { const openRules = useCallback(async () => { setRulesOpen(true); try { - const r = await API.listSeriesRules(); + const r = await fetchRules(); setRules(r); } catch (error) { console.warn('Failed to load series rules', error); @@ -878,12 +572,7 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - let vidUrl = `/proxy/ts/stream/${matched.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - - showVideo(vidUrl); + showVideo(getShowVideoUrl(matched, env_mode)); }, [env_mode, findChannelByTvgId, showVideo] ); @@ -892,12 +581,7 @@ export default function TVChannelGuide({ startDate, endDate }) { (channel, event) => { event.stopPropagation(); - let vidUrl = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - - showVideo(vidUrl); + showVideo(getShowVideoUrl(channel, env_mode)); }, [env_mode, showVideo] ); @@ -906,13 +590,6 @@ export default function TVChannelGuide({ startDate, endDate }) { (program, event) => { event.stopPropagation(); - const programStartMs = - program.startMs ?? dayjs(program.start_time).valueOf(); - const startOffsetMinutes = (programStartMs - start.valueOf()) / 60000; - const leftPx = - (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - const desiredScrollPosition = Math.max(0, leftPx - 20); - if (expandedProgramId === program.id) { setExpandedProgramId(null); setRecordingForProgram(null); @@ -921,6 +598,9 @@ export default function TVChannelGuide({ startDate, endDate }) { setRecordingForProgram(recordingsByProgramId.get(program.id) || null); } + const leftPx = calculateLeftScrollPosition(program, start); + const desiredScrollPosition = calculateDesiredScrollPosition(leftPx); + const guideNode = guideRef.current; if (guideNode) { const currentScrollPosition = guideNode.scrollLeft; @@ -948,16 +628,7 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - const roundedNow = - now.minute() < 30 - ? now.startOf('hour') - : now.startOf('hour').add(30, 'minute'); - const nowOffset = roundedNow.diff(start, 'minute'); - const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; - - const scrollPos = Math.max(scrollPosition, 0); - syncScrollLeft(scrollPos, 'smooth'); + syncScrollLeft(calculateScrollPosition(now, start), 'smooth'); }, [now, nowPosition, start, syncScrollLeft]); const handleTimelineScroll = useCallback(() => { @@ -1000,44 +671,26 @@ export default function TVChannelGuide({ startDate, endDate }) { const handleTimeClick = useCallback( (clickedTime, event) => { - const rect = event.currentTarget.getBoundingClientRect(); - const clickPositionX = event.clientX - rect.left; - const percentageAcross = clickPositionX / rect.width; - const minuteWithinHour = Math.floor(percentageAcross * 60); - - let snappedMinute; - if (minuteWithinHour < 7.5) { - snappedMinute = 0; - } else if (minuteWithinHour < 22.5) { - snappedMinute = 15; - } else if (minuteWithinHour < 37.5) { - snappedMinute = 30; - } else if (minuteWithinHour < 52.5) { - snappedMinute = 45; - } else { - snappedMinute = 0; - clickedTime = clickedTime.add(1, 'hour'); - } - - const snappedTime = clickedTime.minute(snappedMinute); - const snappedOffset = snappedTime.diff(start, 'minute'); - const scrollPosition = - (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - - syncScrollLeft(scrollPosition, 'smooth'); + syncScrollLeft( + calculateScrollPositionByTimeClick(event, clickedTime, start), + 'smooth' + ); }, [start, syncScrollLeft] ); const renderProgram = useCallback( (program, channelStart = start, channel = null) => { - const programStartMs = - program.startMs ?? dayjs(program.start_time).valueOf(); - const programEndMs = program.endMs ?? dayjs(program.end_time).valueOf(); - const programStart = dayjs(programStartMs); - const programEnd = dayjs(programEndMs); + const { + programStart, + programEnd, + startMs: programStartMs, + endMs: programEndMs, + isLive, + isPast, + } = program; const startOffsetMinutes = - (programStartMs - channelStart.valueOf()) / 60000; + (programStartMs - convertToMs(channelStart)) / 60000; const durationMinutes = (programEndMs - programStartMs) / 60000; const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; @@ -1048,10 +701,7 @@ export default function TVChannelGuide({ startDate, endDate }) { const recording = recordingsByProgramId.get(program.id); - const isLive = now.isAfter(programStart) && now.isBefore(programEnd); - const isPast = now.isAfter(programEnd); const isExpanded = expandedProgramId === program.id; - const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; const MIN_EXPANDED_WIDTH = 450; const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); @@ -1069,6 +719,38 @@ export default function TVChannelGuide({ startDate, endDate }) { textOffsetLeft = Math.min(visibleStart, maxOffset); } + const RecordButton = () => { + return ( + + ); + }; + const WatchNow = () => { + return ( + + ); + }; return ( - {programStart.format(timeFormat)} -{' '} - {programEnd.format(timeFormat)} + {format(programStart, timeFormat)} -{' '} + {format(programEnd, timeFormat)} @@ -1183,35 +865,9 @@ export default function TVChannelGuide({ startDate, endDate }) { {isExpanded && ( - {!isPast && ( - - )} + {!isPast && } - {isLive && ( - - )} + {isLive && } )} @@ -1294,49 +950,13 @@ export default function TVChannelGuide({ startDate, endDate }) { }, [searchQuery, selectedGroupId, selectedProfileId]); // Create group options for dropdown - but only include groups used by guide channels - const groupOptions = useMemo(() => { - const options = [{ value: 'all', label: 'All Channel Groups' }]; - - if (channelGroups && guideChannels.length > 0) { - // Get unique channel group IDs from the channels that have program data - const usedGroupIds = new Set(); - guideChannels.forEach((channel) => { - if (channel.channel_group_id) { - usedGroupIds.add(channel.channel_group_id); - } - }); - // Only add groups that are actually used by channels in the guide - Object.values(channelGroups) - .filter((group) => usedGroupIds.has(group.id)) - .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically - .forEach((group) => { - options.push({ - value: group.id.toString(), - label: group.name, - }); - }); - } - return options; - }, [channelGroups, guideChannels]); + const groupOptions = useMemo( + () => getGroupOptions(channelGroups, guideChannels), + [channelGroups, guideChannels] + ); // Create profile options for dropdown - const profileOptions = useMemo(() => { - const options = [{ value: 'all', label: 'All Profiles' }]; - - if (profiles) { - Object.values(profiles).forEach((profile) => { - if (profile.id !== '0') { - // Skip the 'All' default profile - options.push({ - value: profile.id.toString(), - label: profile.name, - }); - } - }); - } - - return options; - }, [profiles]); + const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]); // Clear all filters const clearFilters = () => { @@ -1355,6 +975,13 @@ export default function TVChannelGuide({ startDate, endDate }) { setSelectedProfileId(value || 'all'); }; + const handleClearSearchQuery = () => { + setSearchQuery(''); + }; + const handleChangeSearchQuery = (e) => { + setSearchQuery(e.target.value); + }; + return ( @@ -1373,10 +1000,10 @@ export default function TVChannelGuide({ startDate, endDate }) { direction="column" style={{ zIndex: 1000, + position: 'sticky' }} - c={'#fff'} + c='#ffffff' p={'12px 20px'} - pos={'sticky'} top={0} > {/* Title and current time */} @@ -1386,7 +1013,7 @@ export default function TVChannelGuide({ startDate, endDate }) { - {now.format(`dddd, ${dateFormat}, YYYY • ${timeFormat}`)} + {format(now, `dddd, ${dateFormat}, YYYY • ${timeFormat}`)} setSearchQuery(e.target.value)} + onChange={handleChangeSearchQuery} w={'250px'} // Reduced width from flex: 1 leftSection={} rightSection={ searchQuery ? ( setSearchQuery('')} + onClick={handleClearSearchQuery} variant="subtle" color="gray" size="sm" @@ -1458,12 +1085,12 @@ export default function TVChannelGuide({ startDate, endDate }) { backgroundColor: '#245043', }} bd={'1px solid #3BA882'} - c={'#FFFFFF'} + color='#FFFFFF' > Series Rules - + {filteredChannels.length}{' '} {filteredChannels.length === 1 ? 'channel' : 'channels'} @@ -1482,9 +1109,9 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Logo header cell - sticky in both directions */} @@ -1499,7 +1126,7 @@ export default function TVChannelGuide({ startDate, endDate }) { w={CHANNEL_WIDTH} miw={CHANNEL_WIDTH} h={'40px'} - pos={'sticky'} + pos='sticky' left={0} /> @@ -1509,7 +1136,7 @@ export default function TVChannelGuide({ startDate, endDate }) { flex: 1, overflow: 'hidden', }} - pos={'relative'} + pos='relative' > @@ -1529,99 +1156,12 @@ export default function TVChannelGuide({ startDate, endDate }) { display={'flex'} w={hourTimeline.length * HOUR_WIDTH} > - {' '} - {hourTimeline.map((hourData) => { - const { time, isNewDay } = hourData; - - return ( - handleTimeClick(time, e)} - > - {/* Remove the special day label for new days since we'll show day for all hours */} - - {/* Position time label at the left border of each hour block */} - - {/* Show day above time for every hour using the same format */} - - {formatDayLabel(time)}{' '} - {/* Use same formatDayLabel function for all hours */} - - {time.format(timeFormat)} - - {/*time.format('A')*/} - - - - {/* Hour boundary marker - more visible */} - - - {/* Quarter hour tick marks */} - - {[15, 30, 45].map((minute) => ( - - ))} - - - ); - })} + @@ -1634,7 +1174,7 @@ export default function TVChannelGuide({ startDate, endDate }) { flex: 1, overflow: 'hidden', }} - pos={'relative'} + pos='relative' > {nowPosition >= 0 && ( ) : ( - + No channels match your filters - - - {recordingForProgram && ( - <> - - - - )} - {existingRuleMode && ( - - )} - - + program={recordChoiceProgram} + recording={recordingForProgram} + existingRuleMode={existingRuleMode} + onRecordOne={() => recordOne(recordChoiceProgram)} + onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')} + onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')} + onExistingRuleModeChange={setExistingRuleMode} + /> )} {/* Series rules modal */} {rulesOpen && ( - setRulesOpen(false)} - title="Series Recording Rules" - centered - radius="md" - zIndex={9999} - overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }} - styles={{ - content: { backgroundColor: '#18181B', color: 'white' }, - header: { backgroundColor: '#18181B', color: 'white' }, - title: { color: 'white' }, - }} - > - - {(!rules || rules.length === 0) && ( - - No series rules configured - - )} - {rules && - rules.map((r) => ( - - - {r.title || r.tvg_id} —{' '} - {r.mode === 'new' ? 'New episodes' : 'Every episode'} - - - - - - - ))} - - + rules={rules} + onRulesUpdate={setRules} + /> )} ); diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js index 1f4ff671..68bb74b2 100644 --- a/frontend/src/pages/guideUtils.js +++ b/frontend/src/pages/guideUtils.js @@ -1,7 +1,26 @@ -import dayjs from 'dayjs'; +import { + convertToMs, + initializeTime, + startOfDay, + isBefore, + isAfter, + isSame, + add, + diff, + format, + getNow, + getNowMs, + roundToNearest +} from '../utils/dateTimeUtils.js'; +import API from '../api.js'; export const PROGRAM_HEIGHT = 90; export const EXPANDED_PROGRAM_HEIGHT = 180; +/** Layout constants */ +export const CHANNEL_WIDTH = 120; // Width of the channel/logo column +export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider +export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min +export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT); export function buildChannelIdMap(channels, tvgsById, epgs = {}) { const map = new Map(); @@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) { return map; } -export function mapProgramsByChannel(programs, channelIdByTvgId) { +export const mapProgramsByChannel = (programs, channelIdByTvgId) => { if (!programs?.length || !channelIdByTvgId?.size) { return new Map(); } const map = new Map(); + const nowMs = getNowMs(); + programs.forEach((program) => { const channelIds = channelIdByTvgId.get(String(program.tvg_id)); if (!channelIds || channelIds.length === 0) { return; } - const startMs = program.startMs ?? dayjs(program.start_time).valueOf(); - const endMs = program.endMs ?? dayjs(program.end_time).valueOf(); + const startMs = program.startMs ?? convertToMs(program.start_time); + const endMs = program.endMs ?? convertToMs(program.end_time); const programData = { ...program, startMs, endMs, + programStart: initializeTime(program.startMs), + programEnd: initializeTime(program.endMs), + // Precompute live/past status + isLive: nowMs >= program.startMs && nowMs < program.endMs, + isPast: nowMs >= program.endMs, }; // Add this program to all channels that share the same TVG ID @@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) { }); return map; -} +}; export function computeRowHeights( filteredChannels, @@ -94,3 +120,282 @@ export function computeRowHeights( return expanded ? expandedHeight : defaultHeight; }); } + +export const fetchPrograms = async () => { + console.log('Fetching program grid...'); + const fetched = await API.getGrid(); // GETs your EPG grid + console.log(`Received ${fetched.length} programs`); + + return fetched.map((program) => { + return { + ...program, + startMs: convertToMs(program.start_time), + endMs: convertToMs(program.end_time), + }; + }); +}; + +export const sortChannels = (channels) => { + // Include ALL channels, sorted by channel number - don't filter by EPG data + const sortedChannels = Object.values(channels).sort( + (a, b) => + (a.channel_number || Infinity) - (b.channel_number || Infinity) + ); + + console.log(`Using all ${sortedChannels.length} available channels`); + return sortedChannels; +} + +export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => { + return guideChannels.filter((channel) => { + // Search filter + if (searchQuery) { + if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; + } + + // Channel group filter + if (selectedGroupId !== 'all') { + if (channel.channel_group_id !== parseInt(selectedGroupId)) return false; + } + + // Profile filter + if (selectedProfileId !== 'all') { + const profileChannels = profiles[selectedProfileId]?.channels || []; + const enabledChannelIds = Array.isArray(profileChannels) + ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id) + : profiles[selectedProfileId]?.channels instanceof Set + ? Array.from(profiles[selectedProfileId].channels) + : []; + + if (!enabledChannelIds.includes(channel.id)) return false; + } + + return true; + }); +} + +export const calculateEarliestProgramStart = (programs, defaultStart) => { + if (!programs.length) return defaultStart; + return programs.reduce((acc, p) => { + const s = initializeTime(p.start_time); + return isBefore(s, acc) ? s : acc; + }, defaultStart); +} + +export const calculateLatestProgramEnd = (programs, defaultEnd) => { + if (!programs.length) return defaultEnd; + return programs.reduce((acc, p) => { + const e = initializeTime(p.end_time); + return isAfter(e, acc) ? e : acc; + }, defaultEnd); +} + +export const calculateStart = (earliestProgramStart, defaultStart) => { + return isBefore(earliestProgramStart, defaultStart) + ? earliestProgramStart + : defaultStart; +} + +export const calculateEnd = (latestProgramEnd, defaultEnd) => { + return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd; +} + +export const mapChannelsById = (guideChannels) => { + const map = new Map(); + guideChannels.forEach((channel) => { + map.set(channel.id, channel); + }); + return map; +} + +export const mapRecordingsByProgramId = (recordings) => { + const map = new Map(); + (recordings || []).forEach((recording) => { + const programId = recording?.custom_properties?.program?.id; + if (programId != null) { + map.set(programId, recording); + } + }); + return map; +} + +export const formatTime = (time, dateFormat) => { + const today = startOfDay(getNow()); + const tomorrow = add(today, 1, 'day'); + const weekLater = add(today, 7, 'day'); + const day = startOfDay(time); + + if (isSame(day, today, 'day')) { + return 'Today'; + } else if (isSame(day, tomorrow, 'day')) { + return 'Tomorrow'; + } else if (isBefore(day, weekLater)) { + // Within a week, show day name + return format(time, 'dddd'); + } else { + // Beyond a week, show month and day + return format(time, dateFormat); + } +} + +export const calculateHourTimeline = (start, end, formatDayLabel) => { + const hours = []; + let current = start; + let currentDay = null; + + while (isBefore(current, end)) { + // Check if we're entering a new day + const day = startOfDay(current); + const isNewDay = !currentDay || !isSame(day, currentDay, 'day'); + + if (isNewDay) { + currentDay = day; + } + + // Add day information to our hour object + hours.push({ + time: current, + isNewDay, + dayLabel: formatDayLabel(current), + }); + + current = add(current, 1, 'hour'); + } + return hours; +} + +export const calculateNowPosition = (now, start, end) => { + if (isBefore(now, start) || isAfter(now, end)) return -1; + const minutesSinceStart = diff(now, start, 'minute'); + return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; +}; + +export const calculateScrollPosition = (now, start) => { + const roundedNow = roundToNearest(now, 30); + const nowOffset = diff(roundedNow, start, 'minute'); + const scrollPosition = + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + + return Math.max(scrollPosition, 0); +}; + +export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => { + const channelIds = channelIdByTvgId.get(String(tvgId)); + if (!channelIds || channelIds.length === 0) { + return null; + } + // Return the first channel that matches this TVG ID + return channelById.get(channelIds[0]) || null; +} + +export const fetchRules = async () => { + return await API.listSeriesRules(); +} + +export const getRuleByProgram = (rules, program) => { + return (rules || []).find( + (r) => + String(r.tvg_id) === String(program.tvg_id) && + (!r.title || r.title === program.title) + ); +} + +export const createRecording = async (channel, program) => { + await API.createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + custom_properties: { program }, + }); +} + +export const createSeriesRule = async (program, mode) => { + await API.createSeriesRule({ + tvg_id: program.tvg_id, + mode, + title: program.title, + }); +} + +export const evaluateSeriesRule = async (program) => { + await API.evaluateSeriesRules(program.tvg_id); +} + +export const calculateLeftScrollPosition = (program, start) => { + const programStartMs = + program.startMs ?? convertToMs(program.start_time); + const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000; + + return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; +}; + +export const calculateDesiredScrollPosition = (leftPx) => { + return Math.max(0, leftPx - 20); +} + +export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => { + const rect = event.currentTarget.getBoundingClientRect(); + const clickPositionX = event.clientX - rect.left; + const percentageAcross = clickPositionX / rect.width; + const minuteWithinHour = percentageAcross * 60; + + const snappedMinute = Math.round(minuteWithinHour / 15) * 15; + + const adjustedTime = (snappedMinute === 60) + ? add(clickedTime, 1, 'hour').minute(0) + : clickedTime.minute(snappedMinute); + + const snappedOffset = diff(adjustedTime, start, 'minute'); + return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; +}; + +export const getGroupOptions = (channelGroups, guideChannels) => { + const options = [{ value: 'all', label: 'All Channel Groups' }]; + + if (channelGroups && guideChannels.length > 0) { + // Get unique channel group IDs from the channels that have program data + const usedGroupIds = new Set(); + guideChannels.forEach((channel) => { + if (channel.channel_group_id) { + usedGroupIds.add(channel.channel_group_id); + } + }); + // Only add groups that are actually used by channels in the guide + Object.values(channelGroups) + .filter((group) => usedGroupIds.has(group.id)) + .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically + .forEach((group) => { + options.push({ + value: group.id.toString(), + label: group.name, + }); + }); + } + return options; +} + +export const getProfileOptions = (profiles) => { + const options = [{ value: 'all', label: 'All Profiles' }]; + + if (profiles) { + Object.values(profiles).forEach((profile) => { + if (profile.id !== '0') { + // Skip the 'All' default profile + options.push({ + value: profile.id.toString(), + label: profile.name, + }); + } + }); + } + + return options; +} + +export const deleteSeriesRuleByTvgId = async (tvg_id) => { + await API.deleteSeriesRule(tvg_id); +} + +export const evaluateSeriesRulesByTvgId = async (tvg_id) => { + await API.evaluateSeriesRules(tvg_id); +} \ No newline at end of file diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index b7490f88..d2d2ea63 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -12,6 +12,38 @@ dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(timezone); +export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); + +export const initializeTime = (dateTime) => dayjs(dateTime); + +export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); + +export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2); + +export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2); + +export const isSame = (date1, date2, unit = 'day') => dayjs(date1).isSame(date2, unit); + +export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit); + +export const diff = (date1, date2, unit = 'millisecond') => dayjs(date1).diff(date2, unit); + +export const format = (dateTime, formatStr) => dayjs(dateTime).format(formatStr); + +export const getNow = () => dayjs(); + +export const getNowMs = () => Date.now(); + +export const roundToNearest = (dateTime, minutes) => { + const current = initializeTime(dateTime); + const minute = current.minute(); + const snappedMinute = Math.round(minute / minutes) * minutes; + + return snappedMinute === 60 + ? current.add(1, 'hour').minute(0) + : current.minute(snappedMinute); +}; + export const useUserTimeZone = () => { const settings = useSettingsStore((s) => s.settings); const [timeZone, setTimeZone] = useLocalStorage( @@ -38,15 +70,15 @@ export const useTimeHelpers = () => { (value) => { if (!value) return dayjs.invalid(); try { - return dayjs(value).tz(timeZone); + return initializeTime(value).tz(timeZone); } catch (error) { - return dayjs(value); + return initializeTime(value); } }, [timeZone] ); - const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]); + const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]); return { timeZone, toUserTime, userNow }; }; @@ -78,7 +110,7 @@ export const toTimeString = (value) => { if (parsed.isValid()) return parsed.format('HH:mm'); return value; } - const parsed = dayjs(value); + const parsed = initializeTime(value); return parsed.isValid() ? parsed.format('HH:mm') : '00:00'; }; From ca96adf7818f0c84a11e3743e563d837fcee42aa Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:41:51 -0800 Subject: [PATCH 60/80] Extracted notification util --- frontend/src/components/forms/SeriesRecordingModal.jsx | 4 ++-- frontend/src/pages/Guide.jsx | 10 +++++----- frontend/src/utils/notificationUtils.js | 5 +++++ 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 frontend/src/utils/notificationUtils.js diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx index 1c10e4bd..3d890971 100644 --- a/frontend/src/components/forms/SeriesRecordingModal.jsx +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels.jsx'; import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js'; import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; export default function SeriesRecordingModal({ opened, @@ -18,7 +18,7 @@ export default function SeriesRecordingModal({ } catch (error) { console.warn('Failed to refresh recordings after evaluation', error); } - notifications.show({ + showNotification({ title: 'Evaluated', message: 'Checked for episodes', }); diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index a382fffe..214fc216 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -9,7 +9,6 @@ import React, { import useChannelsStore from '../store/channels'; import useLogosStore from '../store/logos'; import useVideoStore from '../store/useVideoStore'; // NEW import -import { notifications } from '@mantine/notifications'; import useSettingsStore from '../store/settings'; import { ActionIcon, @@ -80,6 +79,7 @@ import GuideRow from '../components/GuideRow.jsx'; import HourTimeline from '../components/HourTimeline'; import ProgramRecordingModal from '../components/forms/ProgramRecordingModal'; import SeriesRecordingModal from '../components/forms/SeriesRecordingModal'; +import { showNotification } from '../utils/notificationUtils.js'; export default function TVChannelGuide({ startDate, endDate }) { const channels = useChannelsStore((s) => s.channels); @@ -130,7 +130,7 @@ export default function TVChannelGuide({ startDate, endDate }) { useEffect(() => { if (Object.keys(channels).length === 0) { console.warn('No channels provided or empty channels array'); - notifications.show({ title: 'No channels available', color: 'red.5' }); + showNotification({ title: 'No channels available', color: 'red.5' }); return; } @@ -522,7 +522,7 @@ export default function TVChannelGuide({ startDate, endDate }) { async (program) => { const channel = findChannelByTvgId(program.tvg_id); if (!channel) { - notifications.show({ + showNotification({ title: 'Unable to schedule recording', message: 'No channel found for this program.', color: 'red.6', @@ -531,7 +531,7 @@ export default function TVChannelGuide({ startDate, endDate }) { } await createRecording(channel, program); - notifications.show({ title: 'Recording scheduled' }); + showNotification({ title: 'Recording scheduled' }); }, [findChannelByTvgId] ); @@ -547,7 +547,7 @@ export default function TVChannelGuide({ startDate, endDate }) { error ); } - notifications.show({ + showNotification({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', }); }, []); diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js new file mode 100644 index 00000000..baf91b54 --- /dev/null +++ b/frontend/src/utils/notificationUtils.js @@ -0,0 +1,5 @@ +import { notifications } from '@mantine/notifications'; + +export function showNotification(notificationObject) { + notifications.show(notificationObject); +} \ No newline at end of file From a5688605cd998cc5ab60588a25831a8fa263bf8b Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:13:07 -0800 Subject: [PATCH 61/80] Lazy-loading button modals --- frontend/src/pages/Guide.jsx | 51 ++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 214fc216..2ae80012 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -5,6 +5,7 @@ import React, { useEffect, useRef, useCallback, + Suspense, } from 'react'; import useChannelsStore from '../store/channels'; import useLogosStore from '../store/logos'; @@ -16,6 +17,7 @@ import { Button, Flex, Group, + LoadingOverlay, Paper, Select, Text, @@ -77,9 +79,12 @@ import { } from '../utils/dateTimeUtils.js'; import GuideRow from '../components/GuideRow.jsx'; import HourTimeline from '../components/HourTimeline'; -import ProgramRecordingModal from '../components/forms/ProgramRecordingModal'; -import SeriesRecordingModal from '../components/forms/SeriesRecordingModal'; +const ProgramRecordingModal = React.lazy(() => + import('../components/forms/ProgramRecordingModal')); +const SeriesRecordingModal = React.lazy(() => + import('../components/forms/SeriesRecordingModal')); import { showNotification } from '../utils/notificationUtils.js'; +import ErrorBoundary from '../components/ErrorBoundary.jsx'; export default function TVChannelGuide({ startDate, endDate }) { const channels = useChannelsStore((s) => s.channels); @@ -1219,27 +1224,35 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Record choice modal */} {recordChoiceOpen && recordChoiceProgram && ( - setRecordChoiceOpen(false)} - program={recordChoiceProgram} - recording={recordingForProgram} - existingRuleMode={existingRuleMode} - onRecordOne={() => recordOne(recordChoiceProgram)} - onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')} - onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')} - onExistingRuleModeChange={setExistingRuleMode} - /> + + }> + setRecordChoiceOpen(false)} + program={recordChoiceProgram} + recording={recordingForProgram} + existingRuleMode={existingRuleMode} + onRecordOne={() => recordOne(recordChoiceProgram)} + onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')} + onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')} + onExistingRuleModeChange={setExistingRuleMode} + /> + + )} {/* Series rules modal */} {rulesOpen && ( - setRulesOpen(false)} - rules={rules} - onRulesUpdate={setRules} - /> + + }> + setRulesOpen(false)} + rules={rules} + onRulesUpdate={setRules} + /> + + )}
); From f97399de07761b46c55d1a070341cdfbc13adc7f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:35:43 -0800 Subject: [PATCH 62/80] Extracted component and util logic --- frontend/src/components/Field.jsx | 47 ++ frontend/src/components/cards/PluginCard.jsx | 258 +++++++ frontend/src/pages/Logos.jsx | 4 +- frontend/src/pages/Plugins.jsx | 770 ++++++------------- frontend/src/utils/cards/PluginCardUtils.js | 24 + frontend/src/utils/notificationUtils.js | 6 +- frontend/src/utils/pages/PluginsUtils.js | 17 + 7 files changed, 603 insertions(+), 523 deletions(-) create mode 100644 frontend/src/components/Field.jsx create mode 100644 frontend/src/components/cards/PluginCard.jsx create mode 100644 frontend/src/utils/cards/PluginCardUtils.js create mode 100644 frontend/src/utils/pages/PluginsUtils.js diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 00000000..1293bf7b --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,47 @@ +import { NumberInput, Select, Switch, TextInput } from '@mantine/core'; +import React from 'react'; + +export const Field = ({ field, value, onChange }) => { + const common = { label: field.label, description: field.help_text }; + const effective = value ?? field.default; + switch (field.type) { + case 'boolean': + return ( + onChange(field.id, e.currentTarget.checked)} + label={field.label} + description={field.help_text} + /> + ); + case 'number': + return ( + onChange(field.id, v)} + {...common} + /> + ); + case 'select': + return ( + ({ - value: o.value + '', - label: o.label, - }))} - onChange={(v) => onChange(field.id, v)} - {...common} - /> - ); - case 'string': - default: - return ( - onChange(field.id, e.currentTarget.value)} - {...common} - /> - ); - } -}; +const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => { + const plugins = usePluginStore((state) => state.plugins); + const loading = usePluginStore((state) => state.loading); + const hasFetchedRef = useRef(false); -const PluginCard = ({ - plugin, - onSaveSettings, - onRunAction, - onToggleEnabled, - onRequireTrust, - onRequestDelete, -}) => { - const [settings, setSettings] = useState(plugin.settings || {}); - const [saving, setSaving] = useState(false); - const [running, setRunning] = useState(false); - const [enabled, setEnabled] = useState(!!plugin.enabled); - const [lastResult, setLastResult] = useState(null); - const [confirmOpen, setConfirmOpen] = useState(false); - const [confirmConfig, setConfirmConfig] = useState({ - title: '', - message: '', - onConfirm: null, - }); + useEffect(() => { + if (!hasFetchedRef.current) { + hasFetchedRef.current = true; + usePluginStore.getState().fetchPlugins(); + } + }, []); - // Keep local enabled state in sync with props (e.g., after import + enable) - React.useEffect(() => { - setEnabled(!!plugin.enabled); - }, [plugin.enabled]); - // Sync settings if plugin changes identity - React.useEffect(() => { - setSettings(plugin.settings || {}); - }, [plugin.key]); + const handleTogglePluginEnabled = async (key, next) => { + const resp = await setPluginEnabled(key, next); - const updateField = (id, val) => { - setSettings((prev) => ({ ...prev, [id]: val })); - }; - - const save = async () => { - setSaving(true); - try { - await onSaveSettings(plugin.key, settings); - notifications.show({ - title: 'Saved', - message: `${plugin.name} settings updated`, - color: 'green', + if (resp?.success) { + usePluginStore.getState().updatePlugin(key, { + enabled: next, + ever_enabled: resp?.ever_enabled, }); - } finally { - setSaving(false); } }; - const missing = plugin.missing; + if (loading && plugins.length === 0) { + return ; + } + return ( - - -
- {plugin.name} - - {plugin.description} + <> + {plugins.length > 0 && + + + }> + {plugins.map((p) => ( + + ))} + + + + } + + {plugins.length === 0 && ( + + + No plugins found. Drop a plugin into /data/plugins{' '} + and reload. -
- - onRequestDelete && onRequestDelete(plugin)} - > - - - - v{plugin.version || '1.0.0'} - - { - const next = e.currentTarget.checked; - if (next && !plugin.ever_enabled && onRequireTrust) { - const ok = await onRequireTrust(plugin); - if (!ok) { - // Revert - setEnabled(false); - return; - } - } - setEnabled(next); - const resp = await onToggleEnabled(plugin.key, next); - if (next && resp?.ever_enabled) { - plugin.ever_enabled = true; - } - }} - size="xs" - onLabel="On" - offLabel="Off" - disabled={missing} - /> - -
- - {missing && ( - - Missing plugin files. Re-import or delete this entry. - +
)} - - {!missing && plugin.fields && plugin.fields.length > 0 && ( - - {plugin.fields.map((f) => ( - - ))} - - - - - )} - - {!missing && plugin.actions && plugin.actions.length > 0 && ( - <> - - - {plugin.actions.map((a) => ( - -
- {a.label} - {a.description && ( - - {a.description} - - )} -
- -
- ))} - {running && ( - - Running action… please wait - - )} - {!running && lastResult?.file && ( - - Output: {lastResult.file} - - )} - {!running && lastResult?.error && ( - - Error: {String(lastResult.error)} - - )} -
- - )} - { - setConfirmOpen(false); - setConfirmConfig({ title: '', message: '', onConfirm: null }); - }} - title={confirmConfig.title} - centered - > - - {confirmConfig.message} - - - - - - - + ); }; export default function PluginsPage() { - const [loading, setLoading] = useState(true); - const [plugins, setPlugins] = useState([]); const [importOpen, setImportOpen] = useState(false); const [importFile, setImportFile] = useState(null); const [importing, setImporting] = useState(false); @@ -358,118 +113,172 @@ export default function PluginsPage() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); - const [uploadNoticeId, setUploadNoticeId] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmConfig, setConfirmConfig] = useState({ + title: '', + message: '', + resolve: null, + }); - const load = async () => { - setLoading(true); - try { - const list = await API.getPlugins(); - setPlugins(list); - } finally { - setLoading(false); - } + const handleReload = () => { + usePluginStore.getState().invalidatePlugins(); }; - useEffect(() => { - load(); + const handleRequestDelete = useCallback((pl) => { + setDeleteTarget(pl); + setDeleteOpen(true); }, []); - const requireTrust = (plugin) => { + const requireTrust = useCallback((plugin) => { return new Promise((resolve) => { setTrustResolve(() => resolve); setTrustOpen(true); }); + }, []); + + const showImportForm = useCallback(() => { + setImportOpen(true); + setImported(null); + setImportFile(null); + setEnableAfterImport(false); + }, []); + + const requestConfirm = useCallback((title, message) => { + return new Promise((resolve) => { + setConfirmConfig({ title, message, resolve }); + setConfirmOpen(true); + }); + }, []); + + const handleImportPlugin = () => { + return async () => { + setImporting(true); + const id = showNotification({ + title: 'Uploading plugin', + message: 'Backend may restart; please wait…', + loading: true, + autoClose: false, + withCloseButton: false, + }); + try { + const resp = await importPlugin(importFile); + if (resp?.success && resp.plugin) { + setImported(resp.plugin); + usePluginStore.getState().invalidatePlugins(); + + updateNotification({ + id, + loading: false, + color: 'green', + title: 'Imported', + message: + 'Plugin imported. If the app briefly disconnected, it should be back now.', + autoClose: 3000, + }); + } else { + updateNotification({ + id, + loading: false, + color: 'red', + title: 'Import failed', + message: resp?.error || 'Unknown error', + autoClose: 5000, + }); + } + } catch (e) { + // API.importPlugin already showed a concise error; just update the loading notice + updateNotification({ + id, + loading: false, + color: 'red', + title: 'Import failed', + message: + (e?.body && (e.body.error || e.body.detail)) || + e?.message || + 'Failed', + autoClose: 5000, + }); + } finally { + setImporting(false); + } + }; }; + const handleEnablePlugin = () => { + return async () => { + if (!imported) return; + + const proceed = imported.ever_enabled || (await requireTrust(imported)); + if (proceed) { + const resp = await setPluginEnabled(imported.key, true); + if (resp?.success) { + usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true }); + + showNotification({ + title: imported.name, + message: 'Plugin enabled', + color: 'green', + }); + } + setImportOpen(false); + setImported(null); + setEnableAfterImport(false); + } + }; + }; + + const handleDeletePlugin = () => { + return async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + const resp = await deletePluginByKey(deleteTarget.key); + if (resp?.success) { + usePluginStore.getState().removePlugin(deleteTarget.key); + + showNotification({ + title: deleteTarget.name, + message: 'Plugin deleted', + color: 'green', + }); + } + setDeleteOpen(false); + setDeleteTarget(null); + } finally { + setDeleting(false); + } + }; + }; + + const handleConfirm = useCallback((confirmed) => { + const resolver = confirmConfig.resolve; + setConfirmOpen(false); + setConfirmConfig({ title: '', message: '', resolve: null }); + if (resolver) resolver(confirmed); + }, [confirmConfig.resolve]); + return ( - + Plugins - - { - await API.reloadPlugins(); - await load(); - }} - title="Reload" - > + - {loading ? ( - - ) : ( - <> - - {plugins.map((p) => ( - { - const resp = await API.setPluginEnabled(key, next); - if (resp?.ever_enabled !== undefined) { - setPlugins((prev) => - prev.map((pl) => - pl.key === key - ? { - ...pl, - ever_enabled: resp.ever_enabled, - enabled: resp.enabled, - } - : pl - ) - ); - } else { - setPlugins((prev) => - prev.map((pl) => - pl.key === key ? { ...pl, enabled: next } : pl - ) - ); - } - return resp; - }} - onRequireTrust={requireTrust} - onRequestDelete={(plugin) => { - setDeleteTarget(plugin); - setDeleteOpen(true); - }} - /> - ))} - - {plugins.length === 0 && ( - - - No plugins found. Drop a plugin into /data/plugins{' '} - and reload. - - - )} - - )} + + {/* Import Plugin Modal */} { - setImporting(true); - const id = notifications.show({ - title: 'Uploading plugin', - message: 'Backend may restart; please wait…', - loading: true, - autoClose: false, - withCloseButton: false, - }); - setUploadNoticeId(id); - try { - const resp = await API.importPlugin(importFile); - if (resp?.success && resp.plugin) { - setImported(resp.plugin); - setPlugins((prev) => [ - resp.plugin, - ...prev.filter((p) => p.key !== resp.plugin.key), - ]); - notifications.update({ - id, - loading: false, - color: 'green', - title: 'Imported', - message: - 'Plugin imported. If the app briefly disconnected, it should be back now.', - autoClose: 3000, - }); - } else { - notifications.update({ - id, - loading: false, - color: 'red', - title: 'Import failed', - message: resp?.error || 'Unknown error', - autoClose: 5000, - }); - } - } catch (e) { - // API.importPlugin already showed a concise error; just update the loading notice - notifications.update({ - id, - loading: false, - color: 'red', - title: 'Import failed', - message: - (e?.body && (e.body.error || e.body.detail)) || - e?.message || - 'Failed', - autoClose: 5000, - }); - } finally { - setImporting(false); - setUploadNoticeId(null); - } - }} + onClick={handleImportPlugin()} > Upload @@ -612,36 +367,7 @@ export default function PluginsPage() { @@ -727,33 +453,37 @@ export default function PluginsPage() { size="xs" color="red" loading={deleting} - onClick={async () => { - if (!deleteTarget) return; - setDeleting(true); - try { - const resp = await API.deletePlugin(deleteTarget.key); - if (resp?.success) { - setPlugins((prev) => - prev.filter((p) => p.key !== deleteTarget.key) - ); - notifications.show({ - title: deleteTarget.name, - message: 'Plugin deleted', - color: 'green', - }); - } - setDeleteOpen(false); - setDeleteTarget(null); - } finally { - setDeleting(false); - } - }} + onClick={handleDeletePlugin()} > Delete - + + {/* Confirmation modal */} + handleConfirm(false)} + title={confirmConfig.title} + centered + > + + {confirmConfig.message} + + + + + + + ); } diff --git a/frontend/src/utils/cards/PluginCardUtils.js b/frontend/src/utils/cards/PluginCardUtils.js new file mode 100644 index 00000000..8752e019 --- /dev/null +++ b/frontend/src/utils/cards/PluginCardUtils.js @@ -0,0 +1,24 @@ +export const getConfirmationDetails = (action, plugin, settings) => { + const actionConfirm = action.confirm; + const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm'); + let requireConfirm = false; + let confirmTitle = `Run ${action.label}?`; + let confirmMessage = `You're about to run "${action.label}" from "${plugin.name}".`; + + if (actionConfirm) { + if (typeof actionConfirm === 'boolean') { + requireConfirm = actionConfirm; + } else if (typeof actionConfirm === 'object') { + requireConfirm = actionConfirm.required !== false; + if (actionConfirm.title) confirmTitle = actionConfirm.title; + if (actionConfirm.message) confirmMessage = actionConfirm.message; + } + } else if (confirmField) { + const settingVal = settings?.confirm; + const effectiveConfirm = + (settingVal !== undefined ? settingVal : confirmField.default) ?? false; + requireConfirm = !!effectiveConfirm; + } + + return { requireConfirm, confirmTitle, confirmMessage }; +}; diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js index baf91b54..ba965343 100644 --- a/frontend/src/utils/notificationUtils.js +++ b/frontend/src/utils/notificationUtils.js @@ -1,5 +1,9 @@ import { notifications } from '@mantine/notifications'; export function showNotification(notificationObject) { - notifications.show(notificationObject); + return notifications.show(notificationObject); +} + +export function updateNotification(notificationId, notificationObject) { + return notifications.update(notificationId, notificationObject); } \ No newline at end of file diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js new file mode 100644 index 00000000..bae98e93 --- /dev/null +++ b/frontend/src/utils/pages/PluginsUtils.js @@ -0,0 +1,17 @@ +import API from '../../api.js'; + +export const updatePluginSettings = async (key, settings) => { + return await API.updatePluginSettings(key, settings); +}; +export const runPluginAction = async (key, actionId) => { + return await API.runPluginAction(key, actionId); +}; +export const setPluginEnabled = async (key, next) => { + return await API.setPluginEnabled(key, next); +}; +export const importPlugin = async (importFile) => { + return await API.importPlugin(importFile); +}; +export const deletePluginByKey = (key) => { + return API.deletePlugin(key); +}; \ No newline at end of file From 26d9dbd246444a2ba1908d88493517c40d942dfa Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:35:53 -0800 Subject: [PATCH 63/80] Added plugins store --- frontend/src/store/plugins.jsx | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/src/store/plugins.jsx diff --git a/frontend/src/store/plugins.jsx b/frontend/src/store/plugins.jsx new file mode 100644 index 00000000..e8d0b065 --- /dev/null +++ b/frontend/src/store/plugins.jsx @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import API from '../api'; + +export const usePluginStore = create((set, get) => ({ + plugins: [], + loading: false, + error: null, + + fetchPlugins: async () => { + set({ loading: true, error: null }); + try { + const response = await API.getPlugins(); + set({ plugins: response || [], loading: false }); + } catch (error) { + set({ error, loading: false }); + } + }, + + updatePlugin: (key, updates) => { + set((state) => ({ + plugins: state.plugins.map((p) => + p.key === key ? { ...p, ...updates } : p + ), + })); + }, + + addPlugin: (plugin) => { + set((state) => ({ plugins: [...state.plugins, plugin] })); + }, + + removePlugin: (key) => { + set((state) => ({ + plugins: state.plugins.filter((p) => p.key !== key), + })); + }, + + invalidatePlugins: () => { + set({ plugins: [] }); + get().fetchPlugins(); + }, +})); \ No newline at end of file From ffa1331c3bad10c6309d8584b52f1837f68de00c Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Dec 2025 23:17:42 -0800 Subject: [PATCH 64/80] Updated to use util functions --- frontend/src/pages/DVR.jsx | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 8e39cf2c..b1cc1fe8 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -18,13 +18,14 @@ import useSettingsStore from '../store/settings'; import useVideoStore from '../store/useVideoStore'; import RecordingForm from '../components/forms/Recording'; import { + isAfter, isBefore, useTimeHelpers, } from '../utils/dateTimeUtils.js'; const RecordingDetailsModal = lazy(() => import('../components/forms/RecordingDetailsModal')); import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx'; import RecordingCard from '../components/cards/RecordingCard.jsx'; import { categorizeRecordings } from '../utils/pages/DVRUtils.js'; -import { getPosterUrl } from '../utils/cards/RecordingCardUtils.js'; +import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; const DVRPage = () => { @@ -110,30 +111,20 @@ const DVRPage = () => { const now = userNow(); const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if(isAfter(now, s) && isBefore(now, e)) { // 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}`; - } + const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode); useVideoStore.getState().showVideo(url, 'live'); } } const handleOnWatchRecording = () => { - let fileUrl = - detailsRecording.custom_properties?.file_url || - detailsRecording.custom_properties?.output_file_url; - if (!fileUrl) return; - if ( - useSettingsStore.getState().environment.env_mode === 'dev' && - fileUrl.startsWith('/') - ) { - fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; - } - useVideoStore.getState().showVideo(fileUrl, 'vod', { + const url = getRecordingUrl( + detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode); + if(!url) return; + useVideoStore.getState().showVideo(url, 'vod', { name: detailsRecording.custom_properties?.program?.title || 'Recording', @@ -163,7 +154,7 @@ const DVRPage = () => { > New Recording - +
Currently Recording From 43525ca32a6cf170f672a895f5df5de3c04019d0 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Dec 2025 23:49:06 -0800 Subject: [PATCH 65/80] Moved RecordingList outside of DVRPage Helps to prevent renders --- frontend/src/pages/DVR.jsx | 46 +++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index b1cc1fe8..7bd6e07f 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -18,16 +18,29 @@ import useSettingsStore from '../store/settings'; import useVideoStore from '../store/useVideoStore'; import RecordingForm from '../components/forms/Recording'; import { - isAfter, isBefore, + isAfter, + isBefore, useTimeHelpers, } from '../utils/dateTimeUtils.js'; -const RecordingDetailsModal = lazy(() => import('../components/forms/RecordingDetailsModal')); +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, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; +const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => { + return list.map((rec) => ( + + )); +}; + const DVRPage = () => { const theme = useMantineTheme(); const recordings = useChannelsStore((s) => s.recordings); @@ -95,17 +108,6 @@ const DVRPage = () => { return categorizeRecordings(recordings, toUserTime, now); }, [recordings, now, toUserTime]); - const RecordingList = ({ list }) => { - return list.map((rec) => ( - - )); - }; - const handleOnWatchLive = () => { const rec = detailsRecording; const now = userNow(); @@ -168,7 +170,11 @@ const DVRPage = () => { { maxWidth: '36rem', cols: 1 }, ]} > - {} + {} {inProgress.length === 0 && ( Nothing recording right now. @@ -190,7 +196,11 @@ const DVRPage = () => { { maxWidth: '36rem', cols: 1 }, ]} > - {} + {} {upcoming.length === 0 && ( No upcoming recordings. @@ -212,7 +222,11 @@ const DVRPage = () => { { maxWidth: '36rem', cols: 1 }, ]} > - {} + {} {completed.length === 0 && ( No completed recordings yet. From d9fc0e68d69fa683247edbff084909e22542b1b6 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:18:42 -0800 Subject: [PATCH 66/80] Signaling ready when no StreamTable rendered --- frontend/src/pages/Channels.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index 0fe4f7a7..b7b87b17 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -65,6 +65,7 @@ const PageContent = () => { if (!authUser.id) return <>; if (authUser.user_level <= USER_LEVELS.STANDARD) { + handleStreamsReady(); return ( From b157159b8706aa91b5d8bb0a6b79eeb64fb6557d Mon Sep 17 00:00:00 2001 From: sethwv-alt Date: Wed, 31 Dec 2025 12:16:19 -0500 Subject: [PATCH 67/80] Fix root-owned __pycache__ by running Django commands as non-root user --- docker/Dockerfile | 3 --- docker/entrypoint.sh | 8 ++++---- docker/init/03-init-dispatcharr.sh | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index dc437227..bfb35c11 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,9 +35,6 @@ RUN rm -rf /app/frontend # Copy built frontend assets COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist -# Run Django collectstatic -RUN python manage.py collectstatic --noinput - # Add timestamp argument ARG TIMESTAMP diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 72eb5928..5de9bf0a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -100,7 +100,7 @@ export POSTGRES_DIR=/data/db if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then # Define all variables to process variables=( - PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED + PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT @@ -174,9 +174,9 @@ else pids+=("$nginx_pid") fi -cd /app -python manage.py migrate --noinput -python manage.py collectstatic --noinput +# Run Django commands as non-root user to prevent permission issues +su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput" +su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput" # Select proper uwsgi config based on environment if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 03fe6816..0c317017 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -15,6 +15,7 @@ DATA_DIRS=( APP_DIRS=( "/app/logo_cache" "/app/media" + "/app/static" ) # Create all directories From a6361a07d2e42632736011ba51bcad9794ba7c73 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:31:29 -0800 Subject: [PATCH 68/80] Extracted component and util logic --- .../forms/settings/DvrSettingsForm.jsx | 263 +++ .../forms/settings/NetworkAccessForm.jsx | 161 ++ .../forms/settings/ProxySettingsForm.jsx | 166 ++ .../forms/settings/StreamSettingsForm.jsx | 306 ++++ .../forms/settings/SystemSettingsForm.jsx | 84 + .../forms/settings/UiSettingsForm.jsx | 142 ++ frontend/src/pages/Logos.jsx | 65 +- frontend/src/pages/Settings.jsx | 1474 ++--------------- frontend/src/utils/dateTimeUtils.js | 173 +- .../forms/settings/DvrSettingsFormUtils.js | 22 + .../forms/settings/NetworkAccessFormUtils.js | 29 + .../forms/settings/ProxySettingsFormUtils.js | 18 + .../forms/settings/StreamSettingsFormUtils.js | 19 + .../forms/settings/SystemSettingsFormUtils.js | 5 + .../forms/settings/UiSettingsFormUtils.js | 14 + frontend/src/utils/networkUtils.js | 4 + frontend/src/utils/pages/SettingsUtils.js | 104 ++ 17 files changed, 1660 insertions(+), 1389 deletions(-) create mode 100644 frontend/src/components/forms/settings/DvrSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/NetworkAccessForm.jsx create mode 100644 frontend/src/components/forms/settings/ProxySettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/StreamSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/SystemSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/UiSettingsForm.jsx create mode 100644 frontend/src/utils/forms/settings/DvrSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/NetworkAccessFormUtils.js create mode 100644 frontend/src/utils/forms/settings/ProxySettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/StreamSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/SystemSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/UiSettingsFormUtils.js create mode 100644 frontend/src/utils/networkUtils.js create mode 100644 frontend/src/utils/pages/SettingsUtils.js diff --git a/frontend/src/components/forms/settings/DvrSettingsForm.jsx b/frontend/src/components/forms/settings/DvrSettingsForm.jsx new file mode 100644 index 00000000..f03bdf66 --- /dev/null +++ b/frontend/src/components/forms/settings/DvrSettingsForm.jsx @@ -0,0 +1,263 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { + Alert, + Button, + FileInput, + Flex, + Group, + NumberInput, + Stack, + Switch, + Text, + TextInput, +} from '@mantine/core'; +import { + getComskipConfig, + getDvrSettingsFormInitialValues, + uploadComskipIni, +} from '../../../utils/forms/settings/DvrSettingsFormUtils.js'; +import { useForm } from '@mantine/form'; + +const DvrSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + const [saved, setSaved] = useState(false); + const [comskipFile, setComskipFile] = useState(null); + const [comskipUploadLoading, setComskipUploadLoading] = useState(false); + const [comskipConfig, setComskipConfig] = useState({ + path: '', + exists: false, + }); + + const form = useForm({ + mode: 'controlled', + initialValues: getDvrSettingsFormInitialValues(), + }); + + useEffect(() => { + if (!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + const formValues = parseSettings(settings); + + form.setValues(formValues); + + if (formValues['dvr-comskip-custom-path']) { + setComskipConfig((prev) => ({ + path: formValues['dvr-comskip-custom-path'], + exists: prev.exists, + })); + } + } + }, [settings]); + + useEffect(() => { + const loadComskipConfig = async () => { + try { + const response = await getComskipConfig(); + if (response) { + setComskipConfig({ + path: response.path || '', + exists: Boolean(response.exists), + }); + if (response.path) { + form.setFieldValue('dvr-comskip-custom-path', response.path); + } + } + } catch (error) { + console.error('Failed to load comskip config', error); + } + }; + loadComskipConfig(); + }, []); + + const onComskipUpload = async () => { + if (!comskipFile) { + return; + } + + setComskipUploadLoading(true); + try { + const response = await uploadComskipIni(comskipFile); + if (response?.path) { + showNotification({ + title: 'comskip.ini uploaded', + message: response.path, + autoClose: 3000, + color: 'green', + }); + form.setFieldValue('dvr-comskip-custom-path', response.path); + useSettingsStore.getState().updateSetting({ + ...(settings['dvr-comskip-custom-path'] || { + key: 'dvr-comskip-custom-path', + name: 'DVR Comskip Custom Path', + }), + value: response.path, + }); + setComskipConfig({ path: response.path, exists: true }); + } + } catch (error) { + console.error('Failed to upload comskip.ini', error); + } finally { + setComskipUploadLoading(false); + setComskipFile(null); + } + }; + + const onSubmit = async () => { + setSaved(false); + + const changedSettings = getChangedSettings(form.getValues(), settings); + + // Update each changed setting in the backend (create if missing) + try { + await saveChangedSettings(settings, changedSettings); + + setSaved(true); + } catch (error) { + // Error notifications are already shown by API functions + // Just don't show the success message + console.error('Error saving settings:', error); + } + }; + + return ( +
+ + {saved && ( + + )} + + + + + + + + {comskipConfig.exists && comskipConfig.path + ? `Using ${comskipConfig.path}` + : 'No custom comskip.ini uploaded.'} + + + + + + + + + + + +
+ ); +}); + +export default DvrSettingsForm; \ No newline at end of file diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx new file mode 100644 index 00000000..1d2c42e7 --- /dev/null +++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx @@ -0,0 +1,161 @@ +import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js'; +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { useForm } from '@mantine/form'; +import { + checkSetting, + updateSetting, +} from '../../../utils/pages/SettingsUtils.js'; +import { Alert, Button, Flex, Stack, Text, TextInput } from '@mantine/core'; +import ConfirmationDialog from '../../ConfirmationDialog.jsx'; +import { + getNetworkAccessFormInitialValues, + getNetworkAccessFormValidation, +} from '../../../utils/forms/settings/NetworkAccessFormUtils.js'; + +const NetworkAccessForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + + const [networkAccessError, setNetworkAccessError] = useState(null); + const [saved, setSaved] = useState(false); + const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] = + useState(false); + const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] = + useState([]); + const [clientIpAddress, setClientIpAddress] = useState(null); + + const networkAccessForm = useForm({ + mode: 'controlled', + initialValues: getNetworkAccessFormInitialValues(), + validate: getNetworkAccessFormValidation(), + }); + + useEffect(() => { + if(!active) setSaved(false); + }, [active]); + + useEffect(() => { + const networkAccessSettings = JSON.parse( + settings['network-access'].value || '{}' + ); + networkAccessForm.setValues( + Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { + acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0'; + return acc; + }, {}) + ); + }, [settings]); + + const onNetworkAccessSubmit = async () => { + setSaved(false); + setNetworkAccessError(null); + const check = await checkSetting({ + ...settings['network-access'], + value: JSON.stringify(networkAccessForm.getValues()), + }); + + if (check.error && check.message) { + setNetworkAccessError(`${check.message}: ${check.data}`); + return; + } + + // Store the client IP + setClientIpAddress(check.client_ip); + + // For now, only warn if we're blocking the UI + const blockedAccess = check.UI; + if (blockedAccess.length === 0) { + return saveNetworkAccess(); + } + + setNetNetworkAccessConfirmCIDRs(blockedAccess); + setNetworkAccessConfirmOpen(true); + }; + + const saveNetworkAccess = async () => { + setSaved(false); + try { + await updateSetting({ + ...settings['network-access'], + value: JSON.stringify(networkAccessForm.getValues()), + }); + setSaved(true); + setNetworkAccessConfirmOpen(false); + } catch (e) { + const errors = {}; + for (const key in e.body.value) { + errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`; + } + networkAccessForm.setErrors(errors); + } + }; + + return ( + <> +
+ + {saved && ( + + )} + {networkAccessError && ( + + )} + + {Object.entries(NETWORK_ACCESS_OPTIONS).map(([key, config]) => ( + + ))} + + + + + +
+ + setNetworkAccessConfirmOpen(false)} + onConfirm={saveNetworkAccess} + title={`Confirm Network Access Blocks`} + message={ + <> + + Your client {clientIpAddress && `(${clientIpAddress}) `}is not + included in the allowed networks for the web UI. Are you sure you + want to proceed? + + +
    + {netNetworkAccessConfirmCIDRs.map((cidr) => ( +
  • {cidr}
  • + ))} +
+ + } + confirmLabel="Save" + cancelLabel="Cancel" + size="md" + /> + + ); +}); + +export default NetworkAccessForm; \ No newline at end of file diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx new file mode 100644 index 00000000..7fc2d0cb --- /dev/null +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -0,0 +1,166 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { useForm } from '@mantine/form'; +import { updateSetting } from '../../../utils/pages/SettingsUtils.js'; +import { + Alert, + Button, + Flex, + NumberInput, + Stack, + TextInput, +} from '@mantine/core'; +import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js'; +import { + getProxySettingDefaults, + getProxySettingsFormInitialValues, +} from '../../../utils/forms/settings/ProxySettingsFormUtils.js'; + +const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { + const isNumericField = (key) => { + // Determine if this field should be a NumberInput + return [ + 'buffering_timeout', + 'redis_chunk_ttl', + 'channel_shutdown_delay', + 'channel_init_grace_period', + ].includes(key); + }; + const isFloatField = (key) => { + return key === 'buffering_speed'; + }; + const getNumericFieldMax = (key) => { + return key === 'buffering_timeout' + ? 300 + : key === 'redis_chunk_ttl' + ? 3600 + : key === 'channel_shutdown_delay' + ? 300 + : 60; + }; + return ( + <> + {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => { + if (isNumericField(key)) { + return ( + + ); + } else if (isFloatField(key)) { + return ( + + ); + } else { + return ( + + ); + } + })} + + ); +}); + +const ProxySettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + + const [saved, setSaved] = useState(false); + + const proxySettingsForm = useForm({ + mode: 'controlled', + initialValues: getProxySettingsFormInitialValues(), + }); + + useEffect(() => { + if(!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + if (settings['proxy-settings']?.value) { + try { + const proxySettings = JSON.parse(settings['proxy-settings'].value); + proxySettingsForm.setValues(proxySettings); + } catch (error) { + console.error('Error parsing proxy settings:', error); + } + } + } + }, [settings]); + + const resetProxySettingsToDefaults = () => { + proxySettingsForm.setValues(getProxySettingDefaults()); + }; + + const onProxySettingsSubmit = async () => { + setSaved(false); + + try { + const result = await updateSetting({ + ...settings['proxy-settings'], + value: JSON.stringify(proxySettingsForm.getValues()), + }); + // API functions return undefined on error + if (result) { + setSaved(true); + } + } catch (error) { + // Error notifications are already shown by API functions + console.error('Error saving proxy settings:', error); + } + }; + + return ( +
+ + {saved && ( + + )} + + + + + + + + +
+ ); +}); + +export default ProxySettingsForm; \ No newline at end of file diff --git a/frontend/src/components/forms/settings/StreamSettingsForm.jsx b/frontend/src/components/forms/settings/StreamSettingsForm.jsx new file mode 100644 index 00000000..1b6b466d --- /dev/null +++ b/frontend/src/components/forms/settings/StreamSettingsForm.jsx @@ -0,0 +1,306 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import useWarningsStore from '../../../store/warnings.jsx'; +import useUserAgentsStore from '../../../store/userAgents.jsx'; +import useStreamProfilesStore from '../../../store/streamProfiles.jsx'; +import { REGION_CHOICES } from '../../../constants.js'; +import React, { useEffect, useState } from 'react'; +import { + getChangedSettings, + parseSettings, + rehashStreams, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { + Alert, + Button, + Flex, + Group, + MultiSelect, + Select, + Switch, + Text, +} from '@mantine/core'; +import ConfirmationDialog from '../../ConfirmationDialog.jsx'; +import { useForm } from '@mantine/form'; +import { + getStreamSettingsFormInitialValues, + getStreamSettingsFormValidation, +} from '../../../utils/forms/settings/StreamSettingsFormUtils.js'; + +const StreamSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const userAgents = useUserAgentsStore((s) => s.userAgents); + const streamProfiles = useStreamProfilesStore((s) => s.profiles); + const regionChoices = REGION_CHOICES; + + // Store pending changed settings when showing the dialog + const [pendingChangedSettings, setPendingChangedSettings] = useState(null); + + const [saved, setSaved] = useState(false); + const [rehashingStreams, setRehashingStreams] = useState(false); + const [rehashSuccess, setRehashSuccess] = useState(false); + const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false); + + // Add a new state to track the dialog type + const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash' + + const form = useForm({ + mode: 'controlled', + initialValues: getStreamSettingsFormInitialValues(), + validate: getStreamSettingsFormValidation(), + }); + + useEffect(() => { + if (!active) { + setSaved(false); + setRehashSuccess(false); + } + }, [active]); + + useEffect(() => { + if (settings) { + const formValues = parseSettings(settings); + + form.setValues(formValues); + } + }, [settings]); + + const executeSettingsSaveAndRehash = async () => { + setRehashConfirmOpen(false); + setSaved(false); + + // Use the stored pending values that were captured before the dialog was shown + const changedSettings = pendingChangedSettings || {}; + + // Update each changed setting in the backend (create if missing) + try { + await saveChangedSettings(settings, changedSettings); + + // Clear the pending values + setPendingChangedSettings(null); + setSaved(true); + } catch (error) { + // Error notifications are already shown by API functions + // Just don't show the success message + console.error('Error saving settings:', error); + setPendingChangedSettings(null); + } + }; + + const executeRehashStreamsOnly = async () => { + setRehashingStreams(true); + setRehashSuccess(false); + setRehashConfirmOpen(false); + + try { + await rehashStreams(); + setRehashSuccess(true); + setTimeout(() => setRehashSuccess(false), 5000); + } catch (error) { + console.error('Error rehashing streams:', error); + } finally { + setRehashingStreams(false); + } + }; + + const onRehashStreams = async () => { + // Skip warning if it's been suppressed + if (isWarningSuppressed('rehash-streams')) { + return executeRehashStreamsOnly(); + } + + setRehashDialogType('rehash'); // Set dialog type to rehash + setRehashConfirmOpen(true); + }; + + const handleRehashConfirm = () => { + if (rehashDialogType === 'save') { + executeSettingsSaveAndRehash(); + } else { + executeRehashStreamsOnly(); + } + }; + + const onSubmit = async () => { + setSaved(false); + + const values = form.getValues(); + const changedSettings = getChangedSettings(values, settings); + + const m3uHashKeyChanged = + settings['m3u-hash-key']?.value !== values['m3u-hash-key'].join(','); + + // If M3U hash key changed, show warning (unless suppressed) + if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) { + // Store the changed settings before showing dialog + setPendingChangedSettings(changedSettings); + setRehashDialogType('save'); // Set dialog type to save + setRehashConfirmOpen(true); + return; + } + + // Update each changed setting in the backend (create if missing) + try { + await saveChangedSettings(settings, changedSettings); + + setSaved(true); + } catch (error) { + // Error notifications are already shown by API functions + // Just don't show the success message + console.error('Error saving settings:', error); + } + }; + + return ( + <> +
+ {saved && ( + + )} + ({ + value: `${option.id}`, + label: option.name, + }))} + /> + onUISettingsChange('table-size', val)} + data={[ + { + value: 'default', + label: 'Default', + }, + { + value: 'compact', + label: 'Compact', + }, + { + value: 'large', + label: 'Large', + }, + ]} + /> + onUISettingsChange('date-format', val)} + data={[ + { + value: 'mdy', + label: 'MM/DD/YYYY', + }, + { + value: 'dmy', + label: 'DD/MM/YYYY', + }, + ]} + /> + onUISettingsChange('table-size', val)} - data={[ - { - value: 'default', - label: 'Default', - }, - { - value: 'compact', - label: 'Compact', - }, - { - value: 'large', - label: 'Large', - }, - ]} - /> - onUISettingsChange('date-format', val)} - data={[ - { - value: 'mdy', - label: 'MM/DD/YYYY', - }, - { - value: 'dmy', - label: 'DD/MM/YYYY', - }, - ]} - /> - ({ - value: `${option.id}`, - label: option.name, - }))} - /> - ({ - label: r.label, - value: `${r.value}`, - }))} - /> + + DVR + + + }> + + + + + - - - Auto-Import Mapped Files - - - + + Stream Settings + + + }> + + + + + - + + System Settings + + + }> + + + + + - {rehashSuccess && ( - - )} + + User-Agents + + + }> + + + + + - - - - - - - + + Stream Profiles + + + }> + + + + + - - System Settings - - - {generalSettingsSaved && ( - - )} - - Configure how many system events (channel start/stop, - buffering, etc.) to keep in the database. Events are - displayed on the Stats page. - - { - form.setFieldValue('max-system-events', value); - }} - min={10} - max={1000} - step={10} - /> - - - - - - - - - User-Agents - - - - - - - Stream Profiles - - - - - - - + + Network Access - {accordianValue == 'network-access' && ( + {accordianValue === 'network-access' && ( Comma-Delimited CIDR ranges )} - - -
- - {networkAccessSaved && ( - - )} - {networkAccessError && ( - - )} - {Object.entries(NETWORK_ACCESS_OPTIONS).map( - ([key, config]) => { - return ( - - ); - } - )} + + + + }> + + + + + - - - - -
-
-
- - - + + Proxy Settings - - -
- - {proxySettingsSaved && ( - - )} - {Object.entries(PROXY_SETTINGS_OPTIONS).map( - ([key, config]) => { - // Determine if this field should be a NumberInput - const isNumericField = [ - 'buffering_timeout', - 'redis_chunk_ttl', - 'channel_shutdown_delay', - 'channel_init_grace_period', - ].includes(key); + + + + }> + + + + + - const isFloatField = key === 'buffering_speed'; - - if (isNumericField) { - return ( - - ); - } else if (isFloatField) { - return ( - - ); - } else { - return ( - - ); - } - } - )} - - - - - - -
-
-
- - - Backup & Restore - - - - + + Backup & Restore + + + }> + + + + + )}
- - { - setRehashConfirmOpen(false); - setRehashDialogType(null); - // Clear pending values when dialog is cancelled - setPendingChangedSettings(null); - }} - onConfirm={handleRehashConfirm} - title={ - rehashDialogType === 'save' - ? 'Save Settings and Rehash Streams' - : 'Confirm Stream Rehash' - } - message={ -
- {`Are you sure you want to rehash all streams? - -This process may take a while depending on the number of streams. -Do not shut down Dispatcharr until the rehashing is complete. -M3U refreshes will be blocked until this process finishes. - -Please ensure you have time to let this complete before proceeding.`} -
- } - confirmLabel={ - rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash' - } - cancelLabel="Cancel" - actionKey="rehash-streams" - onSuppressChange={suppressWarning} - size="md" - /> - - setNetworkAccessConfirmOpen(false)} - onConfirm={saveNetworkAccess} - title={`Confirm Network Access Blocks`} - message={ - <> - - Your client {clientIpAddress && `(${clientIpAddress}) `}is not included in the allowed networks for the web - UI. Are you sure you want to proceed? - - -
    - {netNetworkAccessConfirmCIDRs.map((cidr) => ( -
  • {cidr}
  • - ))} -
- - } - confirmLabel="Save" - cancelLabel="Cancel" - size="md" - /> ); }; diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index b7490f88..7b6c6f2f 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -12,6 +12,41 @@ dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(timezone); +export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); + +export const initializeTime = (dateTime) => dayjs(dateTime); + +export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); + +export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2); + +export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2); + +export const isSame = (date1, date2, unit = 'day') => + dayjs(date1).isSame(date2, unit); + +export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit); + +export const diff = (date1, date2, unit = 'millisecond') => + dayjs(date1).diff(date2, unit); + +export const format = (dateTime, formatStr) => + dayjs(dateTime).format(formatStr); + +export const getNow = () => dayjs(); + +export const getNowMs = () => Date.now(); + +export const roundToNearest = (dateTime, minutes) => { + const current = initializeTime(dateTime); + const minute = current.minute(); + const snappedMinute = Math.round(minute / minutes) * minutes; + + return snappedMinute === 60 + ? current.add(1, 'hour').minute(0) + : current.minute(snappedMinute); +}; + export const useUserTimeZone = () => { const settings = useSettingsStore((s) => s.settings); const [timeZone, setTimeZone] = useLocalStorage( @@ -68,7 +103,7 @@ export const useDateTimeFormat = () => { const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm'; const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM'; - return [timeFormat, dateFormat] + return [timeFormat, dateFormat]; }; export const toTimeString = (value) => { @@ -86,4 +121,138 @@ export const parseDate = (value) => { if (!value) return null; const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true); return parsed.isValid() ? parsed.toDate() : null; +}; + +const TIMEZONE_FALLBACKS = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Phoenix', + 'America/Anchorage', + 'Pacific/Honolulu', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Warsaw', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Australia/Sydney', +]; + +const getSupportedTimeZones = () => { + try { + if (typeof Intl.supportedValuesOf === 'function') { + return Intl.supportedValuesOf('timeZone'); + } + } catch (error) { + console.warn('Unable to enumerate supported time zones:', error); + } + return TIMEZONE_FALLBACKS; +}; + +const getTimeZoneOffsetMinutes = (date, timeZone) => { + try { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + const parts = dtf.formatToParts(date).reduce((acc, part) => { + if (part.type !== 'literal') acc[part.type] = part.value; + return acc; + }, {}); + const asUTC = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + return (asUTC - date.getTime()) / 60000; + } catch (error) { + console.warn(`Failed to compute offset for ${timeZone}:`, error); + return 0; + } +}; + +const formatOffset = (minutes) => { + const rounded = Math.round(minutes); + const sign = rounded < 0 ? '-' : '+'; + const absolute = Math.abs(rounded); + const hours = String(Math.floor(absolute / 60)).padStart(2, '0'); + const mins = String(absolute % 60).padStart(2, '0'); + return `UTC${sign}${hours}:${mins}`; +}; + +export const buildTimeZoneOptions = (preferredZone) => { + const zones = getSupportedTimeZones(); + const referenceYear = new Date().getUTCFullYear(); + const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0)); + const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0)); + + const options = zones + .map((zone) => { + const janOffset = getTimeZoneOffsetMinutes(janDate, zone); + const julOffset = getTimeZoneOffsetMinutes(julDate, zone); + const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone); + const minOffset = Math.min(janOffset, julOffset); + const maxOffset = Math.max(janOffset, julOffset); + const usesDst = minOffset !== maxOffset; + const labelParts = [`now ${formatOffset(currentOffset)}`]; + if (usesDst) { + labelParts.push( + `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}` + ); + } + return { + value: zone, + label: `${zone} (${labelParts.join(' | ')})`, + numericOffset: minOffset, + }; + }) + .sort((a, b) => { + if (a.numericOffset !== b.numericOffset) { + return a.numericOffset - b.numericOffset; + } + return a.value.localeCompare(b.value); + }); + if ( + preferredZone && + !options.some((option) => option.value === preferredZone) + ) { + const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone); + options.push({ + value: preferredZone, + label: `${preferredZone} (now ${formatOffset(currentOffset)})`, + numericOffset: currentOffset, + }); + options.sort((a, b) => { + if (a.numericOffset !== b.numericOffset) { + return a.numericOffset - b.numericOffset; + } + return a.value.localeCompare(b.value); + }); + } + return options; +}; + +export const getDefaultTimeZone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch (error) { + return 'UTC'; + } }; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js new file mode 100644 index 00000000..7fa272d0 --- /dev/null +++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js @@ -0,0 +1,22 @@ +import API from '../../../api.js'; + +export const getComskipConfig = async () => { + return await API.getComskipConfig(); +}; + +export const uploadComskipIni = async (file) => { + return await API.uploadComskipIni(file); +}; + +export const getDvrSettingsFormInitialValues = () => { + return { + 'dvr-tv-template': '', + 'dvr-movie-template': '', + 'dvr-tv-fallback-template': '', + 'dvr-movie-fallback-template': '', + 'dvr-comskip-enabled': false, + 'dvr-comskip-custom-path': '', + 'dvr-pre-offset-minutes': 0, + 'dvr-post-offset-minutes': 0, + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js new file mode 100644 index 00000000..fe1eea8a --- /dev/null +++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js @@ -0,0 +1,29 @@ +import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js'; +import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js'; + +export const getNetworkAccessFormInitialValues = () => { + return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { + acc[key] = '0.0.0.0/0,::/0'; + return acc; + }, {}); +}; + +export const getNetworkAccessFormValidation = () => { + return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { + acc[key] = (value) => { + if ( + value + .split(',') + .some( + (cidr) => + !(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX)) + ) + ) { + return 'Invalid CIDR range'; + } + + return null; + }; + return acc; + }, {}); +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js new file mode 100644 index 00000000..864dd9b1 --- /dev/null +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -0,0 +1,18 @@ +import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js'; + +export const getProxySettingsFormInitialValues = () => { + return Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => { + acc[key] = ''; + return acc; + }, {}); +}; + +export const getProxySettingDefaults = () => { + return { + buffering_timeout: 15, + buffering_speed: 1.0, + redis_chunk_ttl: 60, + channel_shutdown_delay: 0, + channel_init_grace_period: 5, + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js new file mode 100644 index 00000000..2ff5dd55 --- /dev/null +++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js @@ -0,0 +1,19 @@ +import { isNotEmpty } from '@mantine/form'; + +export const getStreamSettingsFormInitialValues = () => { + return { + 'default-user-agent': '', + 'default-stream-profile': '', + 'preferred-region': '', + 'auto-import-mapped-files': true, + 'm3u-hash-key': [], + }; +}; + +export const getStreamSettingsFormValidation = () => { + return { + 'default-user-agent': isNotEmpty('Select a user agent'), + 'default-stream-profile': isNotEmpty('Select a stream profile'), + 'preferred-region': isNotEmpty('Select a region'), + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js new file mode 100644 index 00000000..75c4f513 --- /dev/null +++ b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js @@ -0,0 +1,5 @@ +export const getSystemSettingsFormInitialValues = () => { + return { + 'max-system-events': 100, + }; +}; diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js new file mode 100644 index 00000000..79e99d96 --- /dev/null +++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js @@ -0,0 +1,14 @@ +import { createSetting, updateSetting } from '../../pages/SettingsUtils.js'; + +export const saveTimeZoneSetting = async (tzValue, settings) => { + const existing = settings['system-time-zone']; + if (existing?.id) { + await updateSetting({ ...existing, value: tzValue }); + } else { + await createSetting({ + key: 'system-time-zone', + name: 'System Time Zone', + value: tzValue, + }); + } +}; \ No newline at end of file diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js new file mode 100644 index 00000000..8562face --- /dev/null +++ b/frontend/src/utils/networkUtils.js @@ -0,0 +1,4 @@ +export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/; + +export const IPV6_CIDR_REGEX = + /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/; diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js new file mode 100644 index 00000000..e6179f06 --- /dev/null +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -0,0 +1,104 @@ +import API from '../../api.js'; + +export const checkSetting = async (values) => { + return await API.checkSetting(values); +}; + +export const updateSetting = async (values) => { + return await API.updateSetting(values); +}; + +export const createSetting = async (values) => { + return await API.createSetting(values); +}; + +export const rehashStreams = async () => { + return await API.rehashStreams(); +}; + +export const saveChangedSettings = async (settings, changedSettings) => { + for (const updatedKey in changedSettings) { + const existing = settings[updatedKey]; + if (existing?.id) { + const result = await updateSetting({ + ...existing, + value: changedSettings[updatedKey], + }); + // API functions return undefined on error + if (!result) { + throw new Error('Failed to update setting'); + } + } else { + const result = await createSetting({ + key: updatedKey, + name: updatedKey.replace(/-/g, ' '), + value: changedSettings[updatedKey], + }); + // API functions return undefined on error + if (!result) { + throw new Error('Failed to create setting'); + } + } + } +}; + +export const getChangedSettings = (values, settings) => { + const changedSettings = {}; + + for (const settingKey in values) { + // Only compare against existing value if the setting exists + const existing = settings[settingKey]; + + // Convert array values (like m3u-hash-key) to comma-separated strings + const stringValue = Array.isArray(values[settingKey]) + ? values[settingKey].join(',') + : `${values[settingKey]}`; + + // Skip empty values to avoid validation errors + if (!stringValue) { + continue; + } + + if (!existing) { + // Create new setting on save + changedSettings[settingKey] = stringValue; + } else if (stringValue !== String(existing.value)) { + // If the user changed the setting's value from what's in the DB: + changedSettings[settingKey] = stringValue; + } + } + return changedSettings; +}; + +export const parseSettings = (settings) => { + return Object.entries(settings).reduce((acc, [key, value]) => { + // Modify each value based on its own properties + switch (value.value) { + case 'true': + value.value = true; + break; + case 'false': + value.value = false; + break; + } + + let val = null; + switch (key) { + case 'm3u-hash-key': + // Split comma-separated string, filter out empty strings + val = value.value ? value.value.split(',').filter((v) => v) : []; + break; + case 'dvr-pre-offset-minutes': + case 'dvr-post-offset-minutes': + val = Number.parseInt(value.value || '0', 10); + if (Number.isNaN(val)) val = 0; + break; + default: + val = value.value; + break; + } + + acc[key] = val; + return acc; + }, {}); +}; \ No newline at end of file From 7b1a85617f1933121377cbf29bdce838a9b609ea Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:55:14 -0800 Subject: [PATCH 69/80] Minor changes Exporting UiSettingsForm as default Reverted admin level type check --- frontend/src/components/forms/settings/UiSettingsForm.jsx | 6 ++++-- frontend/src/pages/Settings.jsx | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/forms/settings/UiSettingsForm.jsx b/frontend/src/components/forms/settings/UiSettingsForm.jsx index 69feec74..c0f7b354 100644 --- a/frontend/src/components/forms/settings/UiSettingsForm.jsx +++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx @@ -9,7 +9,7 @@ import { showNotification } from '../../../utils/notificationUtils.js'; import { Select } from '@mantine/core'; import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js'; -export const UiSettingsForm = React.memo(() => { +const UiSettingsForm = React.memo(() => { const settings = useSettingsStore((s) => s.settings); const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); @@ -139,4 +139,6 @@ export const UiSettingsForm = React.memo(() => { /> ); -}); \ No newline at end of file +}); + +export default UiSettingsForm; \ No newline at end of file diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index a1a54435..4ce519a3 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -17,7 +17,7 @@ const BackupManager = React.lazy(() => import('../components/backups/BackupManager.jsx')); import useAuthStore from '../store/auth'; import { USER_LEVELS } from '../constants'; -import { UiSettingsForm } from '../components/forms/settings/UiSettingsForm.jsx'; +import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; const NetworkAccessForm = React.lazy(() => import('../components/forms/settings/NetworkAccessForm.jsx')); @@ -52,7 +52,7 @@ const SettingsPage = () => { - {authUser.user_level === USER_LEVELS.ADMIN && ( + {authUser.user_level == USER_LEVELS.ADMIN && ( <> DVR From b4b0774189bf71470c598958f8b00677e676b2bb Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:20:09 -0800 Subject: [PATCH 70/80] Including notification util changes --- frontend/src/pages/Logos.jsx | 3 ++- frontend/src/utils/notificationUtils.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/notificationUtils.js diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx index dd0bb5ad..f95212d6 100644 --- a/frontend/src/pages/Logos.jsx +++ b/frontend/src/pages/Logos.jsx @@ -4,6 +4,7 @@ import useLogosStore from '../store/logos'; import useVODLogosStore from '../store/vodLogos'; import LogosTable from '../components/tables/LogosTable'; import VODLogosTable from '../components/tables/VODLogosTable'; +import { showNotification } from '../utils/notificationUtils.js'; const LogosPage = () => { const logos = useLogosStore(s => s.logos); @@ -20,7 +21,7 @@ const LogosPage = () => { await useLogosStore.getState().fetchAllLogos(); } } catch (err) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to load channel logos', color: 'red', diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js new file mode 100644 index 00000000..ba965343 --- /dev/null +++ b/frontend/src/utils/notificationUtils.js @@ -0,0 +1,9 @@ +import { notifications } from '@mantine/notifications'; + +export function showNotification(notificationObject) { + return notifications.show(notificationObject); +} + +export function updateNotification(notificationId, notificationObject) { + return notifications.update(notificationId, notificationObject); +} \ No newline at end of file From c57f9fd7e7db1db5c30cbb4a9152dcc660bb74b3 Mon Sep 17 00:00:00 2001 From: patchy8736 <55986823+patchy8736@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:57:27 +0100 Subject: [PATCH 71/80] Fix episode processing issues in VOD tasks - Ensure season and episode numbers are properly converted to integers with error handling - Remove zero-padding from debug log format for season/episode numbers - Add validation to filter out relations with unsaved episodes that have no primary key - Add proper logging for skipped relations when episode is not saved to database These changes address potential crashes when API returns string values instead of integers and prevent database errors when bulk creation operations fail silently due to conflicts. Fixes issue #770 --- apps/vod/tasks.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index d42be946..6c874de1 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1292,8 +1292,15 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) try: episode_id = str(episode_data.get('id')) episode_name = episode_data.get('title', 'Unknown Episode') - season_number = episode_data['_season_number'] - episode_number = episode_data.get('episode_num', 0) + # Ensure season and episode numbers are integers (API may return strings) + try: + season_number = int(episode_data['_season_number']) + except (ValueError, TypeError): + season_number = 0 + try: + episode_number = int(episode_data.get('episode_num', 0)) + except (ValueError, TypeError): + episode_number = 0 info = episode_data.get('info', {}) # Extract episode metadata @@ -1324,7 +1331,7 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Check if we already have this episode pending creation (multiple streams for same episode) if not episode and episode_key in episodes_pending_creation: episode = episodes_pending_creation[episode_key] - logger.debug(f"Reusing pending episode for S{season_number:02d}E{episode_number:02d} (stream_id: {episode_id})") + logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})") if episode: # Update existing episode @@ -1432,6 +1439,21 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) if key in episode_pk_map: relation.episode = episode_pk_map[key] + # Filter out relations with unsaved episodes (no PK) + # This can happen if bulk_create had a conflict and ignore_conflicts=True didn't save the episode + valid_relations_to_create = [] + for relation in relations_to_create: + if relation.episode.pk is not None: + valid_relations_to_create.append(relation) + else: + season_num = relation.episode.season_number + episode_num = relation.episode.episode_number + logger.warning( + f"Skipping relation for episode S{season_num}E{episode_num} " + f"- episode not saved to database" + ) + relations_to_create = valid_relations_to_create + # Update existing episodes if episodes_to_update: Episode.objects.bulk_update(episodes_to_update, [ From 13e4b19960d8f412fda380bf1e5a123716151398 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 1 Jan 2026 18:21:52 -0600 Subject: [PATCH 72/80] changelog: Add change for settings/logo refactor. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a87240..3a2dd297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs +- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) (PR #795) + - Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files + - Moved larger nested components into their own files + - Moved business logic into corresponding utils/ files + - Extracted larger in-line component logic into its own function + - Each panel in Settings now uses its own form state with the parent component handling active state management ### Fixed From e8c9432f650c781e12dd452f778d58648be8532a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 1 Jan 2026 18:29:54 -0600 Subject: [PATCH 73/80] changelog: Update changelog for VOD category filtering. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2dd297..02c852d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant) - M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704) - M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) - Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect From 6678311fa739952fafd8fe6787fc662410cb9387 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Fri, 2 Jan 2026 02:03:50 -0800 Subject: [PATCH 74/80] Added loading overlay while programs are fetching --- frontend/src/pages/Guide.jsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 2ae80012..ac0fdf82 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -91,6 +91,8 @@ export default function TVChannelGuide({ startDate, endDate }) { const recordings = useChannelsStore((s) => s.recordings); const channelGroups = useChannelsStore((s) => s.channelGroups); const profiles = useChannelsStore((s) => s.profiles); + const isLoading = useChannelsStore((s) => s.isLoading); + const [isProgramsLoading, setIsProgramsLoading] = useState(true); const logos = useLogosStore((s) => s.logos); const tvgsById = useEPGsStore((s) => s.tvgsById); @@ -136,13 +138,22 @@ export default function TVChannelGuide({ startDate, endDate }) { if (Object.keys(channels).length === 0) { console.warn('No channels provided or empty channels array'); showNotification({ title: 'No channels available', color: 'red.5' }); + setIsProgramsLoading(false); return; } const sortedChannels = sortChannels(channels); - setGuideChannels(sortedChannels); - fetchPrograms().then((data) => setPrograms(data)); + + fetchPrograms() + .then((data) => { + setPrograms(data); + setIsProgramsLoading(false); + }) + .catch((error) => { + console.error('Failed to fetch programs:', error); + setIsProgramsLoading(false); + }); }, [channels]); // Apply filters when search, group, or profile changes @@ -1181,6 +1192,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} pos='relative' > + {nowPosition >= 0 && ( Date: Fri, 2 Jan 2026 09:53:45 -0600 Subject: [PATCH 75/80] Enhance error logging for invalid season and episode numbers in batch_process_episodes --- apps/vod/tasks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 6c874de1..4eb9fadc 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1295,11 +1295,13 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Ensure season and episode numbers are integers (API may return strings) try: season_number = int(episode_data['_season_number']) - except (ValueError, TypeError): + except (ValueError, TypeError) as e: + logger.warning(f"Invalid season_number '{episode_data.get('_season_number')}' for episode '{episode_name}': {e}") season_number = 0 try: episode_number = int(episode_data.get('episode_num', 0)) - except (ValueError, TypeError): + except (ValueError, TypeError) as e: + logger.warning(f"Invalid episode_num '{episode_data.get('episode_num')}' for episode '{episode_name}': {e}") episode_number = 0 info = episode_data.get('info', {}) From 6a985d7a7dc202b0c4ab025e381cbae8b0da0ec8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 2 Jan 2026 10:13:01 -0600 Subject: [PATCH 76/80] changelog: Update changelog for PR --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c852d2..d41d3063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs -- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) (PR #795) +- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) - Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files - Moved larger nested components into their own files - Moved business logic into corresponding utils/ files @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- VOD episode processing now properly handles season and episode numbers from APIs that return string values instead of integers, with comprehensive error logging to track data quality issues - Thanks [@patchy8736](https://github.com/patchy8736) (Fixes #770) +- VOD episode-to-stream relations are now validated to ensure episodes have been saved to the database before creating relations, preventing integrity errors when bulk_create operations encounter conflicts - Thanks [@patchy8736](https://github.com/patchy8736) - VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant) - M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704) - M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) From 131ebf9f55894ac260542c2ba492d31892902219 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 2 Jan 2026 11:29:01 -0600 Subject: [PATCH 77/80] changelog: Updated changelog for new refactor. --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d41d3063..516a9e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 + - Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis, GuideRow, HourTimeline, PluginCard, ProgramRecordingModal, SeriesRecordingModal, Field) + - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils) + - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks - Removed unused Dashboard and Home pages + - Guide page refactoring: Extracted GuideRow and HourTimeline components, moved grid calculations and utility functions to guideUtils.js, added loading states for initial data fetching, improved performance through better memoization + - Plugins page refactoring: Extracted PluginCard and Field components, added Zustand store for plugin state management, improved plugin action confirmation handling, better separation of concerns between UI and business logic - 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 - M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs - Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) From 0cb189acba1b8c0495d40c2d29b2d3b921aa5122 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 2 Jan 2026 12:03:42 -0600 Subject: [PATCH 78/80] changelog: Document Docker container file permissions update for Django management commands --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d41d3063..c5264f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) - Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect - XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781) +- Docker container file permissions: Django management commands (`migrate`, `collectstatic`) now run as the non-root user to prevent root-owned `__pycache__` and static files from causing permission issues - Thanks [@sethwv](https://github.com/sethwv) - Stream validation now continues with GET request if HEAD request fails due to connection issues - Thanks [@kvnnap](https://github.com/kvnnap) (Fixes #782) - XtreamCodes M3U files now correctly set `x-tvg-url` and `url-tvg` headers to reference XC EPG URL (`xmltv.php`) instead of standard EPG endpoint when downloaded via XC API (Fixes #629) From 3f46f28a709dccbd60ae5c34053c8e0a913371c9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 2 Jan 2026 15:22:25 -0600 Subject: [PATCH 79/80] Bug Fix: Auto Channel Sync Force EPG Source feature not properly forcing "No EPG" assignment - When selecting "Force EPG Source" > "No EPG (Disabled)", channels were still being auto-matched to EPG data instead of forcing dummy/no EPG. Now correctly sets `force_dummy_epg` flag to prevent unwanted EPG assignment. (Fixes #788) --- CHANGELOG.md | 1 + .../src/components/forms/LiveGroupFilter.jsx | 133 ++++++++++++------ 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 381b5570..ef933e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Auto Channel Sync Force EPG Source feature not properly forcing "No EPG" assignment - When selecting "Force EPG Source" > "No EPG (Disabled)", channels were still being auto-matched to EPG data instead of forcing dummy/no EPG. Now correctly sets `force_dummy_epg` flag to prevent unwanted EPG assignment. (Fixes #788) - VOD episode processing now properly handles season and episode numbers from APIs that return string values instead of integers, with comprehensive error logging to track data quality issues - Thanks [@patchy8736](https://github.com/patchy8736) (Fixes #770) - VOD episode-to-stream relations are now validated to ensure episodes have been saved to the database before creating relations, preventing integrity errors when bulk_create operations encounter conflicts - Thanks [@patchy8736](https://github.com/patchy8736) - VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant) diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx index ef68bee8..b6e6494c 100644 --- a/frontend/src/components/forms/LiveGroupFilter.jsx +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -369,7 +369,8 @@ const LiveGroupFilter = ({ if ( group.custom_properties?.custom_epg_id !== undefined || - group.custom_properties?.force_dummy_epg + group.custom_properties?.force_dummy_epg || + group.custom_properties?.force_epg_selected ) { selectedValues.push('force_epg'); } @@ -432,23 +433,20 @@ const LiveGroupFilter = ({ // Handle force_epg if (selectedOptions.includes('force_epg')) { - // Migrate from old force_dummy_epg if present + // Set default to force_dummy_epg if no EPG settings exist yet if ( - newCustomProps.force_dummy_epg && - newCustomProps.custom_epg_id === undefined + newCustomProps.custom_epg_id === + undefined && + !newCustomProps.force_dummy_epg ) { - // Migrate: force_dummy_epg=true becomes custom_epg_id=null - newCustomProps.custom_epg_id = null; - delete newCustomProps.force_dummy_epg; - } else if ( - newCustomProps.custom_epg_id === undefined - ) { - // New configuration: initialize with null (no EPG/default dummy) - newCustomProps.custom_epg_id = null; + // Default to "No EPG (Disabled)" + newCustomProps.force_dummy_epg = true; } } else { - // Only remove custom_epg_id when deselected + // Remove all EPG settings when deselected delete newCustomProps.custom_epg_id; + delete newCustomProps.force_dummy_epg; + delete newCustomProps.force_epg_selected; } // Handle group_override @@ -1124,7 +1122,8 @@ const LiveGroupFilter = ({ {/* Show EPG selector when force_epg is selected */} {(group.custom_properties?.custom_epg_id !== undefined || - group.custom_properties?.force_dummy_epg) && ( + group.custom_properties?.force_dummy_epg || + group.custom_properties?.force_epg_selected) && ( { - // Handle migration from force_dummy_epg + // Show custom EPG if set if ( group.custom_properties?.custom_epg_id !== - undefined + undefined && + group.custom_properties?.custom_epg_id !== null ) { - // Convert to string, use '0' for null/no EPG - return group.custom_properties.custom_epg_id === - null - ? '0' - : group.custom_properties.custom_epg_id.toString(); - } else if ( - group.custom_properties?.force_dummy_epg - ) { - // Show "No EPG" for old force_dummy_epg configs + return group.custom_properties.custom_epg_id.toString(); + } + // Show "No EPG" if force_dummy_epg is set + if (group.custom_properties?.force_dummy_epg) { return '0'; } - return '0'; + // Otherwise show empty/placeholder + return null; })()} onChange={(value) => { - // Convert back: '0' means no EPG (null) - const newValue = - value === '0' ? null : parseInt(value); - setGroupStates( - groupStates.map((state) => { - if ( - state.channel_group === group.channel_group - ) { - return { - ...state, - custom_properties: { + if (value === '0') { + // "No EPG (Disabled)" selected - use force_dummy_epg + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + const newProps = { ...state.custom_properties, - custom_epg_id: newValue, - }, - }; - } - return state; - }) - ); + }; + delete newProps.custom_epg_id; + delete newProps.force_epg_selected; + newProps.force_dummy_epg = true; + return { + ...state, + custom_properties: newProps, + }; + } + return state; + }) + ); + } else if (value) { + // Specific EPG source selected + const epgId = parseInt(value); + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + const newProps = { + ...state.custom_properties, + }; + newProps.custom_epg_id = epgId; + delete newProps.force_dummy_epg; + delete newProps.force_epg_selected; + return { + ...state, + custom_properties: newProps, + }; + } + return state; + }) + ); + } else { + // Cleared - remove all EPG settings + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + const newProps = { + ...state.custom_properties, + }; + delete newProps.custom_epg_id; + delete newProps.force_dummy_epg; + delete newProps.force_epg_selected; + return { + ...state, + custom_properties: newProps, + }; + } + return state; + }) + ); + } }} data={[ { value: '0', label: 'No EPG (Disabled)' }, From 9cc90354ee2fa0daa180ee015f84d4f52af87fd1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 2 Jan 2026 15:45:05 -0600 Subject: [PATCH 80/80] changelog: Update changelog for region code addition. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef933e8f..9d75289e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) +- Region code options now intentionally include both `GB` (ISO 3166-1 standard) and `UK` (commonly used by EPG/XMLTV providers) to accommodate real-world EPG data variations. Many providers use `UK` in channel identifiers (e.g., `BBCOne.uk`) despite `GB` being the official ISO country code. Users should select the region code that matches their specific EPG provider's convention for optimal region-based EPG matching bonuses - Thanks [@bigpandaaaa](https://github.com/bigpandaaaa) - Channel number inputs in stream-to-channel creation modals no longer have a maximum value restriction, allowing users to enter any valid channel number supported by the database - Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed) - Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink)