From d4688fa4e4b501340c0a24b7006175af62c76900 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 12:59:19 -0500 Subject: [PATCH 01/34] Add M3U and EPG URL configuration options with dynamic parameters Closes #207 --- .../src/components/tables/ChannelsTable.jsx | 208 ++++++++++++++---- 1 file changed, 171 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 3bf71d00..21a04bcb 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -41,6 +41,9 @@ import { Pagination, NativeSelect, UnstyledButton, + Stack, + Select, + NumberInput, } from '@mantine/core'; import { getCoreRowModel, flexRender } from '@tanstack/react-table'; import './table.css'; @@ -211,7 +214,7 @@ const ChannelRowActions = React.memo( } ); -const ChannelsTable = ({}) => { +const ChannelsTable = ({ }) => { const theme = useMantineTheme(); /** @@ -283,14 +286,25 @@ const ChannelsTable = ({}) => { const [isLoading, setIsLoading] = useState(true); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); - const [epgUrl, setEPGUrl] = useState(epgUrlBase); - const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); + const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); const [channelToDelete, setChannelToDelete] = useState(null); + // M3U and EPG URL configuration state + const [m3uParams, setM3uParams] = useState({ + cachedlogos: true, + direct: false, + tvg_id_source: 'channel_number' + }); + const [epgParams, setEpgParams] = useState({ + cachedlogos: true, + tvg_id_source: 'channel_number', + days: 0 + }); + /** * Dereived variables */ @@ -514,16 +528,47 @@ const ChannelsTable = ({}) => { } } }; + // Build URLs with parameters + const buildM3UUrl = () => { + const params = new URLSearchParams(); + if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); + if (m3uParams.direct) params.append('direct', 'true'); + if (m3uParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', m3uParams.tvg_id_source); + const baseUrl = m3uUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; + }; + + const buildEPGUrl = () => { + const params = new URLSearchParams(); + if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); + if (epgParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', epgParams.tvg_id_source); + if (epgParams.days > 0) params.append('days', epgParams.days.toString()); + + const baseUrl = epgUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; + }; // Example copy URLs const copyM3UUrl = () => { - copyToClipboard(m3uUrl); + copyToClipboard(buildM3UUrl()); + notifications.show({ + title: 'M3U URL Copied!', + message: 'The M3U URL has been copied to your clipboard.', + }); }; const copyEPGUrl = () => { - copyToClipboard(epgUrl); + copyToClipboard(buildEPGUrl()); + notifications.show({ + title: 'EPG URL Copied!', + message: 'The EPG URL has been copied to your clipboard.', + }); }; const copyHDHRUrl = () => { copyToClipboard(hdhrUrl); + notifications.show({ + title: 'HDHR URL Copied!', + message: 'The HDHR URL has been copied to your clipboard.', + }); }; const onSortingChange = (column) => { @@ -812,8 +857,8 @@ const ChannelsTable = ({}) => { return hasStreams ? {} // Default style for channels with streams : { - className: 'no-streams-row', // Add a class instead of background color - }; + className: 'no-streams-row', // Add a class instead of background color + }; }, }); @@ -891,9 +936,7 @@ const ChannelsTable = ({}) => { - - - + - - - - - - - - + + + + + } + /> + Use cached logos + setM3uParams(prev => ({ + ...prev, + cachedlogos: event.target.checked + }))} + /> + - + + Direct stream URLs + setM3uParams(prev => ({ + ...prev, + direct: event.target.checked + }))} + /> + setEpgParams(prev => ({ + ...prev, + tvg_id_source: value + }))} + comboboxProps={{ withinPortal: false }} + data={[ + { value: 'channel_number', label: 'Channel Number' }, + { value: 'tvg_id', label: 'TVG-ID' }, + { value: 'gracenote', label: 'Gracenote Station ID' } + ]} + /> setEpgParams(prev => ({ + ...prev, + days: value || 0 + }))} + /> + From 3fb2433d3a90c13840463a83e404cd257d9dd669 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 13:19:19 -0500 Subject: [PATCH 02/34] Add confirmation dialog for m3u profile deletion. --- frontend/src/components/forms/M3UProfiles.jsx | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index f4cdbe62..163f981b 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -1,7 +1,9 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import API from '../../api'; import M3UProfile from './M3UProfile'; import usePlaylistsStore from '../../store/playlists'; +import ConfirmationDialog from '../ConfirmationDialog'; +import useWarningsStore from '../../store/warnings'; import { Card, Checkbox, @@ -23,10 +25,15 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const theme = useMantineTheme(); const allProfiles = usePlaylistsStore((s) => s.profiles); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); const [profileEditorOpen, setProfileEditorOpen] = useState(false); const [profile, setProfile] = useState(null); const [profiles, setProfiles] = useState([]); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [profileToDelete, setProfileToDelete] = useState(null); useEffect(() => { try { @@ -50,13 +57,30 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { setProfileEditorOpen(true); }; - const deleteProfile = async (id) => { if (!playlist || !playlist.id) return; + + // Get profile details for the confirmation dialog + const profileObj = profiles.find(p => p.id === id); + setProfileToDelete(profileObj); + setDeleteTarget(id); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-profile')) { + return executeDeleteProfile(id); + } + + setConfirmDeleteOpen(true); + }; + + const executeDeleteProfile = async (id) => { + if (!playlist || !playlist.id) return; try { await API.deleteM3UProfile(playlist.id, id); + setConfirmDeleteOpen(false); } catch (error) { console.error('Error deleting profile:', error); + setConfirmDeleteOpen(false); } }; @@ -171,14 +195,38 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { New - - - + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteProfile(deleteTarget)} + title="Confirm Profile Deletion" + message={ + profileToDelete ? ( +
+ {`Are you sure you want to delete the following profile? + +Name: ${profileToDelete.name} +Max Streams: ${profileToDelete.max_streams} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this profile? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-profile" + onSuppressChange={suppressWarning} + size="md" + /> ); }; From f4e4fb1d130af9709d9c1aed767384d09912b555 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 13:24:32 -0500 Subject: [PATCH 03/34] Add confirmation dialog for deleting a channel profile. --- .../ChannelsTable/ChannelTableHeader.jsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 6a7d5de5..2876bec5 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -32,6 +32,8 @@ import useChannelsStore from '../../../store/channels'; import useAuthStore from '../../../store/auth'; import { USER_LEVELS } from '../../../constants'; import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers'; +import ConfirmationDialog from '../../ConfirmationDialog'; +import useWarningsStore from '../../../store/warnings'; const CreateProfilePopover = React.memo(() => { const [opened, setOpened] = useState(false); @@ -103,18 +105,35 @@ const ChannelTableHeader = ({ const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); + const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); + const [profileToDelete, setProfileToDelete] = useState(null); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); const authUser = useAuthStore((s) => s.user); - + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); const closeAssignChannelNumbersModal = () => { setAssignNumbersModalOpen(false); }; const deleteProfile = async (id) => { + // Get profile details for the confirmation dialog + const profileObj = profiles[id]; + setProfileToDelete(profileObj); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-profile')) { + return executeDeleteProfile(id); + } + + setConfirmDeleteProfileOpen(true); + }; + + const executeDeleteProfile = async (id) => { await API.deleteChannelProfile(id); + setConfirmDeleteProfileOpen(false); }; const matchEpg = async () => { @@ -292,6 +311,31 @@ const ChannelTableHeader = ({ isOpen={assignNumbersModalOpen} onClose={closeAssignChannelNumbersModal} /> + + setConfirmDeleteProfileOpen(false)} + onConfirm={() => executeDeleteProfile(profileToDelete?.id)} + title="Confirm Profile Deletion" + message={ + profileToDelete ? ( +
+ {`Are you sure you want to delete the following profile? + +Name: ${profileToDelete.name} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this profile? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-profile" + onSuppressChange={suppressWarning} + size="md" + /> ); }; From 7f1bdd01291544f51dfc92baee33e325e8dfd16f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 14:00:41 -0500 Subject: [PATCH 04/34] Add support for 'num' property in channel number extraction --- apps/channels/api_views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 3ffb98af..0106ffd5 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -401,6 +401,8 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = float(stream_custom_props["tvg-chno"]) elif "channel-number" in stream_custom_props: channel_number = float(stream_custom_props["channel-number"]) + elif "num" in stream_custom_props: + channel_number = float(stream_custom_props["num"]) if channel_number is None: provided_number = request.data.get("channel_number") @@ -546,6 +548,8 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = float(stream_custom_props["tvg-chno"]) elif "channel-number" in stream_custom_props: channel_number = float(stream_custom_props["channel-number"]) + elif "num" in stream_custom_props: + channel_number = float(stream_custom_props["num"]) # Get the tvc_guide_stationid from custom properties if it exists tvc_guide_stationid = None if "tvc-guide-stationid" in stream_custom_props: From c43231c4926c7353871663ea1927a19a140809f5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 14:09:53 -0500 Subject: [PATCH 05/34] Add confirmation dialog for user deletion with warning suppression --- frontend/src/pages/Users.jsx | 57 ++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index 52e79869..765eedc8 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -18,6 +18,8 @@ import UserForm from '../components/forms/User'; import useAuthStore from '../store/auth'; import API from '../api'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../constants'; +import ConfirmationDialog from '../components/ConfirmationDialog'; +import useWarningsStore from '../store/warnings'; const UsersPage = () => { const theme = useMantineTheme(); @@ -27,6 +29,12 @@ const UsersPage = () => { 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); + + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); console.log(authUser); @@ -34,14 +42,28 @@ const UsersPage = () => { setSelectedUser(null); setUserModalOpen(false); }; - const editUser = (user) => { setSelectedUser(user); setUserModalOpen(true); }; const deleteUser = (id) => { - API.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); }; return ( @@ -118,13 +140,38 @@ const UsersPage = () => { })} - - - + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteUser(deleteTarget)} + title="Confirm User Deletion" + message={ + userToDelete ? ( +
+ {`Are you sure you want to delete the following user? + +Username: ${userToDelete.username} +Email: ${userToDelete.email} +User Level: ${USER_LEVEL_LABELS[userToDelete.user_level]} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this user? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-user" + onSuppressChange={suppressWarning} + size="md" + /> ); }; From dae143a72471edaef6bf22886b6eb1dc4780cf0a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 22:54:52 -0500 Subject: [PATCH 06/34] Better rendering for small screens on m3u and epg tables. --- frontend/src/components/tables/EPGsTable.jsx | 19 +++++++++---------- frontend/src/components/tables/M3UsTable.jsx | 2 +- frontend/src/pages/ContentSources.jsx | 8 ++++---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 6978d005..25977bad 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -570,7 +570,7 @@ const EPGsTable = () => { style={{ flex: 1, overflowY: 'auto', - overflowX: 'hidden', + overflowX: 'auto', border: 'solid 1px rgb(68,68,68)', borderRadius: 'var(--mantine-radius-default)', }} @@ -593,15 +593,14 @@ const EPGsTable = () => { Name: ${epgToDelete.name} Source Type: ${epgToDelete.source_type} -${ - epgToDelete.url - ? `URL: ${epgToDelete.url}` - : epgToDelete.api_key - ? `API Key: ${epgToDelete.api_key}` - : epgToDelete.file_path - ? `File Path: ${epgToDelete.file_path}` - : '' -} +${epgToDelete.url + ? `URL: ${epgToDelete.url}` + : epgToDelete.api_key + ? `API Key: ${epgToDelete.api_key}` + : epgToDelete.file_path + ? `File Path: ${epgToDelete.file_path}` + : '' + } This will remove all related program information and channel associations. This action cannot be undone.`} diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index ac7ee631..2dd236fd 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -872,7 +872,7 @@ const M3UTable = () => { style={{ flex: 1, overflowY: 'auto', - overflowX: 'hidden', + overflowX: 'auto', border: 'solid 1px rgb(68,68,68)', borderRadius: 'var(--mantine-radius-default)', }} diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx index 24e736d4..66a20e06 100644 --- a/frontend/src/pages/ContentSources.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -7,15 +7,15 @@ import { Box, Stack } from '@mantine/core'; const M3UPage = () => { const isLoading = useUserAgentsStore((state) => state.isLoading); const error = useUserAgentsStore((state) => state.error); - if (isLoading) return
Loading...
; - if (error) return
Error: {error}
; - - return ( + if (error) return
Error: {error}
; return ( From 384609ae775e34483a055bcf37a2e42efbd71eee Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 21 Jun 2025 23:46:58 -0500 Subject: [PATCH 07/34] Better sizing. --- frontend/src/components/tables/EPGsTable.jsx | 2 +- frontend/src/pages/ContentSources.jsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 25977bad..5c9b6e48 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -563,7 +563,7 @@ const EPGsTable = () => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(40vh - 10px)', + //height: '50%', }} > { From 0b6175bac67652b314af19dfd5a8b59fb429767d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 22 Jun 2025 00:19:31 -0500 Subject: [PATCH 08/34] Fix background color to match rest of the theme. --- frontend/src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index f14083c8..5c37b48b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -12,7 +12,7 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: #2E2F34; + background-color: #18181b; /* Ensure the global background is dark */ color: #ffffff; } From 77590367acbc60640fd71500411edbe6f4cbe84f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 22 Jun 2025 00:30:07 -0500 Subject: [PATCH 09/34] Remove unnecessary padding. --- frontend/src/components/tables/EPGsTable.jsx | 4 ++-- frontend/src/components/tables/M3UsTable.jsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 5c9b6e48..ad1e85f2 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -533,7 +533,7 @@ const EPGsTable = () => { // alignItems: 'center', // backgroundColor: theme.palette.background.paper, justifyContent: 'flex-end', - padding: 10, + padding: 0, // gap: 1, }} > @@ -563,7 +563,7 @@ const EPGsTable = () => { style={{ display: 'flex', flexDirection: 'column', - //height: '50%', + height: 'calc(40vh - 15px)', }} > { // alignItems: 'center', // backgroundColor: theme.palette.background.paper, justifyContent: 'flex-end', - padding: 10, + padding: 0, // gap: 1, }} > @@ -865,7 +865,7 @@ const M3UTable = () => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(40vh - 10px)', + height: 'calc(40vh - 15px)', }} > Date: Sun, 22 Jun 2025 01:02:32 -0500 Subject: [PATCH 10/34] Move buttons to label row --- frontend/src/components/tables/EPGsTable.jsx | 35 +++++++++---------- frontend/src/components/tables/M3UsTable.jsx | 36 +++++++++----------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index ad1e85f2..2101a12f 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -500,6 +500,7 @@ const EPGsTable = () => { style={{ display: 'flex', alignItems: 'center', + justifyContent: 'space-between', paddingBottom: 10, }} gap={15} @@ -518,6 +519,21 @@ const EPGsTable = () => { > EPGs + { // gap: 1, }} > - - - - - diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 2186da9e..c71789f2 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -803,7 +803,7 @@ const M3UTable = () => { return ( { > M3U Accounts + { // gap: 1, }} > - - - - - From 49f141d64af6ce274ae68e34e1ef6fb9aabc626a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 22 Jun 2025 13:21:34 -0500 Subject: [PATCH 11/34] Better calculation for number of cards per column. Fixes #218 --- frontend/src/pages/Stats.jsx | 45 +++++++++++++++--------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ba9829fe..f6ec1392 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -854,18 +854,15 @@ const ChannelsPage = () => { }, []); setClients(clientStats); }, [channelStats, channels, channelsByUUID, streamProfiles]); - return ( - {Object.keys(activeChannels).length === 0 ? ( { No active channels currently streaming - ) : ( - Object.values(activeChannels).map((channel) => ( - - - - )) + ) : (Object.values(activeChannels).map((channel) => ( + )) )} - + ); }; From 58a1304ddca4e8451c3d728cc3204554a181690a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 22 Jun 2025 13:25:43 -0500 Subject: [PATCH 12/34] Always show 2 decimal places for FFmpeg speed. --- 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 f6ec1392..6ee7b130 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -613,13 +613,13 @@ const ChannelCard = ({ )} {channel.ffmpeg_speed && ( - + = 1.0 ? "green" : "red"} > - {channel.ffmpeg_speed}x + {parseFloat(channel.ffmpeg_speed).toFixed(2)}x )} From e2b93d3e6f9735248e93a91382e82f873a660af9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 23 Jun 2025 17:27:34 -0500 Subject: [PATCH 13/34] Better stats card filling. --- frontend/src/pages/Stats.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 6ee7b130..c3709c17 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -480,6 +480,8 @@ const ChannelCard = ({ style={{ color: '#fff', backgroundColor: '#27272A', + maxWidth: '700px', + width: '100%', }} > @@ -861,7 +863,7 @@ const ChannelsPage = () => { display: 'grid', gap: '1rem', padding: '10px', - gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))', + gridTemplateColumns: 'repeat(auto-fill, minmax(500px, 1fr))', }} > {Object.keys(activeChannels).length === 0 ? ( From cc41731ae1a9e1c3cc41ad42e6371d277470f323 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 23 Jun 2025 19:23:16 -0500 Subject: [PATCH 14/34] Minimum widths set --- frontend/src/components/tables/ChannelsTable.jsx | 4 ++-- frontend/src/components/tables/StreamsTable.jsx | 14 +++++++------- frontend/src/pages/Channels.jsx | 14 +++++++++----- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 21a04bcb..352b379c 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1096,7 +1096,7 @@ const ChannelsTable = ({ }) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 58px)', + height: 'calc(100vh - 100px)', backgroundColor: '#27272A', }} > @@ -1119,7 +1119,7 @@ const ChannelsTable = ({ }) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 110px)', + height: 'calc(100vh - 152px)', }} > { +const StreamsTable = ({ }) => { const theme = useMantineTheme(); /** @@ -653,7 +653,7 @@ const StreamsTable = ({}) => { @@ -678,10 +678,10 @@ const StreamsTable = ({}) => { style={ selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? { - borderWidth: '1px', - borderColor: theme.tailwind.green[5], - color: 'white', - } + borderWidth: '1px', + borderColor: theme.tailwind.green[5], + color: 'white', + } : undefined } disabled={ @@ -801,7 +801,7 @@ const StreamsTable = ({}) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 110px)', + height: 'calc(100vh - 152px)', }} > { ); } - return (
-
- +
+
+ +
-
- +
+
+ +
From 587ab4afe0fbe1617da4a14e4dc4fa896d1eea0e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Jun 2025 17:56:05 -0500 Subject: [PATCH 15/34] Gets rid of unneccessary blank space at the bottom. --- frontend/src/components/tables/ChannelsTable.jsx | 4 ++-- frontend/src/components/tables/StreamsTable.jsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 352b379c..3813916c 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1096,7 +1096,7 @@ const ChannelsTable = ({ }) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 100px)', + height: 'calc(100vh - 60px)', backgroundColor: '#27272A', }} > @@ -1119,7 +1119,7 @@ const ChannelsTable = ({ }) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 152px)', + height: 'calc(100vh - 100px)', }} > { @@ -801,7 +801,7 @@ const StreamsTable = ({ }) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 152px)', + height: 'calc(100vh - 100px)', }} > Date: Tue, 24 Jun 2025 18:13:13 -0500 Subject: [PATCH 16/34] Better sizes for mobile --- frontend/src/pages/Channels.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index ca7a67c3..93300f1b 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -12,7 +12,6 @@ const ChannelsPage = () => { if (!authUser.id) { return <>; } - if (authUser.user_level <= USER_LEVELS.STANDARD) { return ( @@ -20,20 +19,21 @@ const ChannelsPage = () => { ); } + return ( -
+
-
+
-
+
From c04ca0a8041d33eedcf102d0fbf2e9ab3efe7876 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 25 Jun 2025 17:01:21 -0500 Subject: [PATCH 17/34] Add buffering as an active state. --- apps/proxy/ts_proxy/server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index bf5d4981..4699091a 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -472,7 +472,7 @@ class ProxyServer: if b'state' in metadata: state = metadata[b'state'].decode('utf-8') active_states = [ChannelState.INITIALIZING, ChannelState.CONNECTING, - ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE] + ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE, ChannelState.BUFFERING] if state in active_states: logger.info(f"Channel {channel_id} already being initialized with state {state}") # Create buffer and client manager only if we don't have them @@ -689,7 +689,8 @@ class ProxyServer: owner = metadata.get(b'owner', b'').decode('utf-8') # States that indicate the channel is running properly - valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING] + valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, + ChannelState.CONNECTING, ChannelState.BUFFERING, ChannelState.INITIALIZING] # If the channel is in a valid state, check if the owner is still active if state in valid_states: From 23e63ba4a024f0713226248a701c79ae592424bb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 09:56:31 -0500 Subject: [PATCH 18/34] Add minimum width to settings for better mobile support. --- frontend/src/pages/Settings.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 1d724bc9..a5b07fa2 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -256,6 +256,7 @@ const SettingsPage = () => { variant="separated" defaultValue="ui-settings" onChange={setAccordianValue} + style={{ minWidth: 400 }} > UI Settings From f8ef219665c1d5a406ad64b5d80b181e8a789c68 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 10:17:48 -0500 Subject: [PATCH 19/34] Set better sizes for user-agent table for mobile support. --- frontend/src/components/tables/UserAgentsTable.jsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index 8c66d11d..265ed125 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -99,6 +99,7 @@ const UserAgentsTable = () => { accessorKey: 'is_active', sortingFn: 'basic', enableSorting: false, + size: 60, cell: ({ cell }) => (
{cell.getValue() ? : } @@ -108,7 +109,7 @@ const UserAgentsTable = () => { { id: 'actions', header: 'Actions', - size: tableSize == 'compact' ? 75 : 100, + size: tableSize == 'compact' ? 50 : 75, }, ], [] @@ -234,18 +235,22 @@ const UserAgentsTable = () => { display: 'flex', flexDirection: 'column', maxHeight: 300, + width: '100%', + overflow: 'hidden', }} > - +
+ +
From f6339b691cc461754961016218881060ca796fef Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 10:55:25 -0500 Subject: [PATCH 20/34] Set better sizes for stream profile table for mobile support. --- frontend/src/components/tables/StreamProfilesTable.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index a33e5d9f..76be0462 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -122,7 +122,7 @@ const StreamProfiles = () => { { header: 'Active', accessorKey: 'is_active', - size: 50, + size: 60, cell: ({ row, cell }) => (
{ { id: 'actions', header: 'Actions', - size: tableSize == 'compact' ? 75 : 100, + size: tableSize == 'compact' ? 50 : 75, }, ], [] @@ -310,12 +310,14 @@ const StreamProfiles = () => { style={{ flex: 1, overflowY: 'auto', - overflowX: 'hidden', + overflowX: 'auto', border: 'solid 1px rgb(68,68,68)', borderRadius: 'var(--mantine-radius-default)', }} > - +
+ +
From 65e0be80e01340c335dca6a70afeea71742782b9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 11:49:08 -0500 Subject: [PATCH 21/34] Refactor channel selection handling to use table state and clear selection on data change --- .../src/components/tables/ChannelsTable.jsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 3813916c..25453ec1 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -375,7 +375,9 @@ const ChannelsTable = ({ }) => { }; const editChannel = async (ch = null) => { - if (selectedChannelIds.length > 0) { + // Use table's selected state instead of store state to avoid stale selections + const currentSelection = table ? table.getState().selectedTableIds : []; + if (currentSelection.length > 1) { setChannelBatchModalOpen(true); } else { setChannel(ch); @@ -611,7 +613,7 @@ const ChannelsTable = ({ }) => { setHDHRUrl(`${hdhrUrlBase}${profileString}`); setEPGUrl(`${epgUrlBase}${profileString}`); setM3UUrl(`${m3uUrlBase}${profileString}`); - }, [selectedProfileId]); + }, [selectedProfileId, profiles]); useEffect(() => { const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 @@ -620,7 +622,19 @@ const ChannelsTable = ({ }) => { totalCount ); setPaginationString(`${startItem} to ${endItem} of ${totalCount}`); - }, [data]); + }, [pagination.pageIndex, pagination.pageSize, totalCount]); + + // Clear selection when data changes (e.g., when navigating back to the page) + useEffect(() => { + setSelectedChannelIds([]); + }, [data, setSelectedChannelIds]); + + // Clear selection when component unmounts + useEffect(() => { + return () => { + setSelectedChannelIds([]); + }; + }, [setSelectedChannelIds]); const columns = useMemo( () => [ From ba6012b28ccd51687ea4a03bf2f7fe3334f41872 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 13:15:00 -0500 Subject: [PATCH 22/34] Fixes bulk channel editor not saving. Fixes #222 --- apps/channels/api_views.py | 83 +++++++++++++++---- frontend/src/api.js | 9 +- .../src/components/forms/ChannelBatch.jsx | 50 ++++++++--- 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 0106ffd5..b651081e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,7 +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 -import os, json, requests +import os, json, requests, logging from apps.accounts.permissions import ( Authenticated, IsAdmin, @@ -48,6 +48,9 @@ import mimetypes from rest_framework.pagination import PageNumberPagination +logger = logging.getLogger(__name__) + + class OrInFilter(django_filters.Filter): """ Custom filter that handles the OR condition instead of AND. @@ -275,30 +278,76 @@ class ChannelViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["patch"], url_path="edit/bulk") def edit_bulk(self, request): - data_list = request.data - if not isinstance(data_list, list): + """ + Bulk edit channels. + Expects a list of channels with their updates. + """ + data = request.data + if not isinstance(data, list): return Response( - {"error": "Expected a list of channel objects objects"}, + {"error": "Expected a list of channel updates"}, status=status.HTTP_400_BAD_REQUEST, ) updated_channels = [] - try: - with transaction.atomic(): - for item in data_list: - channel = Channel.objects.id(id=item.pop("id")) - for key, value in item.items(): - setattr(channel, key, value) + errors = [] - channel.save(update_fields=item.keys()) - updated_channels.append(channel) - except Exception as e: - logger.error("Error during bulk channel edit", e) - return Response({"error": e}, status=500) + for channel_data in data: + channel_id = channel_data.get("id") + if not channel_id: + errors.append({"error": "Channel ID is required"}) + continue - response_data = ChannelSerializer(updated_channels, many=True).data + try: + channel = Channel.objects.get(id=channel_id) - return Response(response_data, status=status.HTTP_200_OK) + # Handle channel_group_id properly - convert string to integer if needed + if 'channel_group_id' in channel_data: + group_id = channel_data['channel_group_id'] + if group_id is not None: + try: + channel_data['channel_group_id'] = int(group_id) + except (ValueError, TypeError): + channel_data['channel_group_id'] = None + + # Use the serializer to validate and update + serializer = ChannelSerializer( + channel, data=channel_data, partial=True + ) + + if serializer.is_valid(): + updated_channel = serializer.save() + updated_channels.append(updated_channel) + else: + errors.append({ + "channel_id": channel_id, + "errors": serializer.errors + }) + + except Channel.DoesNotExist: + errors.append({ + "channel_id": channel_id, + "error": "Channel not found" + }) + except Exception as e: + errors.append({ + "channel_id": channel_id, + "error": str(e) + }) + + if errors: + return Response( + {"errors": errors, "updated_count": len(updated_channels)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Serialize the updated channels for response + serialized_channels = ChannelSerializer(updated_channels, many=True).data + + return Response({ + "message": f"Successfully updated {len(updated_channels)} channels", + "channels": serialized_channels + }) @action(detail=False, methods=["get"], url_path="ids") def get_ids(self, request, *args, **kwargs): diff --git a/frontend/src/api.js b/frontend/src/api.js index 17c38b90..cd9d21ca 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -390,9 +390,9 @@ export default class API { static async updateChannels(ids, values) { const body = []; - for (const id in ids) { + for (const id of ids) { body.push({ - id, + id: id, ...values, }); } @@ -406,7 +406,10 @@ export default class API { } ); - useChannelsStore.getState().updateChannels(response); + // Pass the channels array from the response, not the entire response + if (response.channels) { + useChannelsStore.getState().updateChannels(response.channels); + } return response; } catch (e) { errorNotification('Failed to update channels', e); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 12ee52b8..e26dba38 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -36,6 +36,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [selectedChannelGroup, setSelectedChannelGroup] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); @@ -51,27 +52,38 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); const onSubmit = async () => { + setIsSubmitting(true); + const values = { ...form.getValues(), - channel_group_id: selectedChannelGroup, }; + // Handle channel group ID - convert to integer if it exists + if (selectedChannelGroup) { + values.channel_group_id = parseInt(selectedChannelGroup); + } else { + delete values.channel_group_id; + } + if (!values.stream_profile_id || values.stream_profile_id === '0') { values.stream_profile_id = null; } - if (!values.channel_group_id) { - delete values.channel_group_id; - } - if (values.user_level == '-1') { delete values.user_level; } - await API.batchUpdateChannels({ - ids: channelIds, - values, - }); + // Remove the channel_group field from form values as we use channel_group_id + delete values.channel_group; + + try { + await API.updateChannels(channelIds, values); + onClose(); + } catch (error) { + console.error('Failed to update channels:', error); + } finally { + setIsSubmitting(false); + } }; // useEffect(() => { @@ -151,6 +163,21 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { onClick={() => setGroupPopoverOpened(true)} size="xs" style={{ flex: 1 }} + rightSection={ + form.getValues().channel_group && ( + { + e.stopPropagation(); + setSelectedChannelGroup(''); + form.setValues({ channel_group: '' }); + }} + > + + + ) + } /> { label: '(no change)', }, ].concat( - Object.entries(USER_LEVELS).map(([label, value]) => { + Object.entries(USER_LEVELS).map(([, value]) => { return { label: USER_LEVEL_LABELS[value], value: `${value}`, @@ -274,9 +301,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { /> - - From 00073698b3f2ecd32a431dbc268dce457355209d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 13:30:52 -0500 Subject: [PATCH 23/34] Update front end when channels are edited. --- frontend/src/api.js | 5 +---- frontend/src/components/forms/ChannelBatch.jsx | 5 +++++ frontend/src/store/channels.jsx | 10 +++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index cd9d21ca..391eaae9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -406,10 +406,7 @@ export default class API { } ); - // Pass the channels array from the response, not the entire response - if (response.channels) { - useChannelsStore.getState().updateChannels(response.channels); - } + // Don't automatically update the store here - let the caller handle it return response; } catch (e) { errorNotification('Failed to update channels', e); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index e26dba38..e5b4504e 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -78,6 +78,11 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { try { await API.updateChannels(channelIds, values); + // Refresh both the channels table data and the main channels store + await Promise.all([ + API.requeryChannels(), + useChannelsStore.getState().fetchChannels() + ]); onClose(); } catch (error) { console.error('Failed to update channels:', error); diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 54b1b20f..beb62fe1 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -152,14 +152,18 @@ const useChannelsStore = create((set, get) => ({ })), updateChannels: (channels) => { - const channelsByUUID = {}; + // Ensure channels is an array + if (!Array.isArray(channels)) { + console.error('updateChannels expects an array, received:', typeof channels, channels); + return; + } const channelsByUUID = {}; const updatedChannels = channels.reduce((acc, chan) => { - channelsByUUID[chan.uuid] = chan; + channelsByUUID[chan.uuid] = chan.id; acc[chan.id] = chan; return acc; }, {}); - return set((state) => ({ + set((state) => ({ channels: { ...state.channels, ...updatedChannels, From 99f2b5b4b1d520434fe4a0591af32b7ae6b162aa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 13:42:56 -0500 Subject: [PATCH 24/34] Add no change options to bulk edit. --- .../src/components/forms/ChannelBatch.jsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index e5b4504e..ca88d8ab 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -46,7 +46,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { mode: 'uncontrolled', initialValues: { channel_group: '', - stream_profile_id: '0', + stream_profile_id: '-1', user_level: '-1', }, }); @@ -56,17 +56,13 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const values = { ...form.getValues(), - }; - - // Handle channel group ID - convert to integer if it exists - if (selectedChannelGroup) { + }; // Handle channel group ID - convert to integer if it exists + if (selectedChannelGroup && selectedChannelGroup !== '-1') { values.channel_group_id = parseInt(selectedChannelGroup); } else { delete values.channel_group_id; - } - - if (!values.stream_profile_id || values.stream_profile_id === '0') { - values.stream_profile_id = null; + } if (!values.stream_profile_id || values.stream_profile_id === '-1') { + delete values.stream_profile_id; } if (values.user_level == '-1') { @@ -124,10 +120,12 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); } }; - - const filteredGroups = groupOptions.filter((group) => - group.name.toLowerCase().includes(groupFilter.toLowerCase()) - ); + const filteredGroups = [ + { id: '-1', name: '(no change)' }, + ...groupOptions.filter((group) => + group.name.toLowerCase().includes(groupFilter.toLowerCase()) + ) + ]; if (!isOpen) { return <>; @@ -276,7 +274,10 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { name="stream_profile_id" {...form.getInputProps('stream_profile_id')} key={form.key('stream_profile_id')} - data={[{ value: '0', label: '(use default)' }].concat( + data={[ + { value: '-1', label: '(no change)' }, + { value: '0', label: '(use default)' } + ].concat( streamProfiles.map((option) => ({ value: `${option.id}`, label: option.name, From 855578bf05dfca2ce12d3939fc23057c57db443e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 13:53:36 -0500 Subject: [PATCH 25/34] No change initial loading for group. --- frontend/src/components/forms/ChannelBatch.jsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index ca88d8ab..25f731ab 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -35,7 +35,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); - const [selectedChannelGroup, setSelectedChannelGroup] = useState(''); + const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); const [isSubmitting, setIsSubmitting] = useState(false); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); @@ -45,7 +45,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const form = useForm({ mode: 'uncontrolled', initialValues: { - channel_group: '', + channel_group: '(no change)', stream_profile_id: '-1', user_level: '-1', }, @@ -165,16 +165,15 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { key={form.key('channel_group')} onClick={() => setGroupPopoverOpened(true)} size="xs" - style={{ flex: 1 }} - rightSection={ - form.getValues().channel_group && ( + style={{ flex: 1 }} rightSection={ + form.getValues().channel_group && form.getValues().channel_group !== '(no change)' && ( { e.stopPropagation(); - setSelectedChannelGroup(''); - form.setValues({ channel_group: '' }); + setSelectedChannelGroup('-1'); + form.setValues({ channel_group: '(no change)' }); }} > From 5a38a56dc6a750b83a1c84748fc6b93b47c1a127 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 13:56:12 -0500 Subject: [PATCH 26/34] Fix setting stream profile to 'use default' --- frontend/src/components/forms/ChannelBatch.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 25f731ab..2ba3245c 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -61,8 +61,13 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { values.channel_group_id = parseInt(selectedChannelGroup); } else { delete values.channel_group_id; - } if (!values.stream_profile_id || values.stream_profile_id === '-1') { + } + + // Handle stream profile ID - convert special values + if (!values.stream_profile_id || values.stream_profile_id === '-1') { delete values.stream_profile_id; + } else if (values.stream_profile_id === '0' || values.stream_profile_id === 0) { + values.stream_profile_id = null; // Convert "use default" to null } if (values.user_level == '-1') { From 81d0c9472ff9356c02d4537d195c8a0a70893516 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 26 Jun 2025 15:52:33 -0500 Subject: [PATCH 27/34] When 1 channel is selected open correct channel editor. --- frontend/src/components/tables/ChannelsTable.jsx | 13 ++++++++++++- .../tables/ChannelsTable/ChannelTableHeader.jsx | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 25453ec1..73a47ac9 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -377,10 +377,20 @@ const ChannelsTable = ({ }) => { const editChannel = async (ch = null) => { // Use table's selected state instead of store state to avoid stale selections const currentSelection = table ? table.getState().selectedTableIds : []; + console.log('editChannel called with:', { ch, currentSelection, tableExists: !!table }); + if (currentSelection.length > 1) { setChannelBatchModalOpen(true); } else { - setChannel(ch); + // If no channel object is passed but we have a selection, get the selected channel + let channelToEdit = ch; + if (!channelToEdit && currentSelection.length === 1) { + const selectedId = currentSelection[0]; + + // Use table data since that's what's currently displayed + channelToEdit = data.find(d => d.id === selectedId); + } + setChannel(channelToEdit); setChannelModalOpen(true); } }; @@ -1119,6 +1129,7 @@ const ChannelsTable = ({ }) => { editChannel={editChannel} deleteChannels={deleteChannels} selectedTableIds={table.selectedTableIds} + table={table} /> {/* 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 2876bec5..8813ceda 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -229,7 +229,7 @@ const ChannelTableHeader = ({ leftSection={} variant="default" size="xs" - onClick={editChannel} + onClick={() => editChannel()} disabled={ selectedTableIds.length == 0 || authUser.user_level != USER_LEVELS.ADMIN From 8c47f7b0e66e6166c8d60460cfc4f7d6c0638089 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 09:59:23 -0500 Subject: [PATCH 28/34] Fixes groups not loading in TV Guide. --- frontend/src/pages/Guide.jsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c0042c31..f70d6608 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -693,11 +693,10 @@ export default function TVChannelGuide({ startDate, endDate }) { // 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); + 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)) @@ -709,7 +708,6 @@ export default function TVChannelGuide({ startDate, endDate }) { }); }); } - return options; }, [channelGroups, guideChannels]); From 6d13aa53148343e380c60f4f4b06801aef41f9d3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 10:04:33 -0500 Subject: [PATCH 29/34] Fix console error. --- frontend/src/pages/Guide.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index f70d6608..340cd286 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -836,7 +836,7 @@ export default function TVChannelGuide({ startDate, endDate }) { {(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && ( - )} @@ -1085,7 +1085,7 @@ export default function TVChannelGuide({ startDate, endDate }) { borderRight: '1px solid #27272A', // Increased border width for visibility borderBottom: '1px solid #27272A', // Match the row border boxShadow: '2px 0 5px rgba(0,0,0,0.2)', // Added shadow for depth - position: 'sticky', + //position: 'sticky', left: 0, zIndex: 30, // Higher than expanded programs to prevent overlap height: rowHeight, From 66b95f2ef8d992baf5e0080148a9edb75e6f96ec Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 10:06:54 -0500 Subject: [PATCH 30/34] Fixes channels not actually filtering based on selected group. --- frontend/src/pages/Guide.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 340cd286..eae1c2a7 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -110,7 +110,7 @@ export default function TVChannelGuide({ startDate, endDate }) { // Apply channel group filter if (selectedGroupId !== 'all') { result = result.filter( - (channel) => channel.channel_group?.id === parseInt(selectedGroupId) + (channel) => channel.channel_group_id === parseInt(selectedGroupId) ); } From 7b23b0a4df3908bca79c1e4f8834366e269b212f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 10:24:08 -0500 Subject: [PATCH 31/34] Slide text right when program starts before current view. Closes #223 --- frontend/src/pages/Guide.jsx | 234 +++++++++++++++++++---------------- 1 file changed, 126 insertions(+), 108 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index eae1c2a7..cfc1d298 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -492,7 +492,6 @@ export default function TVChannelGuide({ startDate, endDate }) { guideRef.current.scrollLeft = scrollPosition; } }; - // Renders each program block function renderProgram(program, channelStart) { const programKey = `${program.tvg_id}-${program.start_time}`; @@ -522,18 +521,9 @@ export default function TVChannelGuide({ startDate, endDate }) { const isLive = now.isAfter(programStart) && now.isBefore(programEnd); // Determine if the program has ended - const isPast = now.isAfter(programEnd); - - // Check if this program is expanded + const isPast = now.isAfter(programEnd); // Check if this program is expanded const isExpanded = expandedProgramId === program.id; - // Calculate how much of the program is cut off - const cutOffMinutes = Math.max( - 0, - channelStart.diff(programStart, 'minute') - ); - const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - // Set the height based on expanded state const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; @@ -542,6 +532,25 @@ export default function TVChannelGuide({ startDate, endDate }) { const MIN_EXPANDED_WIDTH = 450; // Minimum width in pixels when expanded const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); + // Calculate text positioning for long programs that start before the visible area + const currentScrollLeft = guideRef.current?.scrollLeft || 0; + const programStartInView = leftPx + gapSize; + const programEndInView = leftPx + gapSize + widthPx; + const viewportLeft = currentScrollLeft; + + // Check if program starts before viewport but extends into it + const startsBeforeView = programStartInView < viewportLeft; + const extendsIntoView = programEndInView > viewportLeft; + + // Calculate text offset to position it at the visible portion + let textOffsetLeft = 0; + if (startsBeforeView && extendsIntoView) { + // Position text at the start of the visible area, but not beyond the program end + const visibleStart = Math.max(viewportLeft - programStartInView, 0); + const maxOffset = widthPx - 200; // Leave some space for text, don't push to very end + textOffsetLeft = Math.min(visibleStart, maxOffset); + } + return ( - + {programStart.format('h:mma')} - {programEnd.format('h:mma')} - - - {/* Description is always shown but expands when row is expanded */} + {/* Description is always shown but expands when row is expanded */} {program.description && ( - - {program.description} - + + {program.description} + + )} {/* Expanded content */} @@ -906,101 +925,100 @@ export default function TVChannelGuide({ startDate, endDate }) { borderBottom: '1px solid #27272A', width: hourTimeline.length * HOUR_WIDTH, }} - > - {hourTimeline.map((hourData, hourIndex) => { - const { time, isNewDay, dayLabel } = hourData; + > {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 */} + 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 */} - {/* Show day above time for every hour using the same format */} - - {formatDayLabel(time)}{' '} - {/* Use same formatDayLabel function for all hours */} - - {time.format('h:mm')} - - {time.format('A')} - + {formatDayLabel(time)}{' '} + {/* Use same formatDayLabel function for all hours */} + {time.format('h:mm')} + + {time.format('A')} + + - {/* Hour boundary marker - more visible */} - + {/* Hour boundary marker - more visible */} + - {/* Quarter hour tick marks */} - - {[15, 30, 45].map((minute) => ( - - ))} - + {/* Quarter hour tick marks */} + + {[15, 30, 45].map((minute) => ( + + ))} - ); - })} + + ); + })} From fe2df9b5302702751aabf255150a044866524d80 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 10:56:24 -0500 Subject: [PATCH 32/34] Render link forms overtop of all elements. --- .../src/components/tables/ChannelsTable.jsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 73a47ac9..d9e624a9 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -929,9 +929,8 @@ const ChannelsTable = ({ }) => { > Links: - - + - + + { } - /> + /> + Use cached logos { cachedlogos: event.target.checked }))} /> - { { value: 'tvg_id', label: 'TVG-ID' }, { value: 'gracenote', label: 'Gracenote Station ID' } ]} - /> + Date: Fri, 27 Jun 2025 11:17:31 -0500 Subject: [PATCH 33/34] Better sizing for link forms. --- .../src/components/tables/ChannelsTable.jsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index d9e624a9..8c19671a 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -930,7 +930,7 @@ const ChannelsTable = ({ }) => { Links: - + - + { - +