From 6a8a94101a8b0f0861ca696984ff0391a48e1fd9 Mon Sep 17 00:00:00 2001 From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:59:52 -0600 Subject: [PATCH 01/75] Add filter to show unassociated streams in Streams table Adds a filter button with 'Only Unassociated' toggle option to the Streams table, allowing users to quickly identify streams from providers that are not associated with any channel. The filter button follows the existing UI pattern from ChannelTableHeader, positioned on the right side of the toolbar. Leverages existing backend support for the unassigned query parameter. Resolves #667 --- .../src/components/tables/StreamsTable.jsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 21b13baf..8c931a65 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -20,6 +20,9 @@ import { ArrowUpNarrowWide, ArrowDownWideNarrow, Search, + Filter, + Square, + SquareCheck, } from 'lucide-react'; import { TextInput, @@ -48,7 +51,6 @@ import { Modal, NumberInput, Radio, - Checkbox, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; import useSettingsStore from '../../store/settings'; @@ -232,6 +234,7 @@ const StreamsTable = ({ onReady }) => { name: '', channel_group: '', m3u_account: '', + unassigned: '', }); const [columnSizing, setColumnSizing] = useLocalStorage( 'streams-table-column-sizing', @@ -382,6 +385,13 @@ const StreamsTable = ({ onReady }) => { })); }; + const toggleUnassignedOnly = () => { + setFilters((prev) => ({ + ...prev, + unassigned: prev.unassigned === '1' ? '' : '1', + })); + }; + const fetchData = useCallback(async () => { setIsLoading(true); @@ -1057,6 +1067,29 @@ const StreamsTable = ({ onReady }) => { + + + + + + + + ) : ( + + ) + } + > + Only Unassociated + + + + ; - } - - const WatchRecording = () => { - return + return ( + ); + }; + + const WatchRecording = () => { + return ( + - Watch - - ; - } + + + ); + }; const MainCard = ( { {isSeriesGroup ? 'Next recording' : 'Time'} - {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} + {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} + {end.format(timeformat)} @@ -419,4 +433,4 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { ); }; -export default RecordingCard; \ No newline at end of file +export default RecordingCard; diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index a00f3664..0e441cbe 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -25,7 +25,10 @@ import { Users, Video, } from 'lucide-react'; -import { toFriendlyDuration } from '../../utils/dateTimeUtils.js'; +import { + toFriendlyDuration, + useDateTimeFormat, +} from '../../utils/dateTimeUtils.js'; import { CustomTable, useTable } from '../tables/CustomTable/index.jsx'; import { TableHelper } from '../../helpers/index.jsx'; import logo from '../../images/logo.png'; @@ -68,9 +71,8 @@ const StreamConnectionCard = ({ // Get settings for speed threshold const settings = useSettingsStore((s) => s.settings); - // Get Date-format from localStorage - const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM'; + // Get user's date/time format preferences + const { fullDateTimeFormat } = useDateTimeFormat(); // Create a map of M3U account IDs to names for quick lookup const m3uAccountsMap = useMemo(() => { @@ -258,7 +260,7 @@ const StreamConnectionCard = ({ { id: 'connected', header: 'Connected', - accessorFn: connectedAccessor(dateFormat), + accessorFn: connectedAccessor(fullDateTimeFormat), cell: ({ cell }) => ( { bdrs={6} bd={'1px solid rgba(255, 255, 255, 0.08)'} > - {connection.user_agent && - connection.user_agent !== 'Unknown' && ( - - - User Agent: - - - {connection.user_agent.length > 100 - ? `${connection.user_agent.substring(0, 100)}...` - : connection.user_agent} - - - )} + {connection.user_agent && connection.user_agent !== 'Unknown' && ( + + + User Agent: + + + {connection.user_agent.length > 100 + ? `${connection.user_agent.substring(0, 100)}...` + : connection.user_agent} + + + )} @@ -86,9 +107,7 @@ const ClientDetails = ({ connection, connectionStartTime }) => { {' '} ({Math.round(connection.last_seek_byte / (1024 * 1024))} MB /{' '} - {Math.round( - connection.total_content_size / (1024 * 1024) - )} + {Math.round(connection.total_content_size / (1024 * 1024))} MB) )} @@ -120,12 +139,11 @@ const ClientDetails = ({ connection, connectionStartTime }) => { )} ); -} +}; // Create a VOD Card component similar to ChannelCard const VodConnectionCard = ({ vodContent, stopVODClient }) => { - const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM'; + const { fullDateTimeFormat } = useDateTimeFormat(); const [isClientExpanded, setIsClientExpanded] = useState(false); const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates @@ -197,9 +215,9 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { // Get connection start time for tooltip const getConnectionStartTime = useCallback( (connection) => { - return calculateConnectionStartTime(connection, dateFormat); + return calculateConnectionStartTime(connection, fullDateTimeFormat); }, - [dateFormat] + [fullDateTimeFormat] ); return ( @@ -211,14 +229,16 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { style={{ backgroundColor: '#27272A', }} - color='#FFF' + color="#FFF" maw={700} w={'100%'} > - + {/* Header with poster and basic info */} - { {connection && metadata.duration_secs && (() => { - const { totalTime, currentTime, percentage} = getProgressInfo(); + const { totalTime, currentTime, percentage } = getProgressInfo(); return totalTime > 0 ? ( @@ -346,8 +366,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { Progress - {formatTime(currentTime)} /{' '} - {formatTime(totalTime)} + {formatTime(currentTime)} / {formatTime(totalTime)} { {isClientExpanded && ( + connectionStartTime={getConnectionStartTime(connection)} + /> )} )} @@ -419,4 +439,4 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { ); }; -export default VodConnectionCard; \ No newline at end of file +export default VodConnectionCard; diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index 1abc6f3b..d9ece7bb 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,7 +1,20 @@ import useChannelsStore from '../../store/channels.jsx'; -import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js'; +import { + useDateTimeFormat, + useTimeHelpers, +} from '../../utils/dateTimeUtils.js'; import React from 'react'; -import { Badge, Button, Card, Flex, Group, Image, Modal, Stack, Text, } from '@mantine/core'; +import { + Badge, + Button, + Card, + Flex, + Group, + Image, + Modal, + Stack, + Text, +} from '@mantine/core'; import useVideoStore from '../../store/useVideoStore.jsx'; import { notifications } from '@mantine/notifications'; import { @@ -19,22 +32,23 @@ import { } from '../../utils/forms/RecordingDetailsModalUtils.js'; const RecordingDetailsModal = ({ - opened, - onClose, - recording, - channel, - posterUrl, - onWatchLive, - onWatchRecording, - env_mode, - onEdit, - }) => { + 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 { timeFormat: timeformat, dateFormat: dateformat } = + useDateTimeFormat(); const safeRecording = recording || {}; const customProps = safeRecording.custom_properties || {}; @@ -61,7 +75,13 @@ const RecordingDetailsModal = ({ safeRecording._group_count && safeRecording._group_count > 1 ); const upcomingEpisodes = React.useMemo(() => { - return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow); + return getUpcomingEpisodes( + isSeriesGroup, + allRecordings, + program, + toUserTime, + userNow + ); }, [ allRecordings, isSeriesGroup, @@ -79,31 +99,32 @@ const RecordingDetailsModal = ({ if (now.isAfter(s) && now.isBefore(e)) { if (!channelMap[rec.channel]) return; - useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live'); + useVideoStore + .getState() + .showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live'); } - } + }; const handleOnWatchRecording = () => { - let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode) + let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode); if (!fileUrl) return; useVideoStore.getState().showVideo(fileUrl, 'vod', { - name: - childRec.custom_properties?.program?.title || 'Recording', + 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) + await runComSkip(recording); notifications.show({ title: 'Removing commercials', message: 'Queued comskip for this recording', @@ -113,7 +134,7 @@ const RecordingDetailsModal = ({ } catch (error) { console.error('Failed to run comskip', error); } - } + }; if (!recording) return null; @@ -147,7 +168,7 @@ const RecordingDetailsModal = ({ const handleOnMainCardClick = () => { setChildRec(rec); setChildOpen(true); - } + }; return ( - {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} + {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} + {end.format(timeformat)} @@ -197,142 +219,153 @@ const RecordingDetailsModal = ({ }; const WatchLive = () => { - return ; - } + return ( + + ); + }; const WatchRecording = () => { - return ; - } + return ( + + ); + }; const Edit = () => { - return ; - } + return ( + + ); + }; const Series = () => { - return - {upcomingEpisodes.length === 0 && ( - - No upcoming episodes found - - )} - {upcomingEpisodes.map((ep) => ( - - ))} - {childOpen && childRec && ( - setChildOpen(false)} - recording={childRec} - channel={channelMap[childRec.channel]} - posterUrl={getPosterUrl( - childRec.custom_properties?.poster_logo_id, - childRec.custom_properties, - channelMap[childRec.channel]?.logo?.cache_url - )} - env_mode={env_mode} - onWatchLive={handleOnWatchLive} - onWatchRecording={handleOnWatchRecording} - /> - )} - ; - } - - const Movie = () => { - return - {recordingName} - - - - {channel ? `${channel.channel_number} • ${channel.name}` : '—'} - - - {onWatchLive && } - {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } - {customProps.status === 'completed' && - (!customProps?.comskip || - customProps?.comskip?.status !== 'completed') && ( - - )} - - - - {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} - - {rating && ( - - - {rating} - - - )} - {description && ( - - {description} + return ( + + {upcomingEpisodes.length === 0 && ( + + No upcoming episodes found )} - {statRows.length > 0 && ( - - - Stream Stats - - {statRows.map(([k, v]) => ( - - - {k} - - {v} - - ))} - + {upcomingEpisodes.map((ep) => ( + + ))} + {childOpen && childRec && ( + setChildOpen(false)} + recording={childRec} + channel={channelMap[childRec.channel]} + posterUrl={getPosterUrl( + childRec.custom_properties?.poster_logo_id, + childRec.custom_properties, + channelMap[childRec.channel]?.logo?.cache_url + )} + env_mode={env_mode} + onWatchLive={handleOnWatchLive} + onWatchRecording={handleOnWatchRecording} + /> )} - ; - } + ); + }; + + const Movie = () => { + return ( + + {recordingName} + + + + {channel ? `${channel.channel_number} • ${channel.name}` : '—'} + + + {onWatchLive && } + {onWatchRecording && } + {onEdit && start.isAfter(userNow()) && } + {customProps.status === 'completed' && + (!customProps?.comskip || + customProps?.comskip?.status !== 'completed') && ( + + )} + + + + {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} + {end.format(timeformat)} + + {rating && ( + + + {rating} + + + )} + {description && ( + + {description} + + )} + {statRows.length > 0 && ( + + + Stream Stats + + {statRows.map(([k, v]) => ( + + + {k} + + {v} + + ))} + + )} + + + ); + }; return ( { const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); const recordings = useChannelsStore((s) => s.recordings); const { toUserTime, userNow } = useTimeHelpers(); - const [timeformat, dateformat] = useDateTimeFormat(); + const { timeFormat: timeformat, dateFormat: dateformat } = + useDateTimeFormat(); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); @@ -198,73 +211,70 @@ 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 ( + + {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 ( + + + + + {occStart.format(`${dateformat}, YYYY`)} + + + {occStart.format(timeformat)} – {occEnd.format(timeformat)} + + + + + + - - - ); - })} - ; - } + + ); + })} + + ); + }; return ( { No future airings currently scheduled. - ) : } + ) : ( + + )} ); }; -export default RecurringRuleModal; \ No newline at end of file +export default RecurringRuleModal; diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index f6952542..20027088 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -3,7 +3,6 @@ import API from '../../api'; import useEPGsStore from '../../store/epgs'; import EPGForm from '../forms/EPG'; import DummyEPGForm from '../forms/DummyEPG'; -import { TableHelper } from '../../helpers'; import { ActionIcon, Text, @@ -14,7 +13,6 @@ import { Flex, useMantineTheme, Switch, - Badge, Progress, Stack, Group, @@ -31,9 +29,9 @@ import { SquarePlus, ChevronDown, } from 'lucide-react'; -import dayjs from 'dayjs'; -import useSettingsStore from '../../store/settings'; +import { format } from '../../utils/dateTimeUtils.js'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { useDateTimeFormat } from '../../utils/dateTimeUtils.js'; import ConfirmationDialog from '../../components/ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from './CustomTable'; @@ -116,17 +114,8 @@ const EPGsTable = () => { const refreshProgress = useEPGsStore((s) => s.refreshProgress); const theme = useMantineTheme(); - // Get tableSize directly from localStorage instead of the store + const { fullDateTimeFormat } = useDateTimeFormat(); const [tableSize] = useLocalStorage('table-size', 'default'); - - // Get proper size for action icons to match ChannelsTable - const iconSize = - tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'; - - // Calculate density for Mantine Table - const tableDensity = - tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md'; - const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -356,11 +345,11 @@ const EPGsTable = () => { enableSorting: false, cell: ({ cell }) => { const value = cell.getValue(); - return value ? ( - {new Date(value).toLocaleString()} - ) : ( - Never - ); + if (!value) { + return Never; + } + const formatted = format(value, fullDateTimeFormat); + return {formatted}; }, }, { @@ -391,7 +380,7 @@ const EPGsTable = () => { size: tableSize == 'compact' ? 75 : 100, }, ], - [refreshProgress] + [refreshProgress, fullDateTimeFormat] ); const [isLoading, setIsLoading] = useState(true); diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 6e88af56..6ae42e51 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -19,9 +19,6 @@ import { ActionIcon, Tooltip, Switch, - Progress, - Stack, - Badge, Group, Center, } from '@mantine/core'; @@ -29,16 +26,13 @@ import { SquareMinus, SquarePen, RefreshCcw, - Check, - X, ArrowUpDown, ArrowUpNarrowWide, ArrowDownWideNarrow, SquarePlus, } from 'lucide-react'; -import dayjs from 'dayjs'; -import useSettingsStore from '../../store/settings'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js'; import ConfirmationDialog from '../../components/ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from './CustomTable'; @@ -131,9 +125,7 @@ const RowActions = ({ const M3UTable = () => { const [playlist, setPlaylist] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); - const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false); const [rowSelection, setRowSelection] = useState([]); - const [activeFilterValue, setActiveFilterValue] = useState('all'); const [playlistCreated, setPlaylistCreated] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); @@ -152,6 +144,7 @@ const M3UTable = () => { const theme = useMantineTheme(); const [tableSize] = useLocalStorage('table-size', 'default'); + const { fullDateTimeFormat } = useDateTimeFormat(); const generateStatusString = (data) => { if (data.progress == 100) { @@ -582,11 +575,11 @@ const M3UTable = () => { size: 175, cell: ({ cell }) => { const value = cell.getValue(); - return value ? ( - {new Date(value).toLocaleString()} - ) : ( - Never - ); + if (!value) { + return Never; + } + const formatted = format(value, fullDateTimeFormat); + return {formatted}; }, }, { @@ -611,7 +604,13 @@ const M3UTable = () => { size: tableSize == 'compact' ? 75 : 100, }, ], - [refreshPlaylist, editPlaylist, deletePlaylist, toggleActive] + [ + refreshPlaylist, + editPlaylist, + deletePlaylist, + toggleActive, + fullDateTimeFormat, + ] ); //optionally access the underlying virtualizer instance diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 467423dc..af4e3087 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -5,14 +5,7 @@ import useUsersStore from '../../store/users'; import useAuthStore from '../../store/auth'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; import useWarningsStore from '../../store/warnings'; -import { - SquarePlus, - SquareMinus, - SquarePen, - EllipsisVertical, - Eye, - EyeOff, -} from 'lucide-react'; +import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react'; import { ActionIcon, Box, @@ -22,14 +15,13 @@ import { Flex, Group, useMantineTheme, - Menu, - UnstyledButton, LoadingOverlay, Stack, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js'; const UserRowActions = ({ theme, row, editUser, deleteUser }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); @@ -78,6 +70,7 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => { const UsersTable = () => { const theme = useMantineTheme(); + const { fullDateFormat, fullDateTimeFormat } = useDateTimeFormat(); /** * STORES @@ -210,9 +203,7 @@ const UsersTable = () => { cell: ({ getValue }) => { const date = getValue(); return ( - - {date ? new Date(date).toLocaleDateString() : '-'} - + {date ? format(date, fullDateFormat) : '-'} ); }, }, @@ -224,7 +215,7 @@ const UsersTable = () => { const date = getValue(); return ( - {date ? new Date(date).toLocaleString() : 'Never'} + {date ? format(date, fullDateTimeFormat) : 'Never'} ); }, @@ -280,7 +271,15 @@ const UsersTable = () => { ), }, ], - [theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility] + [ + theme, + editUser, + deleteUser, + visiblePasswords, + togglePasswordVisibility, + fullDateFormat, + fullDateTimeFormat, + ] ); const closeUserForm = () => { diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index ac0fdf82..64fa3099 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -65,9 +65,7 @@ import { PROGRAM_HEIGHT, sortChannels, } from './guideUtils'; -import { - getShowVideoUrl, -} from '../utils/cards/RecordingCardUtils.js'; +import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; import { add, convertToMs, @@ -79,10 +77,12 @@ import { } from '../utils/dateTimeUtils.js'; import GuideRow from '../components/GuideRow.jsx'; import HourTimeline from '../components/HourTimeline'; -const ProgramRecordingModal = React.lazy(() => - import('../components/forms/ProgramRecordingModal')); -const SeriesRecordingModal = React.lazy(() => - import('../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'; @@ -230,7 +230,7 @@ export default function TVChannelGuide({ startDate, endDate }) { [rowHeights] ); - const [timeFormat, dateFormat] = useDateTimeFormat(); + const { timeFormat, dateFormat } = useDateTimeFormat(); // Format day label using relative terms when possible (Today, Tomorrow, etc) const formatDayLabel = useCallback( @@ -774,9 +774,11 @@ export default function TVChannelGuide({ startDate, endDate }) { style={{ cursor: 'pointer', zIndex: isExpanded ? 25 : 5, - transition: isExpanded ? 'height 0.2s ease, width 0.2s ease' : 'height 0.2s ease', + transition: isExpanded + ? 'height 0.2s ease, width 0.2s ease' + : 'height 0.2s ease', }} - pos='absolute' + pos="absolute" left={leftPx + gapSize} top={0} w={isExpanded ? expandedWidthPx : widthPx} @@ -806,7 +808,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} w={'100%'} h={'100%'} - pos='relative' + pos="relative" display={'flex'} p={isExpanded ? 12 : 8} c={isPast ? '#a0aec0' : '#fff'} @@ -1007,7 +1009,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} w={'100%'} h={'100%'} - c='#ffffff' + c="#ffffff" ff={'Roboto, sans-serif'} onClick={handleClickOutside} // Close expanded program when clicking outside > @@ -1016,9 +1018,9 @@ export default function TVChannelGuide({ startDate, endDate }) { direction="column" style={{ zIndex: 1000, - position: 'sticky' + position: 'sticky', }} - c='#ffffff' + c="#ffffff" p={'12px 20px'} top={0} > @@ -1101,7 +1103,7 @@ export default function TVChannelGuide({ startDate, endDate }) { backgroundColor: '#245043', }} bd={'1px solid #3BA882'} - color='#FFFFFF' + color="#FFFFFF" > Series Rules @@ -1125,7 +1127,7 @@ export default function TVChannelGuide({ startDate, endDate }) { @@ -1152,7 +1154,7 @@ export default function TVChannelGuide({ startDate, endDate }) { flex: 1, overflow: 'hidden', }} - pos='relative' + pos="relative" > @@ -1190,7 +1192,7 @@ export default function TVChannelGuide({ startDate, endDate }) { flex: 1, overflow: 'hidden', }} - pos='relative' + pos="relative" > {nowPosition >= 0 && ( @@ -1200,7 +1202,7 @@ export default function TVChannelGuide({ startDate, endDate }) { zIndex: 15, pointerEvents: 'none', }} - pos='absolute' + pos="absolute" left={nowPosition + CHANNEL_WIDTH - guideScrollLeft} top={0} bottom={0} @@ -1225,7 +1227,7 @@ export default function TVChannelGuide({ startDate, endDate }) { {GuideRow} ) : ( - + No channels match your filters @@ -91,7 +100,12 @@ vi.mock('@mantine/core', async () => { TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
{icon} - + {rightSection}
), @@ -111,7 +125,12 @@ vi.mock('@mantine/core', async () => { ), ActionIcon: ({ children, onClick, variant, size, color }) => ( - ), @@ -122,21 +141,23 @@ vi.mock('@mantine/core', async () => { vi.mock('react-window', () => ({ VariableSizeList: ({ children, itemData, itemCount }) => (
- {Array.from({ length: Math.min(itemCount, 5) }, (_, i) => + {Array.from({ length: Math.min(itemCount, 5) }, (_, i) => (
{children({ index: i, style: {}, - data: itemData.filteredChannels[i] + data: itemData.filteredChannels[i], })}
- )} + ))}
), })); vi.mock('../../components/GuideRow', () => ({ - default: ({ data }) =>
GuideRow for {data?.name}
, + default: ({ data }) => ( +
GuideRow for {data?.name}
+ ), })); vi.mock('../../components/HourTimeline', () => ({ default: ({ hourTimeline }) => ( @@ -184,7 +205,9 @@ vi.mock('../guideUtils', async () => { }; }); vi.mock('../../utils/cards/RecordingCardUtils.js', async () => { - const actual = await vi.importActual('../../utils/cards/RecordingCardUtils.js'); + const actual = await vi.importActual( + '../../utils/cards/RecordingCardUtils.js' + ); return { ...actual, getShowVideoUrl: vi.fn(), @@ -262,7 +285,9 @@ describe('Guide', () => { }); useEPGsStore.mockImplementation((selector) => - selector ? selector({ tvgsById: {}, epgs: {} }) : { tvgsById: {}, epgs: {} } + selector + ? selector({ tvgsById: {}, epgs: {} }) + : { tvgsById: {}, epgs: {} } ); useSettingsStore.mockReturnValue('production'); @@ -274,13 +299,18 @@ describe('Guide', () => { if (format?.includes('dddd')) return 'Monday, 01/15/2024 • 12:00 PM'; return '12:00 PM'; }); - dateTimeUtils.initializeTime.mockImplementation(date => date || now); + dateTimeUtils.initializeTime.mockImplementation((date) => date || now); dateTimeUtils.startOfDay.mockReturnValue(now.startOf('day')); dateTimeUtils.add.mockImplementation((date, amount, unit) => dayjs(date).add(amount, unit) ); - dateTimeUtils.convertToMs.mockImplementation(date => dayjs(date).valueOf()); - dateTimeUtils.useDateTimeFormat.mockReturnValue(['12h', 'MM/DD/YYYY']); + dateTimeUtils.convertToMs.mockImplementation((date) => + dayjs(date).valueOf() + ); + dateTimeUtils.useDateTimeFormat.mockReturnValue({ + timeFormat: '12h', + dateFormat: 'MM/DD/YYYY', + }); guideUtils.fetchPrograms.mockResolvedValue([ { @@ -300,8 +330,8 @@ describe('Guide', () => { ]); guideUtils.fetchRules.mockResolvedValue([]); - guideUtils.filterGuideChannels.mockImplementation( - (channels) => Object.values(channels) + guideUtils.filterGuideChannels.mockImplementation((channels) => + Object.values(channels) ); guideUtils.createRecording.mockResolvedValue(undefined); guideUtils.createSeriesRule.mockResolvedValue(undefined); @@ -348,7 +378,9 @@ describe('Guide', () => { render(); // await waitFor(() => { - expect(screen.getByText('No channels match your filters')).toBeInTheDocument(); + expect( + screen.getByText('No channels match your filters') + ).toBeInTheDocument(); // }); }); @@ -356,7 +388,7 @@ describe('Guide', () => { render(); // await waitFor(() => { - expect(screen.getByText(/2 channels/)).toBeInTheDocument(); + expect(screen.getByText(/2 channels/)).toBeInTheDocument(); // }); }); }); @@ -394,7 +426,8 @@ describe('Guide', () => { const user = userEvent.setup({ delay: null }); render(); - const searchInput = await screen.findByPlaceholderText('Search channels...'); + const searchInput = + await screen.findByPlaceholderText('Search channels...'); await user.type(searchInput, 'News'); await waitFor(() => { @@ -457,7 +490,8 @@ describe('Guide', () => { render(); // Set some filters - const searchInput = await screen.findByPlaceholderText('Search channels...'); + const searchInput = + await screen.findByPlaceholderText('Search channels...'); await user.type(searchInput, 'Test'); // Clear them @@ -479,7 +513,9 @@ describe('Guide', () => { await user.click(rulesButton); await waitFor(() => { - expect(screen.getByTestId('series-recording-modal')).toBeInTheDocument(); + expect( + screen.getByTestId('series-recording-modal') + ).toBeInTheDocument(); }); vi.useFakeTimers(); @@ -538,7 +574,12 @@ describe('Guide', () => { describe('Error Handling', () => { it('shows notification when no channels are available', async () => { useChannelsStore.mockImplementation((selector) => { - const state = { channels: {}, recordings: [], channelGroups: {}, profiles: {} }; + const state = { + channels: {}, + recordings: [], + channelGroups: {}, + profiles: {}, + }; return selector ? selector(state) : state; }); @@ -616,4 +657,4 @@ describe('Guide', () => { vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 54644dcd..62d8190e 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -336,7 +336,8 @@ describe('dateTimeUtils', () => { const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat()); - expect(result.current).toEqual(['h:mma', 'MMM D']); + expect(result.current.timeFormat).toBe('h:mma'); + expect(result.current.dateFormat).toBe('MMM D'); }); it('should return 24h format when set', () => { @@ -344,7 +345,7 @@ describe('dateTimeUtils', () => { const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat()); - expect(result.current[0]).toBe('HH:mm'); + expect(result.current.timeFormat).toBe('HH:mm'); }); it('should return dmy date format when set', () => { @@ -352,7 +353,7 @@ describe('dateTimeUtils', () => { const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat()); - expect(result.current[1]).toBe('D MMM'); + expect(result.current.dateFormat).toBe('D MMM'); }); }); diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js index 5c9d9ccc..75d8e9b2 100644 --- a/frontend/src/utils/cards/StreamConnectionCardUtils.js +++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js @@ -68,19 +68,19 @@ export const switchStream = (channel, streamId) => { return API.switchStream(channel.channel_id, streamId); }; -export const connectedAccessor = (dateFormat) => { +export const connectedAccessor = (fullDateTimeFormat) => { return (row) => { // Check for connected_since (which is seconds since connection) if (row.connected_since) { // Calculate the actual connection time by subtracting the seconds from current time const connectedTime = subtract(getNow(), row.connected_since, 'second'); - return format(connectedTime, `${dateFormat} HH:mm:ss`); + return format(connectedTime, fullDateTimeFormat); } // Fallback to connected_at if it exists if (row.connected_at) { const connectedTime = initializeTime(row.connected_at * 1000); - return format(connectedTime, `${dateFormat} HH:mm:ss`); + return format(connectedTime, fullDateTimeFormat); } return 'Unknown'; diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js index 3bf635b6..bf0a2219 100644 --- a/frontend/src/utils/cards/VodConnectionCardUtils.js +++ b/frontend/src/utils/cards/VodConnectionCardUtils.js @@ -117,9 +117,9 @@ export const calculateConnectionDuration = (connection) => { return 'Unknown duration'; } -export const calculateConnectionStartTime = (connection, dateFormat) => { +export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => { if (connection.connected_at) { - return format(connection.connected_at * 1000, `${dateFormat} HH:mm:ss`); + return format(connection.connected_at * 1000, fullDateTimeFormat); } // Fallback: calculate from client_id timestamp @@ -128,7 +128,7 @@ export const calculateConnectionStartTime = (connection, dateFormat) => { const parts = connection.client_id.split('_'); if (parts.length >= 2) { const clientStartTime = parseInt(parts[1]); - return format(clientStartTime, `${dateFormat} HH:mm:ss`); + return format(clientStartTime, fullDateTimeFormat); } } catch { // Ignore parsing errors diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 6d90d42a..2b9c0888 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -112,7 +112,21 @@ export const useDateTimeFormat = () => { const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm'; const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM'; - return [timeFormat, dateFormat]; + // Full format strings for detailed date-time displays + const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY'; + const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss'; + const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`; + + return { + timeFormat, + dateFormat, + fullDateFormat, + fullTimeFormat, + fullDateTimeFormat, + // Also return raw settings for cases that need them + timeFormatSetting, + dateFormatSetting, + }; }; export const toTimeString = (value) => { From 7fc2cbc45be3ffd88f48807dedbdb1f5ee049438 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 19 Jan 2026 20:16:06 -0600 Subject: [PATCH 27/75] tests: Fixed frontend tests failing after datetime utility changes. --- .../cards/__tests__/StreamConnectionCardUtils.test.js | 4 ++-- .../cards/__tests__/VodConnectionCardUtils.test.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js index 92c028c9..a1596280 100644 --- a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js @@ -167,11 +167,11 @@ describe('StreamConnectionCardUtils', () => { dateTimeUtils.subtract.mockReturnValue(mockConnectedTime); dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00'); - const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY'); + const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY, HH:mm:ss'); const result = accessor({ connected_since: 7200 }); expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second'); - expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY HH:mm:ss'); + expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY, HH:mm:ss'); expect(result).toBe('01/01/2024 10:00:00'); }); diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js index 9765daf3..19b71a9d 100644 --- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js @@ -279,9 +279,9 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00'); const connection = { connected_at: 1705329000 }; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY'); + const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss'); - expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY HH:mm:ss'); + expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY, HH:mm:ss'); expect(result).toBe('01/15/2024 14:30:00'); }); @@ -289,15 +289,15 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00'); const connection = { client_id: 'vod_1705323600000_abc' }; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY'); + const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss'); - expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY HH:mm:ss'); + expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY, HH:mm:ss'); expect(result).toBe('01/15/2024 13:00:00'); }); it('should return Unknown when no timestamp data available', () => { const connection = {}; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY'); + const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss'); expect(result).toBe('Unknown'); }); From 2c5cc636a796bb761cf445a2ab99dca9a112bc4c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 19 Jan 2026 21:03:18 -0600 Subject: [PATCH 28/75] Security: - Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: - **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)) - **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7)) - Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router) --- CHANGELOG.md | 7 +++++++ frontend/package-lock.json | 14 +++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d63bbf4..2ac820df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: + - **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)) + - **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7)) +- Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router) + ### Added - Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ed9e6010..476b764b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4404,9 +4404,9 @@ } }, "node_modules/react-router": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", - "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4426,12 +4426,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", - "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", "license": "MIT", "dependencies": { - "react-router": "7.11.0" + "react-router": "7.12.0" }, "engines": { "node": ">=20.0.0" From 3646ec5e46255e1fc224ef75315640061ff3846d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 19 Jan 2026 21:13:02 -0600 Subject: [PATCH 29/75] changelog: Update changelog to reflect streamlink update. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac820df..2644c9e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection - Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page: - Stream connection card "Connected" column - VOD connection card "Connection Start Time" column From 3a5a676097300041b37349e5153a4f8299488c38 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 19 Jan 2026 21:28:22 -0600 Subject: [PATCH 30/75] changelog: Update changelog to reference github issue for streamlink. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2644c9e7..91fc4bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection +- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869) - Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page: - Stream connection card "Connected" column - VOD connection card "Connection Start Time" column From 0e1db3e39cfd24e98343c17717cc74263b08c85d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 13:37:40 -0600 Subject: [PATCH 31/75] Enhancement: - Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: - Currently playing program title displayed with live broadcast indicator (green Radio icon) - Expandable program descriptions via chevron button - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels - Smart scheduling that fetches new program data 5 seconds after current program ends - Only polls when active channel list changes, not on stats refresh - Channel preview button: Added preview functionality to active stream cards on stats page --- CHANGELOG.md | 7 + apps/epg/api_urls.py | 3 +- apps/epg/api_views.py | 86 ++++++++++++ frontend/src/api.js | 17 +++ .../components/cards/StreamConnectionCard.jsx | 127 +++++++++++++++--- frontend/src/pages/Stats.jsx | 127 ++++++++++++++++-- frontend/src/utils/pages/StatsUtils.js | 35 +++++ 7 files changed, 368 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fc4bb2..c371dd93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: + - Currently playing program title displayed with live broadcast indicator (green Radio icon) + - Expandable program descriptions via chevron button + - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels + - Smart scheduling that fetches new program data 5 seconds after current program ends + - Only polls when active channel list changes, not on stats refresh +- Channel preview button: Added preview functionality to active stream cards on stats page - Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) - Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom) - DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery. diff --git a/apps/epg/api_urls.py b/apps/epg/api_urls.py index 2818e66b..ed4b3105 100644 --- a/apps/epg/api_urls.py +++ b/apps/epg/api_urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet +from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView app_name = 'epg' @@ -12,6 +12,7 @@ router.register(r'epgdata', EPGDataViewSet, basename='epgdata') urlpatterns = [ path('grid/', EPGGridAPIView.as_view(), name='epg_grid'), path('import/', EPGImportAPIView.as_view(), name='epg_import'), + path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'), ] urlpatterns += router.urls diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 2fc5a743..319368d0 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -417,3 +417,89 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet): except KeyError: return [Authenticated()] + +# ───────────────────────────── +# 6) Current Programs API +# ───────────────────────────── +class CurrentProgramsAPIView(APIView): + """ + Lightweight endpoint that returns currently playing programs for specified channel IDs. + Accepts POST with JSON body containing channel_ids array, or null/empty to fetch all channels. + """ + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @swagger_auto_schema( + operation_description="Get currently playing programs for specified channels or all channels", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'channel_ids': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_INTEGER), + description="Array of channel IDs. If null or omitted, returns all channels with current programs.", + nullable=True, + ) + }, + ), + responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))}, + ) + def post(self, request, format=None): + # Get channel IDs from request body + channel_ids = request.data.get('channel_ids', None) + + # Import Channel model + from apps.channels.models import Channel + + # Build query for channels with EPG data + query = Channel.objects.filter(epg_data__isnull=False) + + # Filter by specific channel IDs if provided + if channel_ids is not None: + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be an array of integers or null"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + channel_ids = [int(id) for id in channel_ids] + except (ValueError, TypeError): + return Response( + {"error": "channel_ids must contain valid integers"}, + status=status.HTTP_400_BAD_REQUEST + ) + + query = query.filter(id__in=channel_ids) + + # Get channels with EPG data + channels = query.select_related('epg_data') + + # Get current time + now = timezone.now() + + # Build list of current programs + current_programs = [] + + for channel in channels: + # Query for current program + program = ProgramData.objects.filter( + epg=channel.epg_data, + start_time__lte=now, + end_time__gt=now + ).first() + + if program: + # Serialize program and add channel_id for easy mapping + program_data = ProgramDataSerializer(program).data + program_data['channel_id'] = channel.id + current_programs.append(program_data) + + return Response(current_programs, status=status.HTTP_200_OK) + diff --git a/frontend/src/api.js b/frontend/src/api.js index 2c74f3f7..4b728622 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1101,6 +1101,23 @@ export default class API { } } + static async getCurrentPrograms(channelIds = null) { + try { + const response = await request( + `${host}/api/epg/current-programs/`, + { + method: 'POST', + body: { channel_ids: channelIds }, + } + ); + + return response; + } catch (e) { + console.error('Failed to retrieve current programs', e); + return []; + } + } + // Notice there's a duplicated "refreshPlaylist" method above; // you might want to rename or remove one if it's not needed. diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 0e441cbe..5488aef5 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -15,11 +15,16 @@ import { Stack, Text, Tooltip, + useMantineTheme, } from '@mantine/core'; import { + ChevronDown, + ChevronRight, + CirclePlay, Gauge, HardDriveDownload, HardDriveUpload, + Radio, SquareX, Timer, Users, @@ -48,6 +53,7 @@ import { getStreamsByIds, switchStream, } from '../../utils/cards/StreamConnectionCardUtils.js'; +import useVideoStore from '../../store/useVideoStore'; // Create a separate component for each channel card to properly handle the hook const StreamConnectionCard = ({ @@ -57,6 +63,8 @@ const StreamConnectionCard = ({ stopChannel, logos, channelsByUUID, + channels, + currentProgram, }) => { const location = useLocation(); const [availableStreams, setAvailableStreams] = useState([]); @@ -65,11 +73,18 @@ const StreamConnectionCard = ({ const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile const [data, setData] = useState([]); const [previewedStream, setPreviewedStream] = useState(null); + const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false); + + const theme = useMantineTheme(); // Get M3U account data from the playlists store const m3uAccounts = usePlaylistsStore((s) => s.playlists); - // Get settings for speed threshold + // Get settings for speed threshold and environment mode const settings = useSettingsStore((s) => s.settings); + const env_mode = + useSettingsStore((s) => s.environment?.env_mode) || 'production'; + // Get video preview function + const showVideo = useVideoStore((s) => s.showVideo); // Get user's date/time format preferences const { fullDateTimeFormat } = useDateTimeFormat(); @@ -393,6 +408,23 @@ const StreamConnectionCard = ({ // Create select options for available streams const streamOptions = getStreamOptions(availableStreams, m3uAccountsMap); + // Handle preview channel button click + const handlePreviewChannel = () => { + const channelDbId = channelsByUUID[channel.channel_id]; + if (!channelDbId) return; + + const actualChannel = channels[channelDbId]; + if (!actualChannel?.uuid) return; + + const uri = `/proxy/ts/stream/${actualChannel.uuid}`; + let url = `${window.location.protocol}//${window.location.host}${uri}`; + if (env_mode === 'dev') { + url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`; + } + + showVideo(url); + }; + if (location.pathname !== '/stats') { return <>; } @@ -462,9 +494,7 @@ const StreamConnectionCard = ({ - - {channelName} - + {channelName} @@ -474,8 +504,32 @@ const StreamConnectionCard = ({ - {/* Display M3U profile information */} - + {/* Display M3U profile and current program */} + + {currentProgram ? ( + + + + Now Playing: + + + {currentProgram.title} + + setIsProgramDescExpanded(!isProgramDescExpanded)} + > + {isProgramDescExpanded ? ( + + ) : ( + + )} + + + ) : ( + + )} @@ -484,22 +538,53 @@ const StreamConnectionCard = ({ - {/* Add stream selection dropdown */} + {/* Expandable program description */} + {currentProgram && + isProgramDescExpanded && + currentProgram.description && ( + + + {currentProgram.description} + + + )} + + {/* Add stream selection dropdown and preview button */} {availableStreams.length > 0 && ( - - + +
+ {channel.name && ( + + + + + + )} + +
)} {/* Add stream information badges */} diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 19702ae6..fe88b399 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -1,5 +1,20 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; -import { Box, Button, Group, LoadingOverlay, NumberInput, Text, Title, } from '@mantine/core'; +import React, { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Box, + Button, + Group, + LoadingOverlay, + NumberInput, + Text, + Title, +} from '@mantine/core'; import useChannelsStore from '../store/channels'; import useLogosStore from '../store/logos'; import useStreamProfilesStore from '../store/streamProfiles'; @@ -10,22 +25,27 @@ import { fetchActiveChannelStats, getClientStats, getCombinedConnections, + getCurrentPrograms, getStatsByChannelId, getVODStats, stopChannel, stopClient, stopVODClient, } from '../utils/pages/StatsUtils.js'; -const VodConnectionCard = React.lazy(() => - import('../components/cards/VodConnectionCard.jsx')); -const StreamConnectionCard = React.lazy(() => - import('../components/cards/StreamConnectionCard.jsx')); +const VodConnectionCard = React.lazy( + () => import('../components/cards/VodConnectionCard.jsx') +); +const StreamConnectionCard = React.lazy( + () => import('../components/cards/StreamConnectionCard.jsx') +); const Connections = ({ combinedConnections, clients, channelsByUUID, + channels, handleStopVODClient, + currentPrograms, }) => { const logos = useLogosStore((s) => s.logos); @@ -55,6 +75,8 @@ const Connections = ({ stopChannel={stopChannel} logos={logos} channelsByUUID={channelsByUUID} + channels={channels} + currentProgram={currentPrograms[connection.data.channel_id]} /> ); } else if (connection.type === 'vod') { @@ -84,6 +106,20 @@ const StatsPage = () => { const [vodConnections, setVodConnections] = useState([]); const [channelHistory, setChannelHistory] = useState({}); const [isPollingActive, setIsPollingActive] = useState(false); + const [currentPrograms, setCurrentPrograms] = useState({}); + + // Use refs to hold latest values without triggering effects + const channelHistoryRef = useRef(channelHistory); + const channelsByUUIDRef = useRef(channelsByUUID); + + // Update refs when values change + useEffect(() => { + channelHistoryRef.current = channelHistory; + }, [channelHistory]); + + useEffect(() => { + channelsByUUIDRef.current = channelsByUUID; + }, [channelsByUUID]); // Use localStorage for stats refresh interval (in seconds) const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage( @@ -191,7 +227,13 @@ const StatsPage = () => { // Use functional update to access previous state without dependency setChannelHistory((prevChannelHistory) => { // Create a completely new object based only on current channel stats - const stats = getStatsByChannelId(channelStats, prevChannelHistory, channelsByUUID, channels, streamProfiles); + const stats = getStatsByChannelId( + channelStats, + prevChannelHistory, + channelsByUUID, + channels, + streamProfiles + ); console.log('Processed active channels:', stats); @@ -202,6 +244,64 @@ const StatsPage = () => { }); }, [channelStats, channels, channelsByUUID, streamProfiles]); + // Track which channel IDs are active (only changes when channels start/stop, not on stats updates) + const activeChannelIds = useMemo(() => { + return Object.keys(channelHistory).sort().join(','); + }, [channelHistory]); + + // Smart polling for current programs - only fetch when active channels change + useEffect(() => { + // Skip if no active channels + if (!activeChannelIds) { + setCurrentPrograms({}); + return; + } + + let timer = null; + + const fetchPrograms = async () => { + // Use refs to get latest values without adding dependencies + const programs = await getCurrentPrograms( + channelHistoryRef.current, + channelsByUUIDRef.current + ); + setCurrentPrograms(programs); + + // Schedule next fetch based on nearest program end time + if (programs && Object.keys(programs).length > 0) { + const now = new Date(); + let nearestEndTime = null; + + Object.values(programs).forEach((program) => { + if (program && program.end_time) { + const endTime = new Date(program.end_time); + if ( + endTime > now && + (!nearestEndTime || endTime < nearestEndTime) + ) { + nearestEndTime = endTime; + } + } + }); + + if (nearestEndTime) { + const timeUntilChange = nearestEndTime.getTime() - now.getTime(); + const fetchDelay = Math.max(timeUntilChange + 5000, 0); + + timer = setTimeout(fetchPrograms, fetchDelay); + } + } + }; + + // Initial fetch + fetchPrograms(); + + // Cleanup timer on unmount or when active channels change + return () => { + if (timer) clearTimeout(timer); + }; + }, [activeChannelIds]); // Only depend on activeChannelIds + // Combine active streams and VOD connections into a single mixed list const combinedConnections = useMemo(() => { return getCombinedConnections(channelHistory, vodConnections); @@ -216,11 +316,12 @@ const StatsPage = () => { Active Connections - {channelHistoryLength} { - channelHistoryLength !== 1 ? 'streams' : 'stream' - } • {vodConnectionsCount} { - vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection' - } + {channelHistoryLength}{' '} + {channelHistoryLength !== 1 ? 'streams' : 'stream'} •{' '} + {vodConnectionsCount}{' '} + {vodConnectionsCount !== 1 + ? 'VOD connections' + : 'VOD connection'} Refresh Interval (seconds): @@ -273,7 +374,9 @@ const StatsPage = () => { combinedConnections={combinedConnections} clients={clients} channelsByUUID={channelsByUUID} + channels={channels} handleStopVODClient={handleStopVODClient} + currentPrograms={currentPrograms} />
diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js index a25e33f0..72198122 100644 --- a/frontend/src/utils/pages/StatsUtils.js +++ b/frontend/src/utils/pages/StatsUtils.js @@ -20,6 +20,41 @@ export const getVODStats = async () => { return await API.getVODStats(); }; +export const getCurrentPrograms = async (channelHistory, channelsByUUID) => { + try { + // Get all active channel IDs that have actual channels (not just streams) + const activeChannelIds = Object.values(channelHistory) + .filter(ch => ch.name && channelsByUUID && channelsByUUID[ch.channel_id]) + .map(ch => channelsByUUID[ch.channel_id]) + .filter(id => id !== undefined); + + if (activeChannelIds.length === 0) { + return {}; + } + + const programs = await API.getCurrentPrograms(activeChannelIds); + + // Convert array to map keyed by channel UUID for easy lookup + const programsMap = {}; + if (programs && Array.isArray(programs)) { + programs.forEach(program => { + // Find the channel UUID from the channel ID + const channelEntry = Object.entries(channelsByUUID).find( + ([uuid, id]) => id === program.channel_id + ); + if (channelEntry) { + programsMap[channelEntry[0]] = program; + } + }); + } + + return programsMap; + } catch (error) { + console.error('Error fetching current programs:', error); + return {}; + } +}; + export const getCombinedConnections = (channelHistory, vodConnections) => { const activeStreams = Object.values(channelHistory).map((channel) => ({ type: 'stream', From c6f359c15988c8f7bf34a40d762138e3b38131aa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 14:56:32 -0600 Subject: [PATCH 32/75] Enhancement: Refactor StreamsTable component to improve MultiSelect UI with tooltips and pill displays for selected groups and M3Us. --- .../src/components/tables/StreamsTable.jsx | 199 +++++++++--------- 1 file changed, 97 insertions(+), 102 deletions(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index ac189b32..e165c62c 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -51,6 +51,7 @@ import { NumberInput, Radio, LoadingOverlay, + Pill, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; import useSettingsStore from '../../store/settings'; @@ -871,52 +872,48 @@ const StreamsTable = ({ onReady }) => { ? filters.channel_group.split(',').filter(Boolean) : []; return ( - { - const index = selectedGroups.indexOf(value); - if (index === 0) { - return ( - - - {value} - - {selectedGroups.length > 1 && ( - - +{selectedGroups.length - 1} - + + {selectedGroups.length > 0 && ( + + {selectedGroups.slice(0, 10).map((group, idx) => ( +
{group}
+ ))} + {selectedGroups.length > 10 && ( +
+ +{selectedGroups.length - 10} more +
)} -
- ); - } - return null; - }} - style={{ width: '100%' }} - /> + + } + position="top" + withArrow + > + + {selectedGroups[0]} + {selectedGroups.length > 1 && ( + +{selectedGroups.length - 1} + )} + +
+ )} + + ); } @@ -924,66 +921,64 @@ const StreamsTable = ({ onReady }) => { const selectedM3Us = filters.m3u_account ? filters.m3u_account.split(',').filter(Boolean) : []; + const firstLabel = selectedM3Us.length > 0 + ? (m3uOptions.find((opt) => opt.value === selectedM3Us[0])?.label || selectedM3Us[0]) + : null; return ( - { - const index = selectedM3Us.indexOf(value); - if (index === 0) { - const label = - m3uOptions.find((opt) => opt.value === value)?.label || - value; - return ( - - - {label} - - {selectedM3Us.length > 1 && ( - - +{selectedM3Us.length - 1} - + + {selectedM3Us.length > 0 && ( + + {selectedM3Us.slice(0, 10).map((val, idx) => ( +
+ {m3uOptions.find((opt) => opt.value === val)?.label || val} +
+ ))} + {selectedM3Us.length > 10 && ( +
+ +{selectedM3Us.length - 10} more +
)} -
- ); - } - return null; - }} - style={{ flex: 1, minWidth: 0 }} - rightSectionPointerEvents="auto" - rightSection={React.createElement(sortingIcon, { - onClick: (e) => { - e.stopPropagation(); - onSortingChange('m3u'); - }, - size: 14, - style: { cursor: 'pointer' }, - })} - /> + + } + position="top" + withArrow + > + + {firstLabel} + {selectedM3Us.length > 1 && ( + +{selectedM3Us.length - 1} + )} + + + )} + { + e.stopPropagation(); + onSortingChange('m3u'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} + /> +
); } From 091c1686e55ff143f45c8e38514d12d5eb9e2bd1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 15:31:34 -0600 Subject: [PATCH 33/75] Enhancement: Added tooltip on filter pills showing all selected items in a vertical list (up to 10 items, with "+N more" indicator) Also fixed +N not working as intended. --- CHANGELOG.md | 1 + .../src/components/tables/ChannelsTable.jsx | 14 ++ .../tables/CustomTable/CustomTableHeader.jsx | 58 ++++---- .../CustomTable/MultiSelectHeaderWrapper.jsx | 120 ++++++++++++++++ .../src/components/tables/StreamsTable.jsx | 135 +++++------------- 5 files changed, 206 insertions(+), 122 deletions(-) create mode 100644 frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c371dd93..cb61bbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added tooltip on filter pills showing all selected items in a vertical list (up to 10 items, with "+N more" indicator) - Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes. - Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access. - ProfileModal component extracted for improved code organization and maintainability of channel profile management operations. diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b986e70e..78dd4f4c 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1000,6 +1000,13 @@ const ChannelsTable = ({ onReady }) => { clearable onClick={stopPropagation} onChange={handleEPGChange} + value={ + Array.isArray(filters.epg) + ? filters.epg + : filters.epg + ? filters.epg.split(',').filter(Boolean) + : [] + } style={{ width: '100%' }} /> ); @@ -1058,6 +1065,13 @@ const ChannelsTable = ({ onReady }) => { clearable onClick={stopPropagation} onChange={handleGroupChange} + value={ + Array.isArray(filters.channel_group) + ? filters.channel_group + : filters.channel_group + ? filters.channel_group.split(',').filter(Boolean) + : [] + } style={{ width: '100%' }} /> ); diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx index 1bfc7773..0adc516e 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -1,6 +1,7 @@ import { Box, Center, Checkbox, Flex } from '@mantine/core'; import { flexRender } from '@tanstack/react-table'; import { useCallback, useMemo } from 'react'; +import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper'; const CustomTableHeader = ({ getHeaderGroups, @@ -12,33 +13,42 @@ const CustomTableHeader = ({ headerPinned = true, }) => { const renderHeaderCell = (header) => { + let content; + if (headerCellRenderFns[header.id]) { - return headerCellRenderFns[header.id](header); + content = headerCellRenderFns[header.id](header); + } else { + switch (header.id) { + case 'select': + content = ( +
+ 0 && + selectedTableIds.length !== allRowIds.length + } + onChange={onSelectAllChange} + /> +
+ ); + break; + + default: + content = flexRender( + header.column.columnDef.header, + header.getContext() + ); + } } - switch (header.id) { - case 'select': - return ( -
- 0 && - selectedTableIds.length !== allRowIds.length - } - onChange={onSelectAllChange} - /> -
- ); - - default: - return flexRender(header.column.columnDef.header, header.getContext()); - } + // Automatically wrap content to enhance MultiSelect components + return {content}; }; // Get header groups for dependency tracking diff --git a/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx new file mode 100644 index 00000000..947d03d9 --- /dev/null +++ b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx @@ -0,0 +1,120 @@ +import React, { cloneElement, isValidElement } from 'react'; +import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core'; + +/** + * Automatically wraps MultiSelect components with pill display and tooltips + * Recursively searches through React children to find and enhance MultiSelect + */ +const MultiSelectHeaderWrapper = ({ children }) => { + const enhanceMultiSelect = (element) => { + if (!isValidElement(element)) { + return element; + } + + // Check if this element is a MultiSelect + if (element.type === MultiSelect) { + const { value = [], data = [], ...otherProps } = element.props; + const selectedValues = Array.isArray(value) ? value : []; + + if (selectedValues.length === 0) { + // No selections - just render the MultiSelect with hidden pills + return cloneElement(element, { + ...otherProps, + value, + data, + styles: { pill: { display: 'none' } }, + }); + } + + // Get first label + const firstLabel = + data.find((opt) => opt.value === selectedValues[0])?.label || + selectedValues[0]; + + // Build tooltip content + const tooltipContent = ( +
+ {selectedValues.slice(0, 10).map((val, idx) => { + const label = data.find((opt) => opt.value === val)?.label || val; + return
{label}
; + })} + {selectedValues.length > 10 && ( +
+ +{selectedValues.length - 10} more +
+ )} +
+ ); + + return ( + + + + 1 + ? 'calc(100% - 50px)' + : 'calc(100% - 30px)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'block', + pointerEvents: 'auto', + }} + > + {firstLabel} + + {selectedValues.length > 1 && ( + + +{selectedValues.length - 1} + + )} + + + {cloneElement(element, { + ...otherProps, + value, + data, + styles: { pill: { display: 'none' } }, + style: { width: '100%', ...otherProps.style }, + })} + + ); + } + + // Check if element has children - recursively enhance them + if (element.props && element.props.children) { + const enhancedChildren = React.Children.map( + element.props.children, + (child) => enhanceMultiSelect(child) + ); + + // Clone element with enhanced children + return cloneElement(element, {}, enhancedChildren); + } + + return element; + }; + + return <>{enhanceMultiSelect(children)}; +}; + +export default MultiSelectHeaderWrapper; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index e165c62c..aa210b6e 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -872,48 +872,20 @@ const StreamsTable = ({ onReady }) => { ? filters.channel_group.split(',').filter(Boolean) : []; return ( - - {selectedGroups.length > 0 && ( - - {selectedGroups.slice(0, 10).map((group, idx) => ( -
{group}
- ))} - {selectedGroups.length > 10 && ( -
- +{selectedGroups.length - 10} more -
- )} - - } - position="top" - withArrow - > - - {selectedGroups[0]} - {selectedGroups.length > 1 && ( - +{selectedGroups.length - 1} - )} - -
- )} - -
+ ); } @@ -921,64 +893,31 @@ const StreamsTable = ({ onReady }) => { const selectedM3Us = filters.m3u_account ? filters.m3u_account.split(',').filter(Boolean) : []; - const firstLabel = selectedM3Us.length > 0 - ? (m3uOptions.find((opt) => opt.value === selectedM3Us[0])?.label || selectedM3Us[0]) - : null; return ( - - {selectedM3Us.length > 0 && ( - - {selectedM3Us.slice(0, 10).map((val, idx) => ( -
- {m3uOptions.find((opt) => opt.value === val)?.label || val} -
- ))} - {selectedM3Us.length > 10 && ( -
- +{selectedM3Us.length - 10} more -
- )} - - } - position="top" - withArrow - > - - {firstLabel} - {selectedM3Us.length > 1 && ( - +{selectedM3Us.length - 1} - )} - -
- )} - { - e.stopPropagation(); - onSortingChange('m3u'); - }, - size: 14, - style: { cursor: 'pointer' }, - })} - /> -
+ { + e.stopPropagation(); + onSortingChange('m3u'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} + />
); } From 6b9e6b2d8a3e6a232b5a4e478b90bb239bdafde9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 15:38:36 -0600 Subject: [PATCH 34/75] tests: Fix frontent tests for new stats features. --- frontend/src/pages/__tests__/Stats.test.jsx | 57 +++++++++++++-------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index bf5cdb42..c22ceac9 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -14,6 +14,7 @@ import useChannelsStore from '../../store/channels'; import useLogosStore from '../../store/logos'; import { fetchActiveChannelStats, + getCurrentPrograms, getClientStats, getCombinedConnections, getStatsByChannelId, @@ -30,11 +31,11 @@ vi.mock('../../store/streamProfiles'); vi.mock('../../hooks/useLocalStorage'); vi.mock('../../components/SystemEvents', () => ({ - default: () =>
SystemEvents
+ default: () =>
SystemEvents
, })); vi.mock('../../components/ErrorBoundary.jsx', () => ({ - default: ({ children }) =>
{children}
+ default: ({ children }) =>
{children}
, })); vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({ @@ -92,6 +93,7 @@ vi.mock('../../utils/pages/StatsUtils', () => { return { fetchActiveChannelStats: vi.fn(), getVODStats: vi.fn(), + getCurrentPrograms: vi.fn(), getClientStats: vi.fn(), getCombinedConnections: vi.fn(), getStatsByChannelId: vi.fn(), @@ -112,9 +114,7 @@ describe('StatsPage', () => { 'channel-2': mockChannels[1], }; - const mockStreamProfiles = [ - { id: 1, name: 'Profile 1' }, - ]; + const mockStreamProfiles = [{ id: 1, name: 'Profile 1' }]; const mockLogos = { 'logo-1': 'logo-url-1', @@ -131,9 +131,7 @@ describe('StatsPage', () => { vod_connections: [ { content_uuid: 'vod-1', - connections: [ - { client_id: 'client-1', ip: '192.168.1.1' }, - ], + connections: [{ client_id: 'client-1', ip: '192.168.1.1' }], }, ], }; @@ -152,7 +150,11 @@ describe('StatsPage', () => { const mockCombinedConnections = [ { id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } }, { id: 2, type: 'stream', data: { id: 2, uuid: 'channel-2' } }, - { id: 3, type: 'vod', data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] } }, + { + id: 3, + type: 'vod', + data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] }, + }, ]; let mockSetChannelStats; @@ -194,6 +196,7 @@ describe('StatsPage', () => { // Setup API mocks fetchActiveChannelStats.mockResolvedValue(mockChannelStats); getVODStats.mockResolvedValue(mockVODStats); + getCurrentPrograms.mockResolvedValue({}); getStatsByChannelId.mockReturnValue(mockProcessedChannelHistory); getClientStats.mockReturnValue(mockClients); getCombinedConnections.mockReturnValue(mockCombinedConnections); @@ -206,7 +209,7 @@ describe('StatsPage', () => { describe('Initial Rendering', () => { it('renders the page title', async () => { render(); - await screen.findByText('Active Connections') + await screen.findByText('Active Connections'); }); it('fetches initial stats on mount', async () => { @@ -229,7 +232,7 @@ describe('StatsPage', () => { it('renders SystemEvents component', async () => { render(); - await screen.findByTestId('system-events') + await screen.findByTestId('system-events'); }); }); @@ -266,7 +269,7 @@ describe('StatsPage', () => { useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - await screen.findByText('Refreshing disabled') + await screen.findByText('Refreshing disabled'); }); }); @@ -348,8 +351,12 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(screen.getByTestId('stream-connection-card-channel-1')).toBeInTheDocument(); - expect(screen.getByTestId('stream-connection-card-channel-2')).toBeInTheDocument(); + expect( + screen.getByTestId('stream-connection-card-channel-1') + ).toBeInTheDocument(); + expect( + screen.getByTestId('stream-connection-card-channel-2') + ).toBeInTheDocument(); }); }); @@ -357,7 +364,9 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument(); + expect( + screen.getByTestId('vod-connection-card-vod-1') + ).toBeInTheDocument(); }); }); @@ -376,7 +385,9 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(screen.getByTestId('stop-vod-client-client-1')).toBeInTheDocument(); + expect( + screen.getByTestId('stop-vod-client-client-1') + ).toBeInTheDocument(); }); const stopButton = screen.getByTestId('stop-vod-client-client-1'); @@ -422,14 +433,18 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(getClientStats).toHaveBeenCalledWith(mockProcessedChannelHistory); + expect(getClientStats).toHaveBeenCalledWith( + mockProcessedChannelHistory + ); }); }); }); describe('Error Handling', () => { it('handles fetchActiveChannelStats error gracefully', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); fetchActiveChannelStats.mockRejectedValue(new Error('API Error')); render(); @@ -445,7 +460,9 @@ describe('StatsPage', () => { }); it('handles getVODStats error gracefully', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); getVODStats.mockRejectedValue(new Error('VOD API Error')); render(); @@ -491,4 +508,4 @@ describe('StatsPage', () => { }); }); }); -}); \ No newline at end of file +}); From 0984ec98349db8ec8d0ddade1878c2900e02d6e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 16:31:41 -0600 Subject: [PATCH 35/75] Enhancement: Add Progress bar showing elapsed and remaining time for currently playing programs --- CHANGELOG.md | 1 + .../components/cards/StreamConnectionCard.jsx | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb61bbf4..2946d261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: - Currently playing program title displayed with live broadcast indicator (green Radio icon) - Expandable program descriptions via chevron button + - Progress bar showing elapsed and remaining time for currently playing programs - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels - Smart scheduling that fetches new program data 5 seconds after current program ends - Only polls when active channel list changes, not on stats refresh diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 5488aef5..e8b8a33b 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -11,6 +11,7 @@ import { Center, Flex, Group, + Progress, Select, Stack, Text, @@ -549,6 +550,56 @@ const StreamConnectionCard = ({ )} + {/* Program progress bar */} + {currentProgram && + isProgramDescExpanded && + currentProgram.start_time && + currentProgram.end_time && + (() => { + const now = new Date(); + const startTime = new Date(currentProgram.start_time); + const endTime = new Date(currentProgram.end_time); + const totalDuration = (endTime - startTime) / 1000; // in seconds + const elapsed = (now - startTime) / 1000; // in seconds + const remaining = (endTime - now) / 1000; // in seconds + const percentage = Math.min( + 100, + Math.max(0, (elapsed / totalDuration) * 100) + ); + + const formatProgramTime = (seconds) => { + const absSeconds = Math.abs(seconds); + const hours = Math.floor(absSeconds / 3600); + const minutes = Math.floor((absSeconds % 3600) / 60); + const secs = Math.floor(absSeconds % 60); + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + return ( + + + + {formatProgramTime(elapsed)} elapsed + + + {formatProgramTime(remaining)} remaining + + + + + ); + })()} + {/* Add stream selection dropdown and preview button */} {availableStreams.length > 0 && ( From 8bc88112aa3fbdc17e6088e485482537b0fb56c4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 17:09:28 -0600 Subject: [PATCH 36/75] Enhancement: Improved pill functionality and sizing. --- .../CustomTable/MultiSelectHeaderWrapper.jsx | 95 ++++++++++++++++--- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx index 947d03d9..8fca940f 100644 --- a/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx +++ b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx @@ -1,4 +1,4 @@ -import React, { cloneElement, isValidElement } from 'react'; +import React, { cloneElement, isValidElement, useRef } from 'react'; import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core'; /** @@ -6,6 +6,8 @@ import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core'; * Recursively searches through React children to find and enhance MultiSelect */ const MultiSelectHeaderWrapper = ({ children }) => { + const inputRef = useRef(null); + const enhanceMultiSelect = (element) => { if (!isValidElement(element)) { return element; @@ -13,7 +15,7 @@ const MultiSelectHeaderWrapper = ({ children }) => { // Check if this element is a MultiSelect if (element.type === MultiSelect) { - const { value = [], data = [], ...otherProps } = element.props; + const { value = [], data = [], onChange, ...otherProps } = element.props; const selectedValues = Array.isArray(value) ? value : []; if (selectedValues.length === 0) { @@ -22,6 +24,7 @@ const MultiSelectHeaderWrapper = ({ children }) => { ...otherProps, value, data, + onChange, styles: { pill: { display: 'none' } }, }); } @@ -46,8 +49,42 @@ const MultiSelectHeaderWrapper = ({ children }) => { ); + // Handle opening the dropdown when pill is clicked + const handlePillClick = (e) => { + // Check if the click is on the remove button (it has a data-attribute) + if (e.target.closest('[data-disabled]') || e.target.closest('button')) { + return; // Let the remove button handle it + } + e.stopPropagation(); + // Focus and click the input to open the dropdown + if (inputRef.current) { + const input = inputRef.current.querySelector('input'); + if (input) { + input.focus(); + input.click(); + } + } + }; + + // Handle removing a single filter value + const handleRemoveFirst = (e) => { + e?.stopPropagation?.(); + if (onChange && selectedValues.length > 0) { + const newValues = selectedValues.slice(1); + onChange(newValues); + } + }; + + // Handle clearing all filters + const handleClearAll = (e) => { + e?.stopPropagation?.(); + if (onChange) { + onChange([]); + } + }; + return ( - + { position: 'absolute', top: 4, left: 4, - right: 30, + right: 20, zIndex: 1, pointerEvents: 'none', overflow: 'hidden', @@ -63,28 +100,55 @@ const MultiSelectHeaderWrapper = ({ children }) => { > 1 ? '1 1 auto' : '0 1 auto', minWidth: 0, maxWidth: - selectedValues.length > 1 - ? 'calc(100% - 50px)' - : 'calc(100% - 30px)', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - display: 'block', + selectedValues.length > 1 ? 'calc(100% - 40px)' : '100%', pointerEvents: 'auto', }} > - {firstLabel} + + {firstLabel} + {selectedValues.length > 1 && ( - +{selectedValues.length - 1} + + +{selectedValues.length - 1} + )} @@ -93,6 +157,7 @@ const MultiSelectHeaderWrapper = ({ children }) => { ...otherProps, value, data, + onChange, styles: { pill: { display: 'none' } }, style: { width: '100%', ...otherProps.style }, })} From c9b454431c678a08298a3b316720d20e286d666a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 18:01:17 -0600 Subject: [PATCH 37/75] Enhancement: Streams table UI: Added descriptive tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters, Create Stream, Delete) and to row action icons (Add to Channel, Create New Channel). Tooltips now use a 500ms open delay for consistent behavior with existing table header tooltips. Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology. --- CHANGELOG.md | 2 + .../src/components/tables/StreamsTable.jsx | 157 ++++++++++-------- 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2946d261..b1738429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content) - Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663) - Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) +- Streams table UI: Added descriptive tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters, Create Stream, Delete) and to row action icons (Add to Channel, Create New Channel). Tooltips now use a 500ms open delay for consistent behavior with existing table header tooltips. ### Changed - Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. +- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology. - Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. - Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `.g maintainability and providing consistent API across all tables. - Optimized unassociated streams filter performance: Replaced inefficient reverse foreign key NULL check (`channels__isnull=True`) with Count annotation approach, reducing query time from 4-5 seconds to under 500ms for large datasets (75k+ streams) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index aa210b6e..22e7cb67 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -117,7 +117,7 @@ const StreamRowActions = ({ return ( <> - + - + { gap={6} > - + ? 'light' + : 'default' + } + size="xs" + onClick={addStreamsToChannel} + p={5} + color={ + selectedStreamIds.length > 0 && + selectedChannelIds.length === 1 + ? theme.tailwind.green[5] + : undefined + } + style={ + selectedStreamIds.length > 0 && + selectedChannelIds.length === 1 + ? { + borderWidth: '1px', + borderColor: theme.tailwind.green[5], + color: 'white', + } + : undefined + } + disabled={ + !( + selectedStreamIds.length > 0 && + selectedChannelIds.length === 1 + ) + } + > + Add to Channel + + - + +
- + + + @@ -1185,31 +1200,35 @@ const StreamsTable = ({ onReady }) => { - + + + - + + + From 2fc2486c344ccb46e332ba9136fa4e61008fdf06 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 18:51:13 -0600 Subject: [PATCH 38/75] Enhancement: Added "Hide Stale" filter to quickly hide streams marked as stale. --- CHANGELOG.md | 1 + apps/channels/api_views.py | 7 +++- .../src/components/tables/StreamsTable.jsx | 34 ++++++++++++++++--- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1738429..599fb986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Only polls when active channel list changes, not on stats refresh - Channel preview button: Added preview functionality to active stream cards on stats page - Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) +- Streams table: Added "Hide Stale" filter to quickly hide streams marked as stale. - Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom) - DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery. - Mature content filtering support: diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index f9daf0d0..11940177 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -149,7 +149,7 @@ class StreamViewSet(viewsets.ModelViewSet): qs = qs.filter(channels__id=assigned) unassigned = self.request.query_params.get("unassigned") - if unassigned == "1": + if unassigned and str(unassigned).lower() in ("1", "true", "yes", "on"): # Use annotation with Count for better performance on large datasets qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0) @@ -158,6 +158,11 @@ class StreamViewSet(viewsets.ModelViewSet): group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) + # Allow client to hide stale streams (streams marked as is_stale=True) + hide_stale = self.request.query_params.get("hide_stale") + if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"): + qs = qs.filter(is_stale=False) + return qs def list(self, request, *args, **kwargs): diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 22e7cb67..5b9182b2 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -232,7 +232,8 @@ const StreamsTable = ({ onReady }) => { name: '', channel_group: '', m3u_account: '', - unassigned: '', + unassigned: false, + hide_stale: false, }); const [columnSizing, setColumnSizing] = useLocalStorage( 'streams-table-column-sizing', @@ -398,7 +399,14 @@ const StreamsTable = ({ onReady }) => { const toggleUnassignedOnly = () => { setFilters((prev) => ({ ...prev, - unassigned: prev.unassigned === '1' ? '' : '1', + unassigned: !prev.unassigned, + })); + }; + + const toggleHideStale = () => { + setFilters((prev) => ({ + ...prev, + hide_stale: !prev.hide_stale, })); }; @@ -426,9 +434,13 @@ const StreamsTable = ({ onReady }) => { params.append('ordering', `${sortDirection}${sortField}`); } - // Apply debounced filters + // Apply debounced filters; send boolean filters as 'true' when set Object.entries(debouncedFilters).forEach(([key, value]) => { - if (value) params.append(key, value); + if (typeof value === 'boolean') { + if (value) params.append(key, 'true'); + } else if (value !== null && value !== undefined && value !== '') { + params.append(key, String(value)); + } }); try { @@ -1188,7 +1200,7 @@ const StreamsTable = ({ onReady }) => { ) : ( @@ -1197,6 +1209,18 @@ const StreamsTable = ({ onReady }) => { > Only Unassociated + + ) : ( + + ) + } + > + Hide Stale + From e43aba67e71730796e5e1b1b4062c034449973cd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 19:35:19 -0600 Subject: [PATCH 39/75] Enhancement: Refactor StreamConnectionCard layout for improved UI, separating channel name and current program display. --- .../components/cards/StreamConnectionCard.jsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index e8b8a33b..6c5fc4fb 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -494,9 +494,8 @@ const StreamConnectionCard = ({ - - {channelName} - + {/* Stream Profile on right */} + - {/* Display M3U profile and current program */} + {/* Channel Name on left, M3U Profile on right */} - {currentProgram ? ( - - - - Now Playing: - - - {currentProgram.title} - - setIsProgramDescExpanded(!isProgramDescExpanded)} - > - {isProgramDescExpanded ? ( - - ) : ( - - )} - - - ) : ( - - )} + {channelName} + @@ -539,6 +516,30 @@ const StreamConnectionCard = ({ + {/* Display current program on its own line */} + {currentProgram && ( + + + + Now Playing: + + + {currentProgram.title} + + setIsProgramDescExpanded(!isProgramDescExpanded)} + > + {isProgramDescExpanded ? ( + + ) : ( + + )} + + + )} + {/* Expandable program description */} {currentProgram && isProgramDescExpanded && From cbb8a521633e2d619e1e83acefab8d5d5865f3b2 Mon Sep 17 00:00:00 2001 From: Mattias Svensson Date: Wed, 21 Jan 2026 08:04:49 +0100 Subject: [PATCH 40/75] Fix: nginx startup failure due to group name mismatch The script was creating a group named 'dispatch' (hardcoded) while nginx.conf expected a group matching POSTGRES_USER (e.g., 'dispatcharr'). Changes: - Use $POSTGRES_USER for group name instead of hardcoded 'dispatch' - Make sed command more robust to match any existing user directive This ensures the group name matches the user name, fixing the error: "getgrnam("dispatcharr") failed in /etc/nginx/nginx.conf:1" Fixes #877 Co-Authored-By: Claude Opus 4.5 --- docker/init/01-user-setup.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh index d7041265..1b0e0a27 100644 --- a/docker/init/01-user-setup.sh +++ b/docker/init/01-user-setup.sh @@ -6,17 +6,17 @@ export PGID=${PGID:-1000} # Check if group with PGID exists if getent group "$PGID" >/dev/null 2>&1; then - # Group exists, check if it's named 'dispatch' + # Group exists, check if it's named correctly (should match POSTGRES_USER) existing_group=$(getent group "$PGID" | cut -d: -f1) - if [ "$existing_group" != "dispatch" ]; then - # Rename the existing group to 'dispatch' - groupmod -n "dispatch" "$existing_group" - echo "Group $existing_group with GID $PGID renamed to dispatch" + if [ "$existing_group" != "$POSTGRES_USER" ]; then + # Rename the existing group to match POSTGRES_USER + groupmod -n "$POSTGRES_USER" "$existing_group" + echo "Group $existing_group with GID $PGID renamed to $POSTGRES_USER" fi else - # Group doesn't exist, create it - groupadd -g "$PGID" dispatch - echo "Group dispatch with GID $PGID created" + # Group doesn't exist, create it with same name as POSTGRES_USER + groupadd -g "$PGID" "$POSTGRES_USER" + echo "Group $POSTGRES_USER with GID $PGID created" fi # Create user if it doesn't exist @@ -86,5 +86,5 @@ if getent group video >/dev/null 2>&1; then fi fi -# Run nginx as specified user -sed -i "s/user www-data;/user $POSTGRES_USER;/g" /etc/nginx/nginx.conf +# Run nginx as specified user (replace any existing user directive on line 1) +sed -i "1s/^user .*/user $POSTGRES_USER;/" /etc/nginx/nginx.conf From 58d14664e3e6005539db2b43e554f6761f864219 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 21 Jan 2026 16:35:00 -0600 Subject: [PATCH 41/75] Enhancement: Adjust layout and spacing in StreamConnectionCard for improved UI consistency and readability --- .../components/cards/StreamConnectionCard.jsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 6c5fc4fb..bd7c327c 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -450,14 +450,14 @@ const StreamConnectionCard = ({ w={'100%'} > - + - +
@@ -494,42 +494,46 @@ const StreamConnectionCard = ({ - {/* Stream Profile on right */} - + {/* Stream Profile on right - absolutely positioned */} + - - - {/* Channel Name on left, M3U Profile on right */} - - {channelName} + + {/* M3U Profile on right - absolutely positioned */} + {m3uProfileName} - + + + {/* Channel Name on left */} + + {channelName} + {/* Display current program on its own line */} {currentProgram && ( - - - + + + Now Playing: - + {currentProgram.title} setIsProgramDescExpanded(!isProgramDescExpanded)} + style={{ flexShrink: 0 }} > {isProgramDescExpanded ? ( @@ -603,7 +607,7 @@ const StreamConnectionCard = ({ {/* Add stream selection dropdown and preview button */} {availableStreams.length > 0 && ( - + @@ -640,7 +644,7 @@ const StreamConnectionCard = ({ )} {/* Add stream information badges */} - + {channel.resolution && ( From e79622f513474b68a178b3418a89b747f8a2cf05 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 21 Jan 2026 16:44:31 -0600 Subject: [PATCH 42/75] adjust arrow to be next to title always. --- frontend/src/components/cards/StreamConnectionCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index bd7c327c..1cd99814 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -526,7 +526,7 @@ const StreamConnectionCard = ({ Now Playing: - + {currentProgram.title} Date: Wed, 21 Jan 2026 16:55:25 -0600 Subject: [PATCH 43/75] Bug fix: Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712) --- CHANGELOG.md | 1 + frontend/src/components/cards/StreamConnectionCard.jsx | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 599fb986..14b41dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712) - Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869) - Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page: - Stream connection card "Connected" column diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 1cd99814..bf72b109 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -271,6 +271,14 @@ const StreamConnectionCard = ({ { header: 'IP Address', accessorKey: 'ip_address', + size: 150, + cell: ({ cell }) => ( + + + {cell.getValue()} + + + ), }, // Updated Connected column with tooltip { From 6e70753d1c7e949e7dfc816851c6f93f30b2bc3b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 22 Jan 2026 14:25:33 -0600 Subject: [PATCH 44/75] changelog: Fixed nginx startup failure due to group name mismatch in non-container deployments --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b41dcf..c63d73e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712) +- Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877) - Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869) - Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page: - Stream connection card "Connected" column From d4f412e3525dddc645941d81dddfec017b8e0971 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 23 Jan 2026 10:33:14 -0600 Subject: [PATCH 45/75] Bug Fix: Channels table pagination error handling: Fixed "Invalid page" error notifications that appeared when filters reduced the result set while on a page beyond the new total. The API layer now automatically detects invalid page errors, resets pagination to page 1, and retries the request transparently. (Fixes #864) --- CHANGELOG.md | 1 + frontend/src/api.js | 58 +++++++++++++++++++ .../src/components/tables/ChannelsTable.jsx | 39 ++++++++----- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c63d73e5..bd73931a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Channels table pagination error handling: Fixed "Invalid page" error notifications that appeared when filters reduced the result set while on a page beyond the new total. The API layer now automatically detects invalid page errors, resets pagination to page 1, and retries the request transparently. (Fixes #864) - Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712) - Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877) - Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869) diff --git a/frontend/src/api.js b/frontend/src/api.js index 4b728622..3df0b475 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -198,6 +198,31 @@ export default class API { return response; } catch (e) { + // Handle invalid page error by resetting to page 1 and retrying + if (e.body?.detail === 'Invalid page.') { + const currentPagination = useChannelsTableStore.getState().pagination; + + // Only retry if we're not already on page 1 + if (currentPagination.pageIndex > 0) { + // Reset to page 1 + useChannelsTableStore.getState().setPagination({ + ...currentPagination, + pageIndex: 0, + }); + + // Update params to page 1 and retry + const newParams = new URLSearchParams(params); + newParams.set('page', '1'); + + const response = await request( + `${host}/api/channels/channels/?${newParams.toString()}` + ); + + useChannelsTableStore.getState().queryChannels(response, newParams); + return response; + } + } + errorNotification('Failed to fetch channels', e); } } @@ -218,6 +243,39 @@ export default class API { return response; } catch (e) { + // Handle invalid page error by resetting to page 1 and retrying + if (e.body?.detail === 'Invalid page.') { + const currentPagination = useChannelsTableStore.getState().pagination; + + // Only retry if we're not already on page 1 + if (currentPagination.pageIndex > 0) { + // Reset to page 1 + useChannelsTableStore.getState().setPagination({ + ...currentPagination, + pageIndex: 0, + }); + + // Update params to page 1 and retry + const newParams = new URLSearchParams(API.lastQueryParams); + newParams.set('page', '1'); + API.lastQueryParams = newParams; + + const [response, ids] = await Promise.all([ + request( + `${host}/api/channels/channels/?${newParams.toString()}` + ), + API.getAllChannelIds(newParams), + ]); + + useChannelsTableStore + .getState() + .queryChannels(response, newParams); + useChannelsTableStore.getState().setAllQueryIds(ids); + + return response; + } + } + errorNotification('Failed to fetch channels', e); } } diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 78dd4f4c..bcacfe98 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -421,25 +421,32 @@ const ChannelsTable = ({ onReady }) => { } }); - const [results, ids] = await Promise.all([ - await API.queryChannels(params), - await API.getAllChannelIds(params), - ]); + try { + const [results, ids] = await Promise.all([ + await API.queryChannels(params), + await API.getAllChannelIds(params), + ]); - setIsLoading(false); - hasFetchedData.current = true; + setIsLoading(false); + hasFetchedData.current = true; - setTablePrefs((prev) => ({ - ...prev, - pageSize: pagination.pageSize, - })); - setAllRowIds(ids); + setTablePrefs((prev) => ({ + ...prev, + pageSize: pagination.pageSize, + })); + setAllRowIds(ids); - // Signal ready after first successful data fetch AND EPG data is loaded - // This prevents the EPG column from showing "Not Assigned" while EPG data is still loading - if (!hasSignaledReady.current && onReady && tvgsLoaded) { - hasSignaledReady.current = true; - onReady(); + // Signal ready after first successful data fetch AND EPG data is loaded + // This prevents the EPG column from showing "Not Assigned" while EPG data is still loading + if (!hasSignaledReady.current && onReady && tvgsLoaded) { + hasSignaledReady.current = true; + onReady(); + } + } catch (error) { + setIsLoading(false); + // API layer handles "Invalid page" errors by resetting and retrying + // Just re-throw to show notification for actual errors + throw error; } }, [ pagination, From 5348a4a5640b8ae937ee6dcdb2ad6a1e0b1733e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 23 Jan 2026 16:03:26 -0600 Subject: [PATCH 46/75] Feature: Editable Channel Table Mode: - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. - EPG and logo columns now support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. - Group column now uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors. - All editable cells provide clear focus, hover, and disabled states for improved accessibility and usability. - Table unlock/edit mode toggle with clear visual cues and safe-guarded save logic to prevent accidental edits. - All changes are saved via API with optimistic UI updates and error handling. --- CHANGELOG.md | 8 + apps/channels/api_views.py | 75 +- frontend/src/api.js | 18 + .../src/components/tables/ChannelsTable.jsx | 217 ++--- .../ChannelsTable/ChannelTableHeader.jsx | 38 + .../tables/ChannelsTable/DraggableRow.jsx | 59 ++ .../tables/ChannelsTable/EditableCell.jsx | 781 ++++++++++++++++++ .../tables/CustomTable/CustomTable.jsx | 6 +- .../tables/CustomTable/CustomTableBody.jsx | 77 +- .../tables/CustomTable/CustomTableHeader.jsx | 5 + frontend/src/store/channelsTable.jsx | 13 + 11 files changed, 1188 insertions(+), 109 deletions(-) create mode 100644 frontend/src/components/tables/ChannelsTable/DraggableRow.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/EditableCell.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index bd73931a..9d920d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Editable Channel Table Mode: + - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. + - EPG and logo columns now support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. + - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. + - Group column now uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors. + - All editable cells provide clear focus, hover, and disabled states for improved accessibility and usability. + - Table unlock/edit mode toggle with clear visual cues and safe-guarded save logic to prevent accidental edits. + - All changes are saved via API with optimistic UI updates and error handling. - Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: - Currently playing program title displayed with live broadcast indicator (green Radio icon) - Expandable program descriptions via chevron button diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 11940177..2b2870fa 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 -from django.db.models import Count +from django.db.models import Count, F from django.db.models import Q import os, json, requests, logging, mimetypes from django.utils.http import http_date @@ -1251,6 +1251,79 @@ class ChannelViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"error": str(e)}, status=400) + @swagger_auto_schema( + method="post", + operation_description=( + "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " + "The channel will receive the next whole number after the target channel, and all subsequent " + "channels will be renumbered accordingly." + ), + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "insert_after_id": openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the channel to insert after. Use null to move to the beginning.", + nullable=True, + ), + }, + ), + responses={ + 200: "Channel reordered successfully", + 404: "Channel not found", + 400: "Invalid request", + }, + ) + @action(detail=True, methods=["post"], url_path="reorder") + def reorder(self, request, pk=None): + """ + Reorder a channel by moving it near a target position. + Finds the first available channel number without unnecessarily shifting other channels. + """ + channel = self.get_object() + insert_after_id = request.data.get("insert_after_id") + old_channel_number = channel.channel_number + + with transaction.atomic(): + if insert_after_id is None: + # Move to the beginning - find first available number starting from 1 + new_channel_number = 1 + # Check if 1 is taken, find first gap + occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True)) + while new_channel_number in occupied: + new_channel_number += 1 + else: + try: + target_channel = Channel.objects.get(id=insert_after_id) + target_number = target_channel.channel_number or 0 + desired_position = int(target_number) + 1 + + # Get all occupied channel numbers (excluding the channel being moved) + occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True)) + + # Find the first available number at or after the desired position + new_channel_number = desired_position + while new_channel_number in occupied: + new_channel_number += 1 + + except Channel.DoesNotExist: + return Response( + {"error": "Target channel not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Update the dragged channel's number + channel.channel_number = new_channel_number + channel.save(update_fields=['channel_number']) + + return Response( + { + "message": f"Channel {channel.name} moved to position {new_channel_number}", + "channel": self.get_serializer(channel).data, + }, + status=status.HTTP_200_OK, + ) + @swagger_auto_schema( method="post", operation_description="Associate multiple channels with EPG data without triggering a full refresh", diff --git a/frontend/src/api.js b/frontend/src/api.js index 3df0b475..a831ad22 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -568,6 +568,24 @@ export default class API { } } + static async reorderChannel(channelId, insertAfterId) { + try { + const response = await request( + `${host}/api/channels/channels/${channelId}/reorder/`, + { + method: 'POST', + body: { + insert_after_id: insertAfterId, + }, + } + ); + + return response; + } catch (e) { + errorNotification('Failed to reorder channel', e); + } + } + static async setChannelEPG(channelId, epgDataId) { try { const response = await request( diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index bcacfe98..9106fdcc 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -5,6 +5,17 @@ import React, { useCallback, useRef, } from 'react'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import useChannelsStore from '../../store/channels'; import { notifications } from '@mantine/notifications'; import API from '../../api'; @@ -61,9 +72,18 @@ import ChannelTableStreams from './ChannelTableStreams'; import LazyLogo from '../LazyLogo'; import useLocalStorage from '../../hooks/useLocalStorage'; import useEPGsStore from '../../store/epgs'; +import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; import { CustomTable, useTable } from './CustomTable'; import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding'; import ChannelTableHeader from './ChannelsTable/ChannelTableHeader'; +import { + EditableTextCell, + EditableNumberCell, + EditableGroupCell, + EditableEPGCell, + EditableLogoCell, +} from './ChannelsTable/EditableCell'; +import { DraggableRow } from './ChannelsTable/DraggableRow'; import useWarningsStore from '../../store/warnings'; import ConfirmationDialog from '../ConfirmationDialog'; import useAuthStore from '../../store/auth'; @@ -231,6 +251,10 @@ const ChannelsTable = ({ onReady }) => { const tvgsById = useEPGsStore((s) => s.tvgsById); const epgs = useEPGsStore((s) => s.epgs); const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded); + + // Get channel logos for logo selection + const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection(); + const theme = useMantineTheme(); const channelGroups = useChannelsStore((s) => s.channelGroups); const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); @@ -258,6 +282,7 @@ const ChannelsTable = ({ onReady }) => { const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams); const allRowIds = useChannelsTableStore((s) => s.allQueryIds); const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds); + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); // store/channels const channels = useChannelsStore((s) => s.channels); @@ -320,6 +345,15 @@ const ChannelsTable = ({ onReady }) => { const hasFetchedData = useRef(false); + // Drag-and-drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // Require 8px movement before dragging starts + }, + }) + ); + // Column sizing state for resizable columns // Store in localStorage but with empty object as default const [columnSizing, setColumnSizing] = useLocalStorage( @@ -746,6 +780,47 @@ const ChannelsTable = ({ onReady }) => { } }; + const handleDragEnd = async (event) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + const activeIndex = rows.findIndex((row) => row.id === active.id); + const overIndex = rows.findIndex((row) => row.id === over.id); + + if (activeIndex === -1 || overIndex === -1) { + return; + } + + const activeChannel = rows[activeIndex].original; + const overChannel = rows[overIndex].original; + + try { + // Optimistically update the local state + const reorderedData = [...data]; + const [movedItem] = reorderedData.splice(activeIndex, 1); + reorderedData.splice(overIndex, 0, movedItem); + useChannelsTableStore.setState({ channels: reorderedData }); + + // Call backend to reorder + await API.reorderChannel( + activeChannel.id, + overIndex > activeIndex + ? overChannel.id + : rows[overIndex - 1]?.original.id || null + ); + + // Refetch to get updated channel numbers + await API.requeryChannels(); + } catch (error) { + // Revert on error + console.error('Failed to reorder channel:', error); + await API.requeryChannels(); + } + }; + /** * useEffect */ @@ -817,22 +892,7 @@ const ChannelsTable = ({ onReady }) => { size: columnSizing.channel_number || 40, minSize: 30, maxSize: 100, - cell: ({ getValue }) => { - const value = getValue(); - // Format as integer if no decimal component - const formattedValue = - value !== null && value !== undefined - ? value === Math.floor(value) - ? Math.floor(value) - : value - : ''; - - return ( - - {formattedValue} - - ); - }, + cell: (props) => , }, { id: 'name', @@ -840,73 +900,20 @@ const ChannelsTable = ({ onReady }) => { size: columnSizing.name || 200, minSize: 100, grow: true, - cell: ({ getValue }) => ( - - {getValue()} - - ), + cell: (props) => , }, { id: 'epg', header: 'EPG', accessorKey: 'epg_data_id', - cell: ({ getValue }) => { - const epgDataId = getValue(); - const epgObj = epgDataId ? tvgsById[epgDataId] : null; - const tvgName = epgObj?.name; - const tvgId = epgObj?.tvg_id; - const epgName = - epgObj && epgObj.epg_source - ? epgs[epgObj.epg_source]?.name || epgObj.epg_source - : null; - - const tooltip = epgObj - ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim() - : ''; - - // If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading - const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded; - - return ( - - {epgObj && epgName ? ( - {tooltip} - } - withArrow - position="top" - > - - {epgObj.epg_source} - {tvgId} - - - ) : epgObj ? ( - {epgObj.name} - ) : isEpgDataPending ? ( - - ) : ( - Not Assigned - )} - - ); - }, + cell: (props) => ( + + ), size: columnSizing.epg || 200, minSize: 80, }, @@ -916,16 +923,8 @@ const ChannelsTable = ({ onReady }) => { channelGroups[row.channel_group_id] ? channelGroups[row.channel_group_id].name : '', - cell: ({ getValue }) => ( - - {getValue()} - + cell: (props) => ( + ), size: columnSizing.channel_group || 175, minSize: 100, @@ -941,19 +940,21 @@ const ChannelsTable = ({ onReady }) => { maxSize: 120, enableResizing: false, header: '', - cell: ({ getValue }) => { - const logoId = getValue(); - - return ( -
- -
- ); - }, + cell: (props) => ( + { + // Ensure logos are loaded when user tries to edit + ensureLogosLoaded(); + }} + style={{ width: '100%', height: '100%' }} + > + + + ), }, { id: 'actions', @@ -1099,6 +1100,7 @@ const ChannelsTable = ({ onReady }) => { manualSorting: true, manualFiltering: true, enableRowSelection: true, + enableDragDrop: true, onRowSelectionChange: onRowSelectionChange, state: { pagination, @@ -1463,7 +1465,18 @@ const ChannelsTable = ({ onReady }) => { borderRadius: 'var(--mantine-radius-default)', }} > - + + row.id)} + strategy={verticalListSortingStrategy} + > + + +
s.user); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const setIsUnlocked = useChannelsTableStore((s) => s.setIsUnlocked); const headerPinned = table?.headerPinned ?? false; const setHeaderPinned = table?.setHeaderPinned || (() => {}); @@ -239,6 +244,10 @@ const ChannelTableHeader = ({ setHeaderPinned(!headerPinned); }; + const toggleUnlock = () => { + setIsUnlocked(!isUnlocked); + }; + return ( @@ -258,6 +267,23 @@ const ChannelTableHeader = ({ + + {isUnlocked && ( + + + Editing Mode + + )} + : + } + onClick={toggleUnlock} + disabled={authUser.user_level != USER_LEVELS.ADMIN} + > + + {isUnlocked ? 'Lock Table' : 'Unlock for Editing'} + + + { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: row.id, + disabled: !isUnlocked, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + position: 'relative', + }; + + return ( +
+ {isUnlocked && ( + + + + )} +
+ {children} +
+
+ ); +}; diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx new file mode 100644 index 00000000..065e4631 --- /dev/null +++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx @@ -0,0 +1,781 @@ +import React, { + useState, + useCallback, + useEffect, + useRef, + useMemo, +} from 'react'; +import { + Box, + TextInput, + Select, + NumberInput, + Tooltip, + Center, + Skeleton, +} from '@mantine/core'; +import API from '../../../api'; +import useChannelsTableStore from '../../../store/channelsTable'; + +// Editable text cell +export const EditableTextCell = ({ row, column, getValue }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const initialValue = getValue() || ''; + const [value, setValue] = useState(initialValue); + const [isFocused, setIsFocused] = useState(false); + const previousValue = useRef(initialValue); + const isMounted = useRef(false); + const debounceTimer = useRef(null); + + useEffect(() => { + const currentValue = getValue() || ''; + if (!isFocused && currentValue !== previousValue.current) { + setValue(currentValue); + previousValue.current = currentValue; + } + }, [getValue, isFocused]); + + const saveValue = useCallback( + async (newValue) => { + // Don't save if not mounted, not unlocked, or value hasn't changed + if ( + !isMounted.current || + !isUnlocked || + newValue === previousValue.current + ) { + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + [column.id]: newValue || null, + }); + previousValue.current = newValue; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + } + } catch (error) { + // Revert on error + setValue(previousValue.current || ''); + } + }, + [row.original.id, column.id, isUnlocked] + ); + + useEffect(() => { + isMounted.current = true; + const timer = debounceTimer.current; + return () => { + isMounted.current = false; + if (timer) { + clearTimeout(timer); + } + }; + }, []); + + const handleChange = (e) => { + if (!isUnlocked) return; + const newValue = e.currentTarget.value; + setValue(newValue); + + // Clear existing timer + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // Set new timer + debounceTimer.current = setTimeout(() => { + saveValue(newValue); + }, 500); + }; + + const handleBlur = () => { + setIsFocused(false); + if (isUnlocked) { + saveValue(value); + } + }; + + const handleClick = () => { + if (isUnlocked) { + setIsFocused(true); + } + }; + + if (!isUnlocked || !isFocused) { + return ( + + {value} + + ); + } + + return ( + + ); +}; + +// Editable number cell +export const EditableNumberCell = ({ row, column, getValue }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const initialValue = getValue(); + const [value, setValue] = useState(initialValue); + const [isFocused, setIsFocused] = useState(false); + const previousValue = useRef(initialValue); + const isMounted = useRef(false); + + useEffect(() => { + const currentValue = getValue(); + if (!isFocused && currentValue !== previousValue.current) { + setValue(currentValue); + previousValue.current = currentValue; + } + }, [getValue, isFocused]); + + const saveValue = useCallback( + async (newValue) => { + // Don't save if not mounted, not unlocked, or value hasn't changed + if ( + !isMounted.current || + !isUnlocked || + newValue === previousValue.current + ) { + return; + } + + // For channel_number, don't save null/undefined values + if ( + column.id === 'channel_number' && + (newValue === null || newValue === undefined || newValue === '') + ) { + // Revert to previous value + setValue(previousValue.current); + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + [column.id]: newValue, + }); + previousValue.current = newValue; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + + // If channel_number was changed, refetch to reorder the table + if (column.id === 'channel_number') { + await API.requeryChannels(); + // Exit edit mode after resorting to avoid confusion + setIsFocused(false); + } + } + } catch (error) { + // Revert on error + setValue(previousValue.current); + } + }, + [row.original.id, column.id, isUnlocked] + ); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const handleChange = (newValue) => { + if (!isUnlocked) return; + setValue(newValue); + }; + + const handleBlur = () => { + setIsFocused(false); + if (isUnlocked) { + saveValue(value); + } + }; + + const handleClick = () => { + if (isUnlocked) { + setIsFocused(true); + } + }; + + const formattedValue = + value !== null && value !== undefined + ? value === Math.floor(value) + ? Math.floor(value) + : value + : ''; + + if (!isUnlocked || !isFocused) { + return ( + + {formattedValue} + + ); + } + + return ( + + ); +}; + +// Editable select cell for groups +export const EditableGroupCell = ({ row, getValue, channelGroups }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const groupId = row.original.channel_group_id; + const groupName = channelGroups[groupId]?.name || ''; + const previousGroupId = useRef(groupId); + const [isFocused, setIsFocused] = useState(false); + const [searchValue, setSearchValue] = useState(''); + + const saveValue = useCallback( + async (newGroupId) => { + // Don't save if not unlocked or value hasn't changed + if ( + !isUnlocked || + String(newGroupId) === String(previousGroupId.current) + ) { + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + channel_group_id: parseInt(newGroupId, 10), + }); + previousGroupId.current = newGroupId; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + } + } catch (error) { + console.error('Failed to update channel group:', error); + } + }, + [row.original.id, isUnlocked] + ); + + const handleClick = () => { + if (isUnlocked) { + setIsFocused(true); + } + }; + + const handleChange = (newGroupId) => { + saveValue(newGroupId); + setIsFocused(false); + setSearchValue(''); + }; + + const groupOptions = Object.values(channelGroups).map((group) => ({ + value: String(group.id), + label: group.name, + })); + + if (!isUnlocked || !isFocused) { + return ( + + {groupName} + + ); + } + + return ( + setIsFocused(false)} + data={epgOptions} + size="xs" + variant="unstyled" + searchable + searchValue={searchValue} + onSearchChange={setSearchValue} + autoFocus + placeholder={displayText} + nothingFoundMessage="No EPG found" + styles={{ + input: { + minHeight: 'unset', + height: '100%', + padding: '0 4px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }} + /> + ); +}; + +// Editable cell for Logo selection +export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const logoId = getValue(); + const previousLogoId = useRef(logoId); + const [isFocused, setIsFocused] = useState(false); + const [searchValue, setSearchValue] = useState(''); + + const saveValue = useCallback( + async (newLogoId) => { + // Don't save if not unlocked or value hasn't changed + if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) { + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + logo_id: newLogoId === 'null' ? null : parseInt(newLogoId, 10), + }); + previousLogoId.current = newLogoId; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + } + } catch (error) { + console.error('Failed to update logo:', error); + } + }, + [row.original.id, isUnlocked] + ); + + const handleClick = () => { + if (isUnlocked) { + setSearchValue(''); + setIsFocused(true); + } + }; + + const handleChange = (newLogoId) => { + saveValue(newLogoId); + setSearchValue(''); + setIsFocused(false); + }; + + // Build logo options with logo data + const logoOptions = useMemo(() => { + const options = [ + { + value: 'null', + label: 'Default', + logo: null, + }, + ]; + + // Convert channelLogos object to array and sort by name + const logosArray = Object.values(channelLogos); + logosArray.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + + logosArray.forEach((logo) => { + options.push({ + value: String(logo.id), + label: logo.name || `Logo ${logo.id}`, + logo: logo, + }); + }); + + return options; + }, [channelLogos]); + + // Get display text for the current logo + const displayText = + logoId && channelLogos[logoId] ? channelLogos[logoId].name : 'Default'; + + // Custom option renderer to show logo images + const renderOption = ({ option }) => { + if (option.value === 'null') { + return
Default
; + } + + return ( + + {option.label} { + e.target.style.display = 'none'; + }} + /> + {option.label} + + ); + }; + + if (!isUnlocked || !isFocused) { + // When not editing, show the logo image + return ( + + {LazyLogo && ( + + )} + + ); + } + + return ( + + setIsFocused(false)} + onBlur={onBlur} data={groupOptions} size="xs" variant="unstyled" @@ -401,12 +429,10 @@ export const EditableEPGCell = ({ tvgsLoaded, }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); - const epgDataId = getValue(); - const previousEpgDataId = useRef(epgDataId); const [isFocused, setIsFocused] = useState(false); - const [searchValue, setSearchValue] = useState(''); + const epgDataId = getValue(); - // Format display text + // Format display text - needed for both locked and unlocked states const epgObj = epgDataId ? tvgsById[epgDataId] : null; const tvgId = epgObj?.tvg_id; const epgName = @@ -423,13 +449,90 @@ export const EditableEPGCell = ({ // Show skeleton while EPG data is loading (only if channel has an EPG assignment) const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded; + // Build tooltip content + const tooltip = epgObj + ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim() + : ''; + + // Show simple display when locked OR when unlocked but not focused + if (!isUnlocked || !isFocused) { + // If loading EPG data, show skeleton + if (isEpgDataPending) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + padding: '0 4px', + cursor: isUnlocked ? 'pointer' : 'default', + }} + > + + + ); + } + return ( + {tooltip}} + withArrow + position="top" + disabled={!epgObj} + openDelay={500} + > + isUnlocked && setIsFocused(true)} + style={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + padding: '0 4px', + cursor: isUnlocked ? 'pointer' : 'default', + }} + > + {displayText} + + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableEPGCellInner = ({ + row, + tvgsById, + epgs, + epgDataId, + displayText, + onBlur, +}) => { + const previousEpgDataId = useRef(epgDataId); + const [searchValue, setSearchValue] = useState(''); + const saveValue = useCallback( async (newEpgDataId) => { - // Don't save if not unlocked or value hasn't changed - if ( - !isUnlocked || - String(newEpgDataId) === String(previousEpgDataId.current) - ) { + // Don't save if value hasn't changed + if (String(newEpgDataId) === String(previousEpgDataId.current)) { return; } @@ -449,20 +552,13 @@ export const EditableEPGCell = ({ console.error('Failed to update EPG:', error); } }, - [row.original.id, isUnlocked] + [row.original.id] ); - const handleClick = () => { - if (isUnlocked) { - setSearchValue(''); // Start with empty search - setIsFocused(true); - } - }; - const handleChange = (newEpgDataId) => { saveValue(newEpgDataId); setSearchValue(''); - setIsFocused(false); + onBlur(); }; // Build EPG options @@ -514,69 +610,11 @@ export const EditableEPGCell = ({ return options; }, [tvgsById, epgs]); - // Build tooltip content - const tooltip = epgObj - ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim() - : ''; - - if (!isUnlocked || !isFocused) { - // If loading EPG data, show skeleton - if (isEpgDataPending) { - return ( - - - - ); - } - // Otherwise, show the normal EPG assignment cell - return ( - {tooltip}} - withArrow - position="top" - disabled={!epgObj} - openDelay={500} - > - - {displayText} - - - ); - } - return ( setIsFocused(false)} + onBlur={onBlur} data={logoOptions} size="xs" variant="unstyled" diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index efc531ab..2a002d52 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -56,9 +56,10 @@ const useAuthStore = create((set, get) => ({ // Ensure settings are loaded first await useSettingsStore.getState().fetchSettings(); - // Only after settings are loaded, fetch the essential data + // Fetch essential data needed for initial render + // Note: fetchChannels() is intentionally NOT awaited here - it's slow (~3s) + // and only needed for delete modal details. It loads in background after UI renders. await Promise.all([ - useChannelsStore.getState().fetchChannels(), useChannelsStore.getState().fetchChannelGroups(), useChannelsStore.getState().fetchChannelProfiles(), usePlaylistsStore.getState().fetchPlaylists(), @@ -72,7 +73,7 @@ const useAuthStore = create((set, get) => ({ await Promise.all([useUsersStore.getState().fetchUsers()]); } - // Only set isAuthenticated and isInitialized AFTER all data is loaded + // Only set isAuthenticated and isInitialized AFTER essential data is loaded // This prevents routes from rendering before data is ready set({ isAuthenticated: true, @@ -80,6 +81,9 @@ const useAuthStore = create((set, get) => ({ isInitializing: false, }); + // Load channels data in background (not blocking) - needed for delete modal details + useChannelsStore.getState().fetchChannels(); + // Note: Logos are loaded after the Channels page tables finish loading // This is handled by the tables themselves signaling completion } catch (error) { From 76c895f6138734f3a4bb84683314b628eca4444c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 26 Jan 2026 17:37:50 -0600 Subject: [PATCH 55/75] Security: Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details. --- CHANGELOG.md | 1 + frontend/package-lock.json | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd90457..629ce88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)) - **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7)) - Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router) +- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details. ### Added diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 476b764b..0a349a3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3720,9 +3720,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.clamp": { From 497bb819b97368ec46dd139531a9d02767903b4e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 09:50:27 -0600 Subject: [PATCH 56/75] Docs: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints --- CHANGELOG.md | 1 + apps/channels/api_views.py | 118 +++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 629ce88c..cd1786ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability - Editable Channel Table Mode: - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. - EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5d5dc4b6..40659828 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2406,9 +2406,61 @@ class SeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="List all series rules", + operation_description="Retrieve all configured DVR series recording rules.", + responses={ + 200: openapi.Response( + description="List of series rules", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'rules': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), + 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + }, + ), + description='List of series recording rules' + ), + }, + ), + ), + }, + ) def get(self, request): return Response({"rules": CoreSettings.get_dvr_series_rules()}) + @swagger_auto_schema( + operation_summary="Create or update a series rule", + operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['tvg_id'], + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), + 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + }, + ), + responses={ + 200: openapi.Response( + description="Series rule created/updated successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'), + }, + ), + ), + 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"), + }, + ) def post(self, request): data = request.data or {} tvg_id = str(data.get("tvg_id") or "").strip() @@ -2441,6 +2493,25 @@ class DeleteSeriesRuleAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="Delete a series rule", + operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + manual_parameters=[ + openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'), + ], + responses={ + 200: openapi.Response( + description="Series rule deleted successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'), + }, + ), + ), + }, + ) def delete(self, request, tvg_id): tvg_id = unquote(str(tvg_id or "")) rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id] @@ -2455,6 +2526,27 @@ class EvaluateSeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="Evaluate series rules", + operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'), + }, + ), + responses={ + 200: openapi.Response( + description="Evaluation completed successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + }, + ), + ), + }, + ) def post(self, request): tvg_id = request.data.get("tvg_id") # Run synchronously so UI sees results immediately @@ -2476,6 +2568,32 @@ class BulkRemoveSeriesRecordingsAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="Bulk remove scheduled recordings for a series", + operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['tvg_id'], + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'), + 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'), + }, + ), + responses={ + 200: openapi.Response( + description="Recordings removed successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'), + }, + ), + ), + 400: openapi.Response(description="Bad request (missing tvg_id)"), + }, + ) def post(self, request): from django.utils import timezone tvg_id = str(request.data.get("tvg_id") or "").strip() From 9026ece87a5b2d1ac6e936b31fefdb6bf520885c Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 27 Jan 2026 16:38:36 +0000 Subject: [PATCH 57/75] Release v0.18.0 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 629ce88c..94a0f3d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.0] - 2026-01-27 + ### Security - Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: diff --git a/version.py b/version.py index 1aae4039..371a0c5d 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From ed4fabd7735235f8ee83474cec1734e254722e8e Mon Sep 17 00:00:00 2001 From: SergeantPanda <61642231+SergeantPanda@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:41:28 -0600 Subject: [PATCH 58/75] Downgrade version from 0.18.0 to 0.17.0 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 371a0c5d..1aae4039 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From b5fedc1a6c39c6abe65b532c68581281acc9f522 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 27 Jan 2026 16:44:10 +0000 Subject: [PATCH 59/75] Release v0.18.0 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c655ea0e..e455048f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.18.0] - 2026-01-27 +## [0.18.0] - 2026-01-27 + ### Security - Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: diff --git a/version.py b/version.py index 1aae4039..371a0c5d 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From 2abe07fe1c4bcd001b933e1ee20ddf9082283d70 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 10:56:04 -0600 Subject: [PATCH 60/75] Update Series Rules API Swagger Documentation to include required items parameter for TYPE_ARRAY schemas --- CHANGELOG.md | 4 +++- apps/channels/api_views.py | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e455048f..975d3120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.18.0] - 2026-01-27 +### Fixed + +- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure ## [0.18.0] - 2026-01-27 diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 40659828..11e7525f 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2454,7 +2454,18 @@ class SeriesRulesAPIView(APIView): type=openapi.TYPE_OBJECT, properties={ 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'), + 'rules': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), + 'mode': openapi.Schema(type=openapi.TYPE_STRING), + 'title': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + description='Updated list of all rules' + ), }, ), ), @@ -2506,7 +2517,18 @@ class DeleteSeriesRuleAPIView(APIView): type=openapi.TYPE_OBJECT, properties={ 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'), + 'rules': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), + 'mode': openapi.Schema(type=openapi.TYPE_STRING), + 'title': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + description='Updated list of all rules' + ), }, ), ), From 39ed48e0e55412933ea125be9953c8572dcd34b2 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 27 Jan 2026 16:57:57 +0000 Subject: [PATCH 61/75] Release v0.18.1 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e455048f..aa464cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.1] - 2026-01-27 + ## [0.18.0] - 2026-01-27 ## [0.18.0] - 2026-01-27 diff --git a/version.py b/version.py index 371a0c5d..f821e438 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From b55ed77516e86c054989c1bd1df64add629664c0 Mon Sep 17 00:00:00 2001 From: SergeantPanda <61642231+SergeantPanda@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:05:52 -0600 Subject: [PATCH 62/75] Downgrade version from 0.18.1 to 0.18.0 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index f821e438..371a0c5d 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From ddbda17ae4e6d5c118489d6dc6bfc818f9df6f86 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 27 Jan 2026 17:06:41 +0000 Subject: [PATCH 63/75] Release v0.18.1 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 975d3120..97952632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.1] - 2026-01-27 + ### Fixed - Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure diff --git a/version.py b/version.py index 371a0c5d..f821e438 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From 8616933ad7852082c6d779e65fd63ad289215e1b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 13:17:59 -0600 Subject: [PATCH 64/75] Replace drf-yasg pip with drf-spectactular --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3416804d..1f3c63aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ djangorestframework==3.16.1 requests==2.32.5 psutil==7.1.3 pillow -drf-yasg>=1.21.11 +drf-spectacular>=0.28.0 streamlink python-vlc yt-dlp From 53641237452b5cdcfed0ce8a9963356488d9f414 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 13:33:33 -0600 Subject: [PATCH 65/75] - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added - Modern OpenAPI 3.0 specification compliance - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection --- CHANGELOG.md | 8 + apps/accounts/api_views.py | 63 +++-- apps/api/urls.py | 26 +- apps/channels/api_views.py | 487 +++++++++++++------------------------ apps/epg/api_views.py | 39 ++- apps/hdhr/api_views.py | 24 +- apps/hdhr/views.py | 24 +- apps/m3u/api_views.py | 19 +- core/api_views.py | 27 +- dispatcharr/settings.py | 24 +- dispatcharr/urls.py | 28 +-- 11 files changed, 290 insertions(+), 479 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97952632..26c3abd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: + - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added + - Modern OpenAPI 3.0 specification compliance + - Better auto-generation of request/response schemas + - Improved documentation accuracy with serializer introspection + ## [0.18.1] - 2026-01-27 ### Fixed diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 41e2f077..607ce199 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response -from rest_framework import viewsets, status -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from rest_framework import viewsets, status, serializers +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes import json from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed @@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet): return [IsAuthenticated()] return [] - @swagger_auto_schema( - operation_description="Authenticate and log in a user", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["username", "password"], - properties={ - "username": openapi.Schema(type=openapi.TYPE_STRING), - "password": openapi.Schema( - type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD - ), + @extend_schema( + description="Authenticate and log in a user", + request=inline_serializer( + name="LoginRequest", + fields={ + "username": serializers.CharField(), + "password": serializers.CharField(), }, ), - responses={200: "Login successful", 400: "Invalid credentials"}, ) def login(self, request): """Logs in a user and returns user details""" @@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet): ) return Response({"error": "Invalid credentials"}, status=400) - @swagger_auto_schema( - operation_description="Log out the current user", - responses={200: "Logout successful"}, + @extend_schema( + description="Log out the current user", ) def logout(self, request): """Logs out the authenticated user""" @@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet): return [IsAdmin()] - @swagger_auto_schema( - operation_description="Retrieve a list of users", + @extend_schema( + description="Retrieve a list of users", responses={200: UserSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific user by ID") + @extend_schema(description="Retrieve a specific user by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new user") + @extend_schema(description="Create a new user") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a user") + @extend_schema(description="Update a user") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a user") + @extend_schema(description="Delete a user") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) - @swagger_auto_schema( - method="get", - operation_description="Get active user information", + @extend_schema( + description="Get active user information", ) @action(detail=False, methods=["get"], url_path="me") def me(self, request): @@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [Authenticated] - @swagger_auto_schema( - operation_description="Retrieve a list of groups", + @extend_schema( + description="Retrieve a list of groups", responses={200: GroupSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific group by ID") + @extend_schema(description="Retrieve a specific group by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new group") + @extend_schema(description="Create a new group") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a group") + @extend_schema(description="Update a group") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a group") + @extend_schema(description="Delete a group") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) # 🔹 4) Permissions List API -@swagger_auto_schema( - method="get", - operation_description="Retrieve a list of all permissions", +@extend_schema( + description="Retrieve a list of all permissions", responses={200: PermissionSerializer(many=True)}, ) @api_view(["GET"]) diff --git a/apps/api/urls.py b/apps/api/urls.py index 4c92c70a..5a688778 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,23 +1,8 @@ from django.urls import path, include, re_path -from drf_yasg.views import get_schema_view -from drf_yasg import openapi -from rest_framework.permissions import AllowAny +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView app_name = 'api' -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version='v1', - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="support@dispatcharr.local"), - license=openapi.License(name="Unlicense"), - ), - public=True, - permission_classes=(AllowAny,), -) - urlpatterns = [ path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')), path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')), @@ -35,8 +20,9 @@ urlpatterns = [ - # Swagger Documentation api_urls - re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), - path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'), + # OpenAPI Schema and Documentation (drf-spectacular) + path('schema/', SpectacularAPIView.as_view(), name='schema'), + re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), + path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), ] diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 11e7525f..27e07dac 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -4,8 +4,9 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction from django.db.models import Count, F @@ -269,17 +270,15 @@ class StreamViewSet(viewsets.ModelViewSet): ] }) - @swagger_auto_schema( - method="post", - operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["ids"], - properties={ - "ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to retrieve" + @extend_schema( + methods=["POST"], + description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", + request=inline_serializer( + name="StreamByIdsRequest", + fields={ + "ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to retrieve" ), }, ), @@ -342,10 +341,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): return super().partial_update(request, *args, **kwargs) - @swagger_auto_schema( - method="post", - operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", - responses={200: "Cleanup completed"}, + @extend_schema( + methods=["POST"], + description="Delete all channel groups that have no associations (no channels or M3U accounts)", ) @action(detail=False, methods=["post"], url_path="cleanup") def cleanup_unused_groups(self, request): @@ -831,25 +829,22 @@ class ChannelViewSet(viewsets.ModelViewSet): # Return the response with the list of IDs return Response(list(channel_ids)) - @swagger_auto_schema( - method="post", - operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "starting_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="Starting channel number to assign (can be decimal)", + @extend_schema( + methods=["POST"], + description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", + request=inline_serializer( + name="AssignChannelsRequest", + fields={ + "starting_number": serializers.FloatField( + help_text="Starting channel number to assign (can be decimal)", + required=False, ), - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to assign", + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to assign", ), }, ), - responses={200: "Channels have been auto-assigned!"}, ) @action(detail=False, methods=["post"], url_path="assign") def assign(self, request): @@ -869,33 +864,28 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK ) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Create a new channel from an existing stream. " "If 'channel_number' is provided, it will be used (if available); " "otherwise, the next available channel number is assigned. " "If 'channel_profile_ids' is provided, the channel will only be added to those profiles. " "Accepts either a single ID or an array of IDs." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_id"], - properties={ - "stream_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="ID of the stream to link" + request=inline_serializer( + name="FromStreamRequest", + fields={ + "stream_id": serializers.IntegerField(help_text="ID of the stream to link"), + "channel_number": serializers.FloatField( + help_text="(Optional) Desired channel number. Must not be in use.", + required=False, ), - "channel_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="(Optional) Desired channel number. Must not be in use.", - ), - "name": openapi.Schema( - type=openapi.TYPE_STRING, description="Desired channel name" - ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." + "name": serializers.CharField(help_text="Desired channel name", required=False), + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), }, ), @@ -1055,34 +1045,31 @@ class ChannelViewSet(viewsets.ModelViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Asynchronously bulk create channels from stream IDs. " "Returns a task ID to track progress via WebSocket. " "This is the recommended approach for large bulk operations." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to create channels from" + request=inline_serializer( + name="FromStreamBulkRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to create channels from" ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), - "starting_channel_number": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number" + "starting_channel_number": serializers.IntegerField( + help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number", + required=False, ), }, ), - responses={202: "Task started successfully"}, ) @action(detail=False, methods=["post"], url_path="from-stream/bulk") def from_stream_bulk(self, request): @@ -1122,20 +1109,19 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 6) EPG Fuzzy Matching # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.' + @extend_schema( + methods=["POST"], + description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", + request=inline_serializer( + name="MatchEpgRequest", + fields={ + 'channel_ids': serializers.ListField( + child=serializers.IntegerField(), + help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.', + required=False, ) } ), - responses={202: "EPG matching task initiated"}, ) @action(detail=False, methods=["post"], url_path="match-epg") def match_epg(self, request): @@ -1156,10 +1142,9 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": message}, status=status.HTTP_202_ACCEPTED ) - @swagger_auto_schema( - method="post", - operation_description="Try to auto-match this specific channel with EPG data.", - responses={200: "EPG matching completed", 202: "EPG matching task initiated"}, + @extend_schema( + methods=["POST"], + description="Try to auto-match this specific channel with EPG data.", ) @action(detail=True, methods=["post"], url_path="match-epg") def match_channel_epg(self, request, pk=None): @@ -1186,16 +1171,13 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 7) Set EPG and Refresh # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Set EPG data for a channel and refresh program data", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["epg_data_id"], - properties={ - "epg_data_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="EPG data ID to link" - ) + @extend_schema( + methods=["POST"], + description="Set EPG data for a channel and refresh program data", + request=inline_serializer( + name="SetEpgRequest", + fields={ + "epg_data_id": serializers.IntegerField(help_text="EPG data ID to link") }, ), responses={200: "EPG data linked and refresh triggered"}, @@ -1251,28 +1233,22 @@ class ChannelViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"error": str(e)}, status=400) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + description=( "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " "The channel will receive the next whole number after the target channel, and all subsequent " "channels will be renumbered accordingly." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "insert_after_id": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="ID of the channel to insert after. Use null to move to the beginning.", - nullable=True, + request=inline_serializer( + name="ReorderChannelRequest", + fields={ + "insert_after_id": serializers.IntegerField( + help_text="ID of the channel to insert after. Use null to move to the beginning.", + required=False, + allow_null=True, ), }, ), - responses={ - 200: "Channel reordered successfully", - 404: "Channel not found", - 400: "Invalid request", - }, ) @action(detail=True, methods=["post"], url_path="reorder") def reorder(self, request, pk=None): @@ -1340,25 +1316,23 @@ class ChannelViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) - @swagger_auto_schema( - method="post", - operation_description="Associate multiple channels with EPG data without triggering a full refresh", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "associations": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "channel_id": openapi.Schema(type=openapi.TYPE_INTEGER), - "epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER), + @extend_schema( + methods=["POST"], + description="Associate multiple channels with EPG data without triggering a full refresh", + request=inline_serializer( + name="BatchSetEpgRequest", + fields={ + "associations": serializers.ListField( + child=inline_serializer( + name="EpgAssociation", + fields={ + "channel_id": serializers.IntegerField(), + "epg_data_id": serializers.IntegerField(), }, ), ) }, ), - responses={200: "EPG data linked for multiple channels"}, ) @action(detail=False, methods=["post"], url_path="batch-set-epg") def batch_set_epg(self, request): @@ -1456,20 +1430,17 @@ class BulkDeleteStreamsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete streams by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Stream IDs to delete", + @extend_schema( + description="Bulk delete streams by ID", + request=inline_serializer( + name="BulkDeleteStreamsRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Stream IDs to delete", ) }, ), - responses={204: "Streams deleted"}, ) def delete(self, request, *args, **kwargs): stream_ids = request.data.get("stream_ids", []) @@ -1492,20 +1463,17 @@ class BulkDeleteChannelsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete channels by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to delete", + @extend_schema( + description="Bulk delete channels by ID", + request=inline_serializer( + name="BulkDeleteChannelsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to delete", ) }, ), - responses={204: "Channels deleted"}, ) def delete(self, request): channel_ids = request.data.get("channel_ids", []) @@ -1527,20 +1495,17 @@ class BulkDeleteLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete logos by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["logo_ids"], - properties={ - "logo_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Logo IDs to delete", + @extend_schema( + description="Bulk delete logos by ID", + request=inline_serializer( + name="BulkDeleteLogosRequest", + fields={ + "logo_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Logo IDs to delete", ) }, ), - responses={204: "Logos deleted"}, ) def delete(self, request): logo_ids = request.data.get("logo_ids", []) @@ -1597,19 +1562,18 @@ class CleanupUnusedLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Delete all channel logos that are not used by any channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "delete_files": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - description="Whether to delete local logo files from disk", - default=False + @extend_schema( + description="Delete all channel logos that are not used by any channels", + request=inline_serializer( + name="CleanupUnusedLogosRequest", + fields={ + "delete_files": serializers.BooleanField( + help_text="Whether to delete local logo files from disk", + default=False, + required=False, ) }, ), - responses={200: "Cleanup completed"}, ) def post(self, request): """Delete all channel logos with no channel associations""" @@ -2019,29 +1983,9 @@ class BulkUpdateChannelMembershipAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", - request_body=BulkChannelProfileMembershipSerializer, - responses={ - 200: openapi.Response( - description="Channels updated successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "status": openapi.Schema(type=openapi.TYPE_STRING, example="success"), - "updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"), - "created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"), - "invalid_channels": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description="List of channel IDs that don't exist" - ), - }, - ), - ), - 400: "Invalid request data", - 404: "Profile not found", - }, + @extend_schema( + description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", + request=BulkChannelProfileMembershipSerializer, ) def patch(self, request, profile_id): """Bulk enable or disable channels for a specific profile""" @@ -2406,71 +2350,24 @@ class SeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="List all series rules", - operation_description="Retrieve all configured DVR series recording rules.", - responses={ - 200: openapi.Response( - description="List of series rules", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), - 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), - }, - ), - description='List of series recording rules' - ), - }, - ), - ), - }, + @extend_schema( + summary="List all series rules", + description="Retrieve all configured DVR series recording rules.", ) def get(self, request): return Response({"rules": CoreSettings.get_dvr_series_rules()}) - @swagger_auto_schema( - operation_summary="Create or update a series rule", - operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=['tvg_id'], - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), - 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + @extend_schema( + summary="Create or update a series rule", + description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request=inline_serializer( + name="SeriesRuleRequest", + fields={ + 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), + 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'), + 'title': serializers.CharField(help_text='Series title', required=False), }, ), - responses={ - 200: openapi.Response( - description="Series rule created/updated successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), - 'mode': openapi.Schema(type=openapi.TYPE_STRING), - 'title': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - description='Updated list of all rules' - ), - }, - ), - ), - 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"), - }, ) def post(self, request): data = request.data or {} @@ -2504,35 +2401,12 @@ class DeleteSeriesRuleAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Delete a series rule", - operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", - manual_parameters=[ - openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'), + @extend_schema( + summary="Delete a series rule", + description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + parameters=[ + OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'), ], - responses={ - 200: openapi.Response( - description="Series rule deleted successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), - 'mode': openapi.Schema(type=openapi.TYPE_STRING), - 'title': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - description='Updated list of all rules' - ), - }, - ), - ), - }, ) def delete(self, request, tvg_id): tvg_id = unquote(str(tvg_id or "")) @@ -2548,26 +2422,15 @@ class EvaluateSeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Evaluate series rules", - operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'), + @extend_schema( + summary="Evaluate series rules", + description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request=inline_serializer( + name="EvaluateSeriesRulesRequest", + fields={ + "tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."), }, ), - responses={ - 200: openapi.Response( - description="Evaluation completed successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - }, - ), - ), - }, ) def post(self, request): tvg_id = request.data.get("tvg_id") @@ -2590,31 +2453,17 @@ class BulkRemoveSeriesRecordingsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Bulk remove scheduled recordings for a series", - operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=['tvg_id'], - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'), - 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'), + @extend_schema( + summary="Bulk remove scheduled recordings for a series", + description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request=inline_serializer( + name="BulkRemoveSeriesRecordingsRequest", + fields={ + "tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"), + "title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"), + "scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"), }, ), - responses={ - 200: openapi.Response( - description="Recordings removed successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'), - }, - ), - ), - 400: openapi.Response(description="Bad request (missing tvg_id)"), - }, ) def post(self, request): from django.utils import timezone diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 319368d0..00f7403f 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,10 +1,10 @@ import logging, os -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.decorators import action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes from django.utils import timezone from datetime import timedelta from .models import EPGSource, ProgramData, EPGData # Added ProgramData @@ -122,8 +122,8 @@ class EPGGridAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", + @extend_schema( + description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", responses={200: ProgramDataSerializer(many=True)}, ) def get(self, request, format=None): @@ -371,9 +371,8 @@ class EPGImportAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers an EPG data import", - responses={202: "EPG data import initiated"}, + @extend_schema( + description="Triggers an EPG data import", ) def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") @@ -435,20 +434,20 @@ class CurrentProgramsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Get currently playing programs for specified channels or all channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description="Array of channel IDs. If null or omitted, returns all channels with current programs.", - nullable=True, - ) + @extend_schema( + description="Get currently playing programs for specified channels or all channels", + request=inline_serializer( + name="CurrentProgramsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + ), }, ), - responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))}, + responses={200: ProgramDataSerializer(many=True)}, ) def post(self, request, format=None): # Get channel IDs from request body diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 8f1609d4..2227170e 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -4,8 +4,8 @@ from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse import logging -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.db import models from apps.channels.models import Channel, ChannelProfile, Stream @@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request, profile=None): uri_parts = ["hdhr"] @@ -100,9 +99,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request, profile=None): if profile is not None: @@ -141,9 +139,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request, profile=None): data = { @@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 40823259..f9dd42d2 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -3,8 +3,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from apps.channels.models import Channel from .models import HDHRDevice @@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") @@ -81,9 +80,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request): channels = Channel.objects.all().order_by("channel_number") @@ -102,9 +100,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request): data = { @@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 1f16f20f..73331f7a 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -6,8 +6,8 @@ from apps.accounts.permissions import ( permission_classes_by_action, permission_classes_by_method, ) -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache @@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of all active M3U accounts", - responses={202: "M3U refresh initiated"}, + @extend_schema( + description="Triggers a refresh of all active M3U accounts", ) def post(self, request, format=None): refresh_m3u_accounts.delay() @@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of a single M3U account", - responses={202: "M3U account refresh initiated"}, + @extend_schema( + description="Triggers a refresh of a single M3U account", ) def post(self, request, account_id, format=None): refresh_single_m3u_account.delay(account_id) @@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of account information for a specific M3U profile", - responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"}, + @extend_schema( + description="Triggers a refresh of account information for a specific M3U profile", ) def post(self, request, profile_id, format=None): try: diff --git a/core/api_views.py b/core/api_views.py index 30829174..44001f95 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -9,8 +9,8 @@ from rest_framework.views import APIView from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import api_view, permission_classes, action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from .models import ( UserAgent, StreamProfile, @@ -241,10 +241,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): -@swagger_auto_schema( - method="get", - operation_description="Endpoint for environment details", - responses={200: "Environment variables"}, +@extend_schema( + description="Endpoint for environment details", ) @api_view(["GET"]) @permission_classes([Authenticated]) @@ -314,10 +312,8 @@ def environment(request): ) -@swagger_auto_schema( - method="get", - operation_description="Get application version information", - responses={200: "Version information"}, +@extend_schema( + description="Get application version information", ) @api_view(["GET"]) @@ -333,10 +329,8 @@ def version(request): ) -@swagger_auto_schema( - method="post", - operation_description="Trigger rehashing of all streams", - responses={200: "Rehash task started"}, +@extend_schema( + description="Trigger rehashing of all streams", ) @api_view(["POST"]) @permission_classes([Authenticated]) @@ -383,9 +377,8 @@ class TimezoneListView(APIView): def get_permissions(self): return [Authenticated()] - @swagger_auto_schema( - operation_description="Get list of all supported timezones", - responses={200: openapi.Response('List of timezones with grouping by region')} + @extend_schema( + description="Get list of all supported timezones", ) def get(self, request): import pytz diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 1a9a1a44..b65ee2d0 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -32,7 +32,7 @@ INSTALLED_APPS = [ "apps.vod.apps.VODConfig", "core", "daphne", - "drf_yasg", + "drf_spectacular", "channels", "django.contrib.admin", "django.contrib.auth", @@ -151,7 +151,7 @@ AUTH_PASSWORD_VALIDATORS = [ ] REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", "rest_framework.renderers.BrowsableAPIRenderer", @@ -162,10 +162,22 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } -SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": { - "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} - } +SPECTACULAR_SETTINGS = { + "TITLE": "Dispatcharr API", + "DESCRIPTION": "API documentation for Dispatcharr", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SECURITY": [{"BearerAuth": []}], + "COMPONENTS": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.", + } + } + }, } LANGUAGE_CODE = "en-us" diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 890d0c2d..1ab4ebfb 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -3,32 +3,20 @@ from django.urls import path, include, re_path from django.conf import settings from django.conf.urls.static import static from django.views.generic import TemplateView, RedirectView -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.ts_proxy.views import stream_xc from apps.output.views import xc_movie_stream, xc_series_stream -# Define schema_view for Swagger -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version="v1", - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@dispatcharr.local"), - license=openapi.License(name="Creative Commons by-nc-sa"), - ), - public=True, - permission_classes=(permissions.AllowAny,), -) - urlpatterns = [ # API Routes path("api/", include(("apps.api.urls", "api"), namespace="api")), path("api", RedirectView.as_view(url="/api/", permanent=True)), + # Swagger redirects (Swagger UI is served at /api/swagger/) + path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), + path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), # Admin path("admin", RedirectView.as_view(url="/admin/", permanent=True)), path("admin/", admin.site.urls), @@ -68,12 +56,6 @@ urlpatterns = [ name="xc_series_stream", ), - re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), - # ReDoc UI - path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), - # Optionally, serve the raw Swagger JSON - path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), - # VOD proxy is now handled by the main proxy URLs above # Catch-all routes should always be last path("", TemplateView.as_view(template_name="index.html")), # React entry point From bd1e0e8f079c4e1a7a004b448e1c80fc1db20a56 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 14:36:57 -0600 Subject: [PATCH 66/75] Bug fix: Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). --- CHANGELOG.md | 4 ++++ dispatcharr/urls.py | 6 +++--- docker/nginx.conf | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c3abd4..960302d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection +### Fixed + +- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). + ## [0.18.1] - 2026-01-27 ### Fixed diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 1ab4ebfb..092fb0cb 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -17,9 +17,6 @@ urlpatterns = [ path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), - # Admin - path("admin", RedirectView.as_view(url="/admin/", permanent=True)), - path("admin/", admin.site.urls), # Outputs path("output", RedirectView.as_view(url="/output/", permanent=True)), path("output/", include(("apps.output.urls", "output"), namespace="output")), @@ -55,6 +52,9 @@ urlpatterns = [ xc_series_stream, name="xc_series_stream", ), + # Admin + path("admin", RedirectView.as_view(url="/admin/", permanent=True)), + path("admin/", admin.site.urls), # VOD proxy is now handled by the main proxy URLs above # Catch-all routes should always be last diff --git a/docker/nginx.conf b/docker/nginx.conf index 406d587c..e08d08f2 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -59,7 +59,7 @@ server { } # admin disabled when not in dev mode - location /admin { + location ~ ^/admin/?$ { return 301 /login; } From 5cb0b7dd3b3e07fbe9e82dcd3a57e087889a7429 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 14:52:12 -0600 Subject: [PATCH 67/75] Add pytz to requirements. Was included as a dependency of drf-yasg before. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1f3c63aa..6858baec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ djangorestframework==3.16.1 requests==2.32.5 psutil==7.1.3 pillow -drf-spectacular>=0.28.0 +drf-spectacular>=0.29.0 streamlink python-vlc yt-dlp @@ -18,6 +18,7 @@ m3u8 rapidfuzz==3.14.3 regex # Required by transformers but also used for advanced regex features tzlocal +pytz # PyTorch dependencies (CPU only) --extra-index-url https://download.pytorch.org/whl/cpu/ From bd4c9e1d9505bf817daa66a50edeb5ec6b93552b Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:39:04 -0800 Subject: [PATCH 68/75] Added unit tests --- .../hooks/__tests__/useLocalStorage.test.jsx | 110 +++ .../hooks/__tests__/useSmartLogos.test.jsx | 297 ++++++ frontend/src/store/__tests__/auth.test.jsx | 479 +++++++++ .../src/store/__tests__/channels.test.jsx | 442 +++++++++ .../store/__tests__/channelsTable.test.jsx | 295 ++++++ frontend/src/store/__tests__/epgs.test.jsx | 687 +++++++++++++ frontend/src/store/__tests__/logos.test.jsx | 916 ++++++++++++++++++ .../src/store/__tests__/playlists.test.jsx | 266 +++++ frontend/src/store/__tests__/plugins.test.jsx | 234 +++++ .../src/store/__tests__/settings.test.jsx | 204 ++++ .../store/__tests__/streamProfiles.test.jsx | 265 +++++ frontend/src/store/__tests__/streams.test.jsx | 289 ++++++ .../src/store/__tests__/useVODStore.test.jsx | 755 +++++++++++++++ .../store/__tests__/useVideoStore.test.jsx | 182 ++++ .../src/store/__tests__/userAgents.test.jsx | 277 ++++++ frontend/src/store/__tests__/users.test.jsx | 258 +++++ .../src/store/__tests__/vodLogos.test.jsx | 480 +++++++++ .../src/store/__tests__/warnings.test.jsx | 195 ++++ 18 files changed, 6631 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useLocalStorage.test.jsx create mode 100644 frontend/src/hooks/__tests__/useSmartLogos.test.jsx create mode 100644 frontend/src/store/__tests__/auth.test.jsx create mode 100644 frontend/src/store/__tests__/channels.test.jsx create mode 100644 frontend/src/store/__tests__/channelsTable.test.jsx create mode 100644 frontend/src/store/__tests__/epgs.test.jsx create mode 100644 frontend/src/store/__tests__/logos.test.jsx create mode 100644 frontend/src/store/__tests__/playlists.test.jsx create mode 100644 frontend/src/store/__tests__/plugins.test.jsx create mode 100644 frontend/src/store/__tests__/settings.test.jsx create mode 100644 frontend/src/store/__tests__/streamProfiles.test.jsx create mode 100644 frontend/src/store/__tests__/streams.test.jsx create mode 100644 frontend/src/store/__tests__/useVODStore.test.jsx create mode 100644 frontend/src/store/__tests__/useVideoStore.test.jsx create mode 100644 frontend/src/store/__tests__/userAgents.test.jsx create mode 100644 frontend/src/store/__tests__/users.test.jsx create mode 100644 frontend/src/store/__tests__/vodLogos.test.jsx create mode 100644 frontend/src/store/__tests__/warnings.test.jsx diff --git a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx new file mode 100644 index 00000000..8be21e28 --- /dev/null +++ b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx @@ -0,0 +1,110 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useLocalStorage from '../useLocalStorage'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +global.localStorage = localStorageMock; + +// Mock console.error to avoid cluttering test output +global.console.error = vi.fn(); + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('should initialize with default value when localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('defaultValue'); + }); + + it('should initialize with value from localStorage if available', () => { + localStorageMock.setItem('testKey', JSON.stringify('storedValue')); + + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('storedValue'); + }); + + it('should update localStorage when value changes', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'initial')); + + act(() => { + result.current[1]('updated'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated')); + expect(result.current[0]).toBe('updated'); + }); + + it('should handle complex objects', () => { + const complexObject = { name: 'test', count: 42, nested: { value: true } }; + + const { result } = renderHook(() => useLocalStorage('testKey', complexObject)); + + act(() => { + result.current[1]({ name: 'updated', count: 100 }); + }); + + expect(result.current[0]).toEqual({ name: 'updated', count: 100 }); + }); + + it('should handle errors when reading from localStorage', () => { + localStorageMock.getItem.mockImplementationOnce(() => { + throw new Error('Read error'); + }); + + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('defaultValue'); + expect(console.error).toHaveBeenCalledWith( + 'Error reading key "testKey":', + expect.any(Error) + ); + }); + + it('should handle errors when writing to localStorage', () => { + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Write error'); + }); + + const { result } = renderHook(() => useLocalStorage('testKey', 'initial')); + + act(() => { + result.current[1]('updated'); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error saving setting: testKey:', + expect.any(Error) + ); + }); + + it('should handle invalid JSON in localStorage', () => { + localStorageMock.getItem.mockReturnValueOnce('invalid json{'); + + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('defaultValue'); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx new file mode 100644 index 00000000..d8ef2711 --- /dev/null +++ b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx @@ -0,0 +1,297 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos'; +import useLogosStore from '../../store/logos'; + +// Mock the logos store +vi.mock('../../store/logos'); + +describe('useSmartLogos', () => { + describe('useLogoSelection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with empty state', () => { + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogos: vi.fn(), + })); + + const { result } = renderHook(() => useLogoSelection()); + + expect(result.current.logos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLogos).toBe(false); + }); + + it('should load logos when ensureLogosLoaded is called', async () => { + const mockFetchLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogos: mockFetchLogos, + })); + + const { result } = renderHook(() => useLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchLogos).toHaveBeenCalledTimes(1); + }); + + it('should not reload logos if already loaded', async () => { + const mockFetchLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: { logo1: { id: 'logo1' } }, + fetchLogos: mockFetchLogos, + })); + + const { result } = renderHook(() => useLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchLogos).toHaveBeenCalledTimes(1); + }); + + it('should handle errors when fetching logos', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogos: mockFetchLogos, + })); + + const { result } = renderHook(() => useLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load logos for selection:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should indicate hasLogos when logos are present', () => { + useLogosStore.mockImplementation((selector) => selector({ + logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } }, + fetchLogos: vi.fn(), + })); + + const { result } = renderHook(() => useLogoSelection()); + + expect(result.current.hasLogos).toBe(true); + }); + }); + + describe('useChannelLogoSelection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with channel logos state', () => { + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: vi.fn(), + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + expect(result.current.logos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLogos).toBe(false); + }); + + it('should load channel logos when ensureLogosLoaded is called', async () => { + const mockFetchChannelLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchChannelLogos).toHaveBeenCalledTimes(1); + }); + + it('should not reload if already loaded', async () => { + const mockFetchChannelLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: { logo1: { id: 'logo1' } }, + hasLoadedChannelLogos: true, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchChannelLogos).not.toHaveBeenCalled(); + }); + + it('should not load if backgroundLoading is true', async () => { + const mockFetchChannelLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: true, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchChannelLogos).not.toHaveBeenCalled(); + }); + + it('should handle errors when fetching channel logos', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load channel logos:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('useLogosById', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with empty logos', () => { + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: vi.fn(), + })); + + const { result } = renderHook(() => useLogosById([])); + + expect(result.current.logos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.missingLogos).toBe(0); + }); + + it('should fetch missing logos by IDs', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1', 'logo2'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']); + }); + }); + + it('should not fetch logos that are already loaded', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: { logo1: { id: 'logo1' } }, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1', 'logo2'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo2']); + }); + }); + + it('should handle errors when fetching logos by IDs', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1'])); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load logos by IDs:', + expect.any(Error) + ); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('should filter out null/undefined IDs', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1', null, undefined, 'logo2'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']); + }); + }); + + it('should not refetch the same IDs multiple times', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + const { rerender } = renderHook(() => useLogosById(['logo1'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + }); + + rerender(); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx new file mode 100644 index 00000000..4ae99e4b --- /dev/null +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -0,0 +1,479 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import useAuthStore from '../auth'; +import useSettingsStore from '../settings'; +import useChannelsStore from '../channels'; +import usePlaylistsStore from '../playlists'; +import useEPGsStore from '../epgs'; +import useStreamProfilesStore from '../streamProfiles'; +import useUserAgentsStore from '../userAgents'; +import useUsersStore from '../users'; +import API from '../../api'; +import { USER_LEVELS } from '../../constants'; + +// Mock all store dependencies +vi.mock('../settings'); +vi.mock('../channels'); +vi.mock('../playlists'); +vi.mock('../epgs'); +vi.mock('../streamProfiles'); +vi.mock('../userAgents'); +vi.mock('../users'); +vi.mock('../../api'); + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + }; +})(); + +global.localStorage = localStorageMock; + +// Mock console methods +global.console.error = vi.fn(); + +// Helper to create a mock JWT token +const createMockToken = (expiresInSeconds = 3600) => { + const now = Math.floor(Date.now() / 1000); + const exp = now + expiresInSeconds; + const payload = btoa(JSON.stringify({ exp })); + return `header.${payload}.signature`; +}; + +describe('useAuthStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + + // Setup default store mocks + useSettingsStore.mockImplementation((selector) => selector({ + fetchSettings: vi.fn().mockResolvedValue(), + })); + + useChannelsStore.mockImplementation((selector) => selector({ + fetchChannels: vi.fn().mockResolvedValue(), + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + })); + + usePlaylistsStore.mockImplementation((selector) => selector({ + fetchPlaylists: vi.fn().mockResolvedValue(), + })); + + useEPGsStore.mockImplementation((selector) => selector({ + fetchEPGs: vi.fn().mockResolvedValue(), + fetchEPGData: vi.fn().mockResolvedValue(), + })); + + useStreamProfilesStore.mockImplementation((selector) => selector({ + fetchProfiles: vi.fn().mockResolvedValue(), + })); + + useUserAgentsStore.mockImplementation((selector) => selector({ + fetchUserAgents: vi.fn().mockResolvedValue(), + })); + + useUsersStore.mockImplementation((selector) => selector({ + fetchUsers: vi.fn().mockResolvedValue(), + })); + }); + + afterEach(() => { + // Reset the store state + const { setState } = useAuthStore; + if (setState) { + setState({ + isAuthenticated: false, + isInitialized: false, + needsSuperuser: false, + user: { + username: '', + email: '', + user_level: '', + }, + isLoading: false, + error: null, + accessToken: null, + refreshToken: null, + tokenExpiration: null, + superuserExists: true, + }); + } + }); + + describe('Initial State', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useAuthStore()); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.isInitialized).toBe(false); + expect(result.current.needsSuperuser).toBe(false); + expect(result.current.user).toEqual({ + username: '', + email: '', + user_level: '', + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.superuserExists).toBe(true); + }); + }); + + describe('login', () => { + it('should successfully login and store tokens', async () => { + const mockAccessToken = createMockToken(); + const mockRefreshToken = createMockToken(86400); + + API.login.mockResolvedValue({ + access: mockAccessToken, + refresh: mockRefreshToken, + }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'password' }); + }); + + expect(API.login).toHaveBeenCalledWith('testuser', 'password'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken); + expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken); + expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number)); + }); + + it('should handle login failure', async () => { + API.login.mockRejectedValue(new Error('Invalid credentials')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'wrong' }); + }); + + expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + }); + }); + + describe('getRefreshToken', () => { + it('should refresh token successfully', async () => { + const mockNewAccessToken = createMockToken(); + localStorageMock.getItem.mockReturnValue('old-refresh-token'); + + API.refreshToken.mockResolvedValue({ + access: mockNewAccessToken, + }); + + const { result } = renderHook(() => useAuthStore()); + + let newToken; + await act(async () => { + newToken = await result.current.getRefreshToken(); + }); + + expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token'); + expect(newToken).toBe(mockNewAccessToken); + expect(result.current.isAuthenticated).toBe(true); + expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken); + }); + + it('should return false if no refresh token exists', async () => { + localStorageMock.getItem.mockReturnValue(null); + + const { result } = renderHook(() => useAuthStore()); + + let response; + await act(async () => { + response = await result.current.getRefreshToken(); + }); + + expect(response).toBe(false); + expect(API.refreshToken).not.toHaveBeenCalled(); + }); + + it('should logout on refresh token failure', async () => { + localStorageMock.getItem.mockReturnValue('invalid-refresh-token'); + API.refreshToken.mockRejectedValue(new Error('Invalid token')); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.getRefreshToken(); + }); + + expect(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error)); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration'); + }); + }); + + describe('getToken', () => { + it('should return valid access token if not expired', async () => { + const mockToken = createMockToken(3600); + const now = Math.floor(Date.now() / 1000); + + localStorageMock.getItem.mockImplementation((key) => { + if (key === 'tokenExpiration') return (now + 1800).toString(); + if (key === 'accessToken') return mockToken; + return null; + }); + + const { result } = renderHook(() => useAuthStore()); + + let token; + await act(async () => { + token = await result.current.getToken(); + }); + + expect(token).toBe(mockToken); + }); + + it('should refresh token if expired', async () => { + const now = Math.floor(Date.now() / 1000); + const mockNewToken = createMockToken(); + + localStorageMock.getItem.mockImplementation((key) => { + if (key === 'tokenExpiration') return (now - 100).toString(); + if (key === 'refreshToken') return 'refresh-token'; + return null; + }); + + API.refreshToken.mockResolvedValue({ + access: mockNewToken, + }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.getToken(); + }); + + expect(API.refreshToken).toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('should clear tokens and call logout API', async () => { + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(API.logout).toHaveBeenCalled(); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration'); + }); + + it('should continue logout even if API call fails', async () => { + API.logout.mockRejectedValue(new Error('API error')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error)); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.removeItem).toHaveBeenCalled(); + }); + }); + + describe('initializeAuth', () => { + it('should initialize auth with valid refresh token', async () => { + const mockToken = createMockToken(); + localStorageMock.getItem.mockReturnValue('valid-refresh-token'); + API.refreshToken.mockResolvedValue({ access: mockToken }); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(true); + }); + + it('should return false if no refresh token exists', async () => { + localStorageMock.getItem.mockReturnValue(null); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + }); + + describe('initData', () => { + const fetchSettings = vi.fn().mockResolvedValue(); + const fetchChannels = vi.fn().mockResolvedValue(); + const fetchChannelGroups = vi.fn().mockResolvedValue(); + const fetchChannelProfiles = vi.fn().mockResolvedValue(); + const fetchPlaylists = vi.fn().mockResolvedValue(); + const fetchEPGs = vi.fn().mockResolvedValue(); + const fetchEPGData = vi.fn().mockResolvedValue(); + const fetchProfiles = vi.fn().mockResolvedValue(); + const fetchUserAgents = vi.fn().mockResolvedValue(); + const fetchUsers = vi.fn().mockResolvedValue(); + + // Mock getState for each store + useSettingsStore.getState = () => ({ fetchSettings }); + useChannelsStore.getState = () => ({ + fetchChannels, + fetchChannelGroups, + fetchChannelProfiles, + }); + usePlaylistsStore.getState = () => ({ fetchPlaylists }); + useEPGsStore.getState = () => ({ fetchEPGs, fetchEPGData }); + useStreamProfilesStore.getState = () => ({ fetchProfiles }); + useUserAgentsStore.getState = () => ({ fetchUserAgents }); + useUsersStore.getState = () => ({ fetchUsers }); + + it('should initialize data for admin user', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).toHaveBeenCalled(); + expect(result.current.user).toEqual(mockUser); + expect(result.current.isAuthenticated).toBe(true); + expect(fetchSettings).toHaveBeenCalled(); + expect(fetchChannels).toHaveBeenCalled(); + expect(fetchUsers).toHaveBeenCalled(); + }); + + + it('should not fetch users for non-admin user', async () => { + const mockUser = { + username: 'reseller', + email: 'reseller@test.com', + user_level: USER_LEVELS.RESELLER, + }; + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(fetchUsers).not.toHaveBeenCalled(); + }); + + it('should throw error for unauthorized user level', async () => { + const mockUser = { + username: 'streamer', + email: 'streamer@test.com', + user_level: USER_LEVELS.STREAMER, + }; + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await expect( + act(async () => { + await result.current.initData(); + }) + ).rejects.toThrow('Unauthorized'); + }); + + it('should handle errors during data initialization', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + API.me.mockResolvedValue(mockUser); + + const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed')); + + useChannelsStore.getState = vi.fn(() => ({ + fetchChannels, + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + })); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error)); + }); + }); + + describe('setUser', () => { + it('should update user state', () => { + const { result } = renderHook(() => useAuthStore()); + const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN }; + + act(() => { + result.current.setUser(newUser); + }); + + expect(result.current.user).toEqual(newUser); + }); + }); + + describe('setIsAuthenticated', () => { + it('should update authentication state', () => { + const { result } = renderHook(() => useAuthStore()); + + act(() => { + result.current.setIsAuthenticated(true); + }); + + expect(result.current.isAuthenticated).toBe(true); + }); + }); + + describe('setSuperuserExists', () => { + it('should update superuser exists state', () => { + const { result } = renderHook(() => useAuthStore()); + + act(() => { + result.current.setSuperuserExists(false); + }); + + expect(result.current.superuserExists).toBe(false); + }); + }); +}); diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx new file mode 100644 index 00000000..6b452549 --- /dev/null +++ b/frontend/src/store/__tests__/channels.test.jsx @@ -0,0 +1,442 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useChannelsStore from '../channels'; +import api from '../../api'; +import { showNotification } from '../../utils/notificationUtils'; + +// Mock dependencies +vi.mock('../../api'); +vi.mock('../../utils/notificationUtils'); + +describe('useChannelsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state between tests + useChannelsStore.setState({ + channels: {}, + channelsByUUID: {}, + channelGroups: {}, + profiles: { 0: { id: '0', name: 'All', channels: new Set() } }, + selectedProfileId: '0', + channelsPageSelection: [], + stats: {}, + activeChannels: {}, + activeClients: {}, + recordings: [], + recurringRules: [], + isLoading: false, + error: null, + forceUpdate: 0, + }); + }); + + describe('fetchChannels', () => { + it('should fetch and store channels successfully', async () => { + const mockChannels = [ + { id: 1, uuid: 'uuid-1', name: 'Channel 1' }, + { id: 2, uuid: 'uuid-2', name: 'Channel 2' }, + ]; + api.getChannels.mockResolvedValue(mockChannels); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannels(); + }); + + expect(api.getChannels).toHaveBeenCalledOnce(); + expect(result.current.channels).toEqual({ + 1: mockChannels[0], + 2: mockChannels[1], + }); + expect(result.current.channelsByUUID).toEqual({ + 'uuid-1': 1, + 'uuid-2': 2, + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should handle fetch error', async () => { + const errorMessage = 'Network error'; + api.getChannels.mockRejectedValue(new Error(errorMessage)); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannels(); + }); + + expect(result.current.error).toBe(errorMessage); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('fetchChannelGroups', () => { + it('should fetch and process channel groups', async () => { + const mockGroups = [ + { id: 1, name: 'Group 1', channel_count: 5, m3u_account_count: 0 }, + { id: 2, name: 'Group 2', channel_count: 0, m3u_account_count: 2 }, + ]; + api.getChannelGroups.mockResolvedValue(mockGroups); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannelGroups(); + }); + + expect(api.getChannelGroups).toHaveBeenCalledOnce(); + expect(result.current.channelGroups[1]).toMatchObject({ + hasChannels: true, + hasM3UAccounts: false, + canEdit: true, + canDelete: false, + }); + expect(result.current.channelGroups[2]).toMatchObject({ + hasChannels: false, + hasM3UAccounts: true, + canEdit: false, + canDelete: false, + }); + }); + }); + + describe('fetchChannelProfiles', () => { + it('should fetch and process channel profiles', async () => { + const mockProfiles = [ + { id: '1', name: 'Profile 1', channels: [1, 2, 3] }, + ]; + api.getChannelProfiles.mockResolvedValue(mockProfiles); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannelProfiles(); + }); + + expect(api.getChannelProfiles).toHaveBeenCalledOnce(); + expect(result.current.profiles['1'].channels).toBeInstanceOf(Set); + expect(result.current.profiles['1'].channels.has(1)).toBe(true); + }); + }); + + describe('addChannel', () => { + it('should add a new channel', async () => { + const newChannel = { id: 3, uuid: 'uuid-3', name: 'Channel 3' }; + api.getChannelProfiles.mockResolvedValue([]); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + result.current.addChannel(newChannel); + }); + + expect(result.current.channels[3]).toEqual(newChannel); + expect(result.current.channelsByUUID['uuid-3']).toBe(3); + }); + }); + + describe('updateChannel', () => { + it('should update an existing channel', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channels: { 1: { id: 1, uuid: 'uuid-1', name: 'Old Name' } }, + channelsByUUID: { 'uuid-1': 1 }, + }); + }); + + const updatedChannel = { id: 1, uuid: 'uuid-1', name: 'New Name' }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels[1].name).toBe('New Name'); + }); + }); + + describe('removeChannels', () => { + it('should remove channels by IDs', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channels: { + 1: { id: 1, uuid: 'uuid-1' }, + 2: { id: 2, uuid: 'uuid-2' }, + }, + channelsByUUID: { 'uuid-1': 1, 'uuid-2': 2 }, + }); + }); + + act(() => { + result.current.removeChannels([1]); + }); + + expect(result.current.channels[1]).toBeUndefined(); + expect(result.current.channelsByUUID['uuid-1']).toBeUndefined(); + expect(result.current.channels[2]).toBeDefined(); + }); + }); + + describe('channel groups operations', () => { + it('should add a channel group', () => { + const { result } = renderHook(() => useChannelsStore()); + const newGroup = { id: 1, name: 'New Group' }; + + act(() => { + result.current.addChannelGroup(newGroup); + }); + + expect(result.current.channelGroups[1]).toEqual(newGroup); + }); + + it('should update a channel group', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { 1: { id: 1, name: 'Old Name' } }, + }); + }); + + act(() => { + result.current.updateChannelGroup({ id: 1, name: 'Updated Name' }); + }); + + expect(result.current.channelGroups[1].name).toBe('Updated Name'); + }); + + it('should remove a channel group', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { 1: { id: 1, name: 'Group' } }, + }); + }); + + act(() => { + result.current.removeChannelGroup(1); + }); + + expect(result.current.channelGroups[1]).toBeUndefined(); + }); + }); + + describe('profile operations', () => { + it('should add a profile', () => { + const { result } = renderHook(() => useChannelsStore()); + const newProfile = { id: '1', name: 'Profile', channels: [1, 2] }; + + act(() => { + result.current.addProfile(newProfile); + }); + + expect(result.current.profiles['1'].channels).toBeInstanceOf(Set); + }); + + it('should update a profile', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] }); + }); + + expect(result.current.profiles['1'].name).toBe('Updated'); + }); + + it('should remove profiles and reset selected if needed', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + profiles: { '1': { id: '1' }, '2': { id: '2' } }, + selectedProfileId: '1', + }); + }); + + act(() => { + result.current.removeProfiles(['1']); + }); + + expect(result.current.profiles['1']).toBeUndefined(); + expect(result.current.selectedProfileId).toBe('0'); + }); + }); + + describe('updateProfileChannels', () => { + it('should add channels to profile', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + profiles: { '1': { id: '1', channels: new Set([1]) } }, + }); + }); + + act(() => { + result.current.updateProfileChannels([2, 3], '1', true); + }); + + expect(result.current.profiles['1'].channels.has(2)).toBe(true); + expect(result.current.profiles['1'].channels.has(3)).toBe(true); + }); + + it('should remove channels from profile', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } }, + }); + }); + + act(() => { + result.current.updateProfileChannels([2], '1', false); + }); + + expect(result.current.profiles['1'].channels.has(2)).toBe(false); + }); + }); + + describe('setChannelStats', () => { + it('should update stats and show notifications for new channels', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channels: { 1: { id: 1, name: 'Channel 1' } }, + channelsByUUID: { 'uuid-1': 1 }, + stats: { channels: [] }, + }); + }); + + const newStats = { + channels: [ + { channel_id: 'uuid-1', clients: [] }, + ], + }; + + act(() => { + result.current.setChannelStats(newStats); + }); + + expect(result.current.stats).toEqual(newStats); + expect(showNotification).toHaveBeenCalled(); + }); + }); + + describe('recordings operations', () => { + it('should fetch recordings', async () => { + const mockRecordings = [{ id: 1, title: 'Recording 1' }]; + api.getRecordings.mockResolvedValue(mockRecordings); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchRecordings(); + }); + + expect(result.current.recordings).toEqual(mockRecordings); + }); + + it('should remove a recording', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + recordings: [{ id: 1 }, { id: 2 }], + }); + }); + + act(() => { + result.current.removeRecording(1); + }); + + expect(result.current.recordings).toHaveLength(1); + expect(result.current.recordings[0].id).toBe(2); + }); + }); + + describe('recurring rules operations', () => { + it('should fetch recurring rules', async () => { + const mockRules = [{ id: 1, name: 'Rule 1' }]; + api.listRecurringRules.mockResolvedValue(mockRules); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchRecurringRules(); + }); + + expect(result.current.recurringRules).toEqual(mockRules); + }); + + it('should remove a recurring rule', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + recurringRules: [{ id: 1 }, { id: 2 }], + }); + }); + + act(() => { + result.current.removeRecurringRule(1); + }); + + expect(result.current.recurringRules).toHaveLength(1); + }); + }); + + describe('helper methods', () => { + it('should validate if channel group can be edited', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { + 1: { id: 1, canEdit: true }, + 2: { id: 2, canEdit: false }, + }, + }); + }); + + expect(result.current.canEditChannelGroup(1)).toBe(true); + expect(result.current.canEditChannelGroup(2)).toBe(false); + }); + + it('should validate if channel group can be deleted', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { + 1: { id: 1, canDelete: true }, + 2: { id: 2, canDelete: false }, + }, + }); + }); + + expect(result.current.canDeleteChannelGroup(1)).toBe(true); + expect(result.current.canDeleteChannelGroup(2)).toBe(false); + }); + }); + + describe('triggerUpdate', () => { + it('should update forceUpdate timestamp', () => { + const { result } = renderHook(() => useChannelsStore()); + const initialUpdate = result.current.forceUpdate; + + act(() => { + result.current.triggerUpdate(); + }); + + expect(result.current.forceUpdate).not.toBe(initialUpdate); + }); + }); +}); diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx new file mode 100644 index 00000000..171f0672 --- /dev/null +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -0,0 +1,295 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useChannelsTableStore from '../channelsTable'; + +describe('useChannelsTableStore', () => { + beforeEach(() => { + // Mock localStorage + const mockLocalStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + clear: vi.fn(), + }; + global.localStorage = mockLocalStorage; + + vi.clearAllMocks(); + + // Reset store state between tests + useChannelsTableStore.setState({ + channels: [], + pageCount: 0, + totalCount: 0, + sorting: [{ id: 'channel_number', desc: false }], + pagination: { + pageIndex: 0, + pageSize: 50, + }, + selectedChannelIds: [], + allQueryIds: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + expect(result.current.channels).toEqual([]); + expect(result.current.pageCount).toBe(0); + expect(result.current.totalCount).toBe(0); + expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]); + expect(result.current.pagination.pageIndex).toBe(0); + expect(result.current.pagination.pageSize).toBe(50); + expect(result.current.selectedChannelIds).toEqual([]); + expect(result.current.allQueryIds).toEqual([]); + }); + }); + + describe('queryChannels', () => { + it('should update channels, totalCount, and pageCount', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockResults = [ + { id: 1, name: 'Channel 1' }, + { id: 2, name: 'Channel 2' }, + ]; + const mockData = { + results: mockResults, + count: 150, + }; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryChannels(mockData, mockParams); + }); + + expect(result.current.channels).toEqual(mockResults); + expect(result.current.totalCount).toBe(150); + expect(result.current.pageCount).toBe(3); // Math.ceil(150 / 50) + }); + + it('should calculate pageCount correctly with different page sizes', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockData = { + results: [], + count: 75, + }; + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryChannels(mockData, mockParams); + }); + + expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25) + }); + + it('should handle zero results', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockData = { + results: [], + count: 0, + }; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryChannels(mockData, mockParams); + }); + + expect(result.current.channels).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageCount).toBe(0); + }); + }); + + describe('setAllQueryIds', () => { + it('should update allQueryIds', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockIds = [1, 2, 3, 4, 5]; + + act(() => { + result.current.setAllQueryIds(mockIds); + }); + + expect(result.current.allQueryIds).toEqual(mockIds); + }); + + it('should replace existing allQueryIds', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setAllQueryIds([1, 2, 3]); + }); + + expect(result.current.allQueryIds).toEqual([1, 2, 3]); + + act(() => { + result.current.setAllQueryIds([4, 5, 6]); + }); + + expect(result.current.allQueryIds).toEqual([4, 5, 6]); + }); + }); + + describe('setSelectedChannelIds', () => { + it('should update selectedChannelIds', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockIds = [1, 3, 5]; + + act(() => { + result.current.setSelectedChannelIds(mockIds); + }); + + expect(result.current.selectedChannelIds).toEqual(mockIds); + }); + + it('should clear selectedChannelIds when empty array is passed', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setSelectedChannelIds([1, 2, 3]); + }); + + expect(result.current.selectedChannelIds).toEqual([1, 2, 3]); + + act(() => { + result.current.setSelectedChannelIds([]); + }); + + expect(result.current.selectedChannelIds).toEqual([]); + }); + }); + + describe('getChannelStreams', () => { + it('should return streams for existing channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', streams: ['stream1', 'stream2'] }, + { id: 2, name: 'Channel 2', streams: ['stream3'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const streams = result.current.getChannelStreams(1); + + expect(streams).toEqual(['stream1', 'stream2']); + }); + + it('should return empty array for channel without streams', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1' }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const streams = result.current.getChannelStreams(1); + + expect(streams).toEqual([]); + }); + + it('should return empty array for non-existent channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', streams: ['stream1'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const streams = result.current.getChannelStreams(999); + + expect(streams).toEqual([]); + }); + }); + + describe('setPagination', () => { + it('should update pagination', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const newPagination = { + pageIndex: 2, + pageSize: 100, + }; + + act(() => { + result.current.setPagination(newPagination); + }); + + expect(result.current.pagination).toEqual(newPagination); + }); + + it('should update only changed pagination properties', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setPagination({ + pageIndex: 5, + pageSize: 50, + }); + }); + + expect(result.current.pagination.pageIndex).toBe(5); + expect(result.current.pagination.pageSize).toBe(50); + }); + }); + + describe('setSorting', () => { + it('should update sorting', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const newSorting = [{ id: 'name', desc: true }]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle multiple sorting columns', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const newSorting = [ + { id: 'name', desc: false }, + { id: 'channel_number', desc: true }, + ]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should clear sorting when empty array is passed', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setSorting([{ id: 'name', desc: true }]); + }); + + expect(result.current.sorting).toHaveLength(1); + + act(() => { + result.current.setSorting([]); + }); + + expect(result.current.sorting).toEqual([]); + }); + }); +}); diff --git a/frontend/src/store/__tests__/epgs.test.jsx b/frontend/src/store/__tests__/epgs.test.jsx new file mode 100644 index 00000000..05f136d3 --- /dev/null +++ b/frontend/src/store/__tests__/epgs.test.jsx @@ -0,0 +1,687 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import useEPGsStore from '../epgs'; +import api from '../../api'; + +// Mock the api module +vi.mock('../../api'); + +describe('useEPGsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset store state between tests + useEPGsStore.setState({ + epgs: {}, + tvgs: [], + tvgsById: {}, + tvgsLoaded: false, + isLoading: false, + error: null, + refreshProgress: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useEPGsStore()); + + expect(result.current.epgs).toEqual({}); + expect(result.current.tvgs).toEqual([]); + expect(result.current.tvgsById).toEqual({}); + expect(result.current.tvgsLoaded).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.refreshProgress).toEqual({}); + }); + }); + + describe('fetchEPGs', () => { + it('should fetch and store EPGs successfully', async () => { + const mockEPGs = [ + { id: 'epg1', name: 'EPG 1', status: 'idle' }, + { id: 'epg2', name: 'EPG 2', status: 'success' }, + ]; + + api.getEPGs.mockResolvedValue(mockEPGs); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGs(); + }); + + expect(result.current.epgs).toEqual({ + epg1: { id: 'epg1', name: 'EPG 1', status: 'idle' }, + epg2: { id: 'epg2', name: 'EPG 2', status: 'success' }, + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(api.getEPGs).toHaveBeenCalledTimes(1); + }); + + it('should set loading state while fetching', async () => { + api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100))); + + const { result } = renderHook(() => useEPGsStore()); + + const fetchPromise = act(async () => { + await result.current.fetchEPGs(); + }); + + // Check loading state immediately after calling + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await fetchPromise; + + expect(result.current.isLoading).toBe(false); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Network error'); + api.getEPGs.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGs(); + }); + + expect(result.current.error).toBe('Failed to load epgs.'); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError); + + consoleSpy.mockRestore(); + }); + + it('should handle empty EPGs array', async () => { + api.getEPGs.mockResolvedValue([]); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGs(); + }); + + expect(result.current.epgs).toEqual({}); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('fetchEPGData', () => { + it('should fetch and store TVG data successfully', async () => { + const mockTVGs = [ + { id: 'tvg1', name: 'TVG 1' }, + { id: 'tvg2', name: 'TVG 2' }, + ]; + + api.getEPGData.mockResolvedValue(mockTVGs); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGData(); + }); + + expect(result.current.tvgs).toEqual(mockTVGs); + expect(result.current.tvgsById).toEqual({ + tvg1: { id: 'tvg1', name: 'TVG 1' }, + tvg2: { id: 'tvg2', name: 'TVG 2' }, + }); + expect(result.current.tvgsLoaded).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(api.getEPGData).toHaveBeenCalledTimes(1); + }); + + it('should set loading state while fetching', async () => { + api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100))); + + const { result } = renderHook(() => useEPGsStore()); + + const fetchPromise = act(async () => { + await result.current.fetchEPGData(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await fetchPromise; + + expect(result.current.isLoading).toBe(false); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('API error'); + api.getEPGData.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGData(); + }); + + expect(result.current.error).toBe('Failed to load tvgs.'); + expect(result.current.tvgsLoaded).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError); + + consoleSpy.mockRestore(); + }); + + it('should handle empty TVG array', async () => { + api.getEPGData.mockResolvedValue([]); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGData(); + }); + + expect(result.current.tvgs).toEqual([]); + expect(result.current.tvgsById).toEqual({}); + expect(result.current.tvgsLoaded).toBe(true); + }); + }); + + describe('addEPG', () => { + it('should add new EPG to store', () => { + const { result } = renderHook(() => useEPGsStore()); + + const newEPG = { id: 'epg1', name: 'New EPG', status: 'idle' }; + + act(() => { + result.current.addEPG(newEPG); + }); + + expect(result.current.epgs).toEqual({ + epg1: newEPG, + }); + }); + + it('should add multiple EPGs', () => { + const { result } = renderHook(() => useEPGsStore()); + + const epg1 = { id: 'epg1', name: 'EPG 1', status: 'idle' }; + const epg2 = { id: 'epg2', name: 'EPG 2', status: 'success' }; + + act(() => { + result.current.addEPG(epg1); + result.current.addEPG(epg2); + }); + + expect(result.current.epgs).toEqual({ + epg1, + epg2, + }); + }); + + it('should not overwrite existing EPGs', () => { + const { result } = renderHook(() => useEPGsStore()); + + const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' }; + const newEPG = { id: 'epg2', name: 'New', status: 'success' }; + + act(() => { + result.current.addEPG(originalEPG); + result.current.addEPG(newEPG); + }); + + expect(result.current.epgs).toEqual({ + epg1: originalEPG, + epg2: newEPG, + }); + }); + }); + + describe('updateEPG', () => { + it('should update existing EPG', () => { + const { result } = renderHook(() => useEPGsStore()); + + const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' }; + const updatedEPG = { id: 'epg1', name: 'Updated', status: 'success' }; + + act(() => { + result.current.addEPG(originalEPG); + }); + + act(() => { + result.current.updateEPG(updatedEPG); + }); + + expect(result.current.epgs.epg1).toEqual(updatedEPG); + }); + + it('should add EPG if it does not exist', () => { + const { result } = renderHook(() => useEPGsStore()); + + const newEPG = { id: 'epg1', name: 'New', status: 'idle' }; + + act(() => { + result.current.updateEPG(newEPG); + }); + + expect(result.current.epgs.epg1).toEqual(newEPG); + }); + + it('should not update state when called with invalid epg (null)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.updateEPG(null); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null); + + consoleSpy.mockRestore(); + }); + + it('should not update state when called with invalid epg (missing id)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + const invalidEPG = { name: 'No ID' }; + + act(() => { + result.current.updateEPG(invalidEPG); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG); + + consoleSpy.mockRestore(); + }); + + it('should not update state when called with invalid epg (non-object)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.updateEPG('invalid'); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid'); + + consoleSpy.mockRestore(); + }); + }); + + describe('removeEPGs', () => { + it('should remove single EPG', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + epg1: { id: 'epg1', name: 'EPG 1' }, + epg2: { id: 'epg2', name: 'EPG 2' }, + }, + }); + }); + + act(() => { + result.current.removeEPGs(['epg1']); + }); + + expect(result.current.epgs).toEqual({ + epg2: { id: 'epg2', name: 'EPG 2' }, + }); + }); + + it('should remove multiple EPGs', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + epg1: { id: 'epg1', name: 'EPG 1' }, + epg2: { id: 'epg2', name: 'EPG 2' }, + epg3: { id: 'epg3', name: 'EPG 3' }, + }, + }); + }); + + act(() => { + result.current.removeEPGs(['epg1', 'epg3']); + }); + + expect(result.current.epgs).toEqual({ + epg2: { id: 'epg2', name: 'EPG 2' }, + }); + }); + + it('should handle removing non-existent EPG', () => { + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { + epg1: { id: 'epg1', name: 'EPG 1' }, + }; + + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.removeEPGs(['nonexistent']); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + }); + + it('should handle empty array', () => { + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { + epg1: { id: 'epg1', name: 'EPG 1' }, + }; + + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.removeEPGs([]); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + }); + }); + + describe('updateEPGProgress', () => { + beforeEach(() => { + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'idle', last_message: '' }, + }, + }); + }); + }); + + it('should update progress for downloading action', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'downloading', + progress: 50, + speed: '1.5 MB/s', + elapsed_time: '00:00:30', + time_remaining: '00:00:30', + }); + }); + + expect(result.current.refreshProgress.source1).toEqual({ + action: 'downloading', + progress: 50, + speed: '1.5 MB/s', + elapsed_time: '00:00:30', + time_remaining: '00:00:30', + status: 'in_progress', + }); + expect(result.current.epgs.source1.status).toBe('fetching'); + }); + + it('should update progress for parsing_channels action', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'parsing_channels', + progress: 75, + }); + }); + + expect(result.current.epgs.source1.status).toBe('parsing'); + }); + + it('should update progress for parsing_programs action', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'parsing_programs', + progress: 90, + }); + }); + + expect(result.current.epgs.source1.status).toBe('parsing'); + }); + + it('should set status to success when progress is 100', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'success', + progress: 100, + }); + }); + + expect(result.current.epgs.source1.status).toBe('success'); + }); + + it('should use explicit status from data', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + status: 'error', + progress: 50, + }); + }); + + expect(result.current.epgs.source1.status).toBe('error'); + expect(result.current.refreshProgress.source1.status).toBe('error'); + }); + + it('should set last_message on error status', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + status: 'error', + error: 'Connection failed', + }); + }); + + expect(result.current.epgs.source1.last_message).toBe('Connection failed'); + }); + + it('should use default error message if error is not provided', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + status: 'error', + }); + }); + + expect(result.current.epgs.source1.last_message).toBe('Unknown error'); + }); + + it('should not update state when called with invalid data (null)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { ...result.current.epgs }; + const initialProgress = { ...result.current.refreshProgress }; + + act(() => { + result.current.updateEPGProgress(null); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(result.current.refreshProgress).toEqual(initialProgress); + expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null); + + consoleSpy.mockRestore(); + }); + + it('should not update state when called with invalid data (missing source)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { ...result.current.epgs }; + const initialProgress = { ...result.current.refreshProgress }; + + act(() => { + result.current.updateEPGProgress({ progress: 50 }); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(result.current.refreshProgress).toEqual(initialProgress); + expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 }); + + consoleSpy.mockRestore(); + }); + + it('should not update state when source does not exist and no status', () => { + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { ...result.current.epgs }; + const initialProgress = { ...result.current.refreshProgress }; + + act(() => { + result.current.updateEPGProgress({ + source: 'nonexistent', + progress: 50, + }); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(result.current.refreshProgress).toEqual(initialProgress); + }); + + it('should update refreshProgress even when source does not exist but status is provided', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'newSource', + status: 'success', + progress: 100, + }); + }); + + expect(result.current.refreshProgress.newSource).toEqual({ + action: undefined, + progress: 100, + speed: undefined, + elapsed_time: undefined, + time_remaining: undefined, + status: 'success', + }); + }); + + it('should not update EPG if status and last_message have not changed', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'fetching', last_message: '' }, + }, + }); + }); + + const epgsBeforeUpdate = result.current.epgs; + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'downloading', + progress: 25, + }); + }); + + // EPGs object should be the same reference (not updated) + expect(result.current.epgs).toBe(epgsBeforeUpdate); + // But refreshProgress should be updated + expect(result.current.refreshProgress.source1.progress).toBe(25); + }); + + it('should update EPG if status changed', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'idle', last_message: '' }, + }, + }); + }); + + const epgsBeforeUpdate = result.current.epgs; + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'downloading', + progress: 25, + }); + }); + + // EPGs object should be different (updated) because status changed from 'idle' to 'fetching' + expect(result.current.epgs).not.toBe(epgsBeforeUpdate); + expect(result.current.epgs.source1.status).toBe('fetching'); + }); + + it('should preserve current EPG status when no status change is detected', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'parsing', last_message: 'Processing' }, + }, + }); + }); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'parsing_programs', + progress: 85, + }); + }); + + expect(result.current.epgs.source1.status).toBe('parsing'); + expect(result.current.epgs.source1.last_message).toBe('Processing'); + }); + }); +}); diff --git a/frontend/src/store/__tests__/logos.test.jsx b/frontend/src/store/__tests__/logos.test.jsx new file mode 100644 index 00000000..59ac4283 --- /dev/null +++ b/frontend/src/store/__tests__/logos.test.jsx @@ -0,0 +1,916 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import useLogosStore from '../logos'; +import api from '../../api'; + +// Mock the api module +vi.mock('../../api'); + +describe('useLogosStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset store state between tests + useLogosStore.setState({ + logos: {}, + channelLogos: {}, + isLoading: false, + backgroundLoading: false, + hasLoadedAll: false, + hasLoadedChannelLogos: false, + error: null, + allowLogoRendering: false, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.logos).toEqual({}); + expect(result.current.channelLogos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.backgroundLoading).toBe(false); + expect(result.current.hasLoadedAll).toBe(false); + expect(result.current.hasLoadedChannelLogos).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.allowLogoRendering).toBe(false); + }); + }); + + describe('enableLogoRendering', () => { + it('should enable logo rendering', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + result.current.enableLogoRendering(); + }); + + expect(result.current.allowLogoRendering).toBe(true); + }); + }); + + describe('addLogo', () => { + it('should add logo to main logos store', () => { + const { result } = renderHook(() => useLogosStore()); + + const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + + act(() => { + result.current.addLogo(newLogo); + }); + + expect(result.current.logos).toEqual({ + logo1: newLogo, + }); + }); + + it('should add logo to channelLogos if hasLoadedChannelLogos is true', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedChannelLogos: true }); + }); + + const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + + act(() => { + result.current.addLogo(newLogo); + }); + + expect(result.current.logos).toEqual({ logo1: newLogo }); + expect(result.current.channelLogos).toEqual({ logo1: newLogo }); + }); + + it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => { + const { result } = renderHook(() => useLogosStore()); + + const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + + act(() => { + result.current.addLogo(newLogo); + }); + + expect(result.current.logos).toEqual({ logo1: newLogo }); + expect(result.current.channelLogos).toEqual({}); + }); + }); + + describe('updateLogo', () => { + it('should update logo in main logos store', () => { + const { result } = renderHook(() => useLogosStore()); + + const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; + const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + + act(() => { + useLogosStore.setState({ logos: { logo1: originalLogo } }); + }); + + act(() => { + result.current.updateLogo(updatedLogo); + }); + + expect(result.current.logos.logo1).toEqual(updatedLogo); + }); + + it('should update logo in channelLogos if it exists there', () => { + const { result } = renderHook(() => useLogosStore()); + + const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; + const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + + act(() => { + useLogosStore.setState({ + logos: { logo1: originalLogo }, + channelLogos: { logo1: originalLogo }, + }); + }); + + act(() => { + result.current.updateLogo(updatedLogo); + }); + + expect(result.current.logos.logo1).toEqual(updatedLogo); + expect(result.current.channelLogos.logo1).toEqual(updatedLogo); + }); + + it('should not update channelLogos if logo does not exist there', () => { + const { result } = renderHook(() => useLogosStore()); + + const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; + const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + + act(() => { + useLogosStore.setState({ logos: { logo1: originalLogo } }); + }); + + act(() => { + result.current.updateLogo(updatedLogo); + }); + + expect(result.current.channelLogos).toEqual({}); + }); + }); + + describe('removeLogo', () => { + it('should remove logo from both stores', () => { + const { result } = renderHook(() => useLogosStore()); + + const logo1 = { id: 'logo1', name: 'Logo 1' }; + const logo2 = { id: 'logo2', name: 'Logo 2' }; + + act(() => { + useLogosStore.setState({ + logos: { logo1, logo2 }, + channelLogos: { logo1, logo2 }, + }); + }); + + act(() => { + result.current.removeLogo('logo1'); + }); + + expect(result.current.logos).toEqual({ logo2 }); + expect(result.current.channelLogos).toEqual({ logo2 }); + }); + + it('should handle removing non-existent logo', () => { + const { result } = renderHook(() => useLogosStore()); + + const logo1 = { id: 'logo1', name: 'Logo 1' }; + + act(() => { + useLogosStore.setState({ logos: { logo1 } }); + }); + + act(() => { + result.current.removeLogo('nonexistent'); + }); + + expect(result.current.logos).toEqual({ logo1 }); + }); + }); + + describe('fetchLogos', () => { + it('should fetch logos successfully with array response', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + let response; + await act(async () => { + response = await result.current.fetchLogos(100); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.isLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledWith({ page_size: 100 }); + expect(response).toEqual(mockLogos); + }); + + it('should fetch logos successfully with paginated response', async () => { + const mockResponse = { + results: [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ], + count: 2, + }; + + api.getLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchLogos(50); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(api.getLogos).toHaveBeenCalledWith({ page_size: 50 }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Network error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchLogos(); + }) + ).rejects.toThrow('Network error'); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to load logos.'); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchAllLogos', () => { + it('should fetch all logos successfully', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + let response; + await act(async () => { + response = await result.current.fetchAllLogos(); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.hasLoadedAll).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' }); + expect(response).toEqual(mockLogos); + }); + + it('should not refetch if already loaded and not forced', async () => { + const mockLogos = [{ id: 'logo1', name: 'Logo 1' }]; + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + logos: { logo1: mockLogos[0] }, + hasLoadedAll: true, + }); + }); + + const response = await act(async () => { + return await result.current.fetchAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + expect(response).toEqual([mockLogos[0]]); + }); + + it('should refetch if forced', async () => { + const mockLogos = [{ id: 'logo1', name: 'Logo 1' }]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + logos: { logo1: mockLogos[0] }, + hasLoadedAll: true, + }); + }); + + await act(async () => { + await result.current.fetchAllLogos(true); + }); + + expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' }); + }); + + it('should not refetch if already loading', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ isLoading: true }); + }); + + const response = await act(async () => { + return await result.current.fetchAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + expect(response).toEqual([]); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('API error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchAllLogos(); + }) + ).rejects.toThrow('API error'); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to load all logos.'); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchUsedLogos', () => { + it('should fetch used logos successfully', async () => { + const mockResponse = { + results: [ + { id: 'logo1', name: 'Used Logo 1' }, + { id: 'logo2', name: 'Used Logo 2' }, + ], + }; + + api.getLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useLogosStore()); + + let response; + await act(async () => { + response = await result.current.fetchUsedLogos(100); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Used Logo 1' }, + logo2: { id: 'logo2', name: 'Used Logo 2' }, + }); + expect(result.current.isLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 }); + expect(response).toEqual(mockResponse); + }); + + it('should merge with existing logos', async () => { + const existingLogo = { id: 'logo1', name: 'Existing Logo' }; + const newLogo = { id: 'logo2', name: 'New Logo' }; + + api.getLogos.mockResolvedValue({ results: [newLogo] }); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: existingLogo } }); + }); + + await act(async () => { + await result.current.fetchUsedLogos(); + }); + + expect(result.current.logos).toEqual({ + logo1: existingLogo, + logo2: newLogo, + }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Fetch error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchUsedLogos(); + }) + ).rejects.toThrow('Fetch error'); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to load used logos.'); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchChannelAssignableLogos', () => { + it('should return cached logos if already loaded', async () => { + const cachedLogos = { + logo1: { id: 'logo1', name: 'Cached Logo' }, + }; + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + channelLogos: cachedLogos, + hasLoadedChannelLogos: true, + }); + }); + + const response = await act(async () => { + return await result.current.fetchChannelAssignableLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + expect(response).toEqual([cachedLogos.logo1]); + }); + + it('should fetch and cache logos if not loaded', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchChannelAssignableLogos(); + }); + + expect(result.current.channelLogos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.hasLoadedChannelLogos).toBe(true); + expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' }); + }); + }); + + describe('fetchLogosByIds', () => { + it('should fetch missing logos by IDs', async () => { + const existingLogo = { id: 'logo1', name: 'Existing' }; + const newLogo = { id: 'logo2', name: 'New' }; + + api.getLogosByIds.mockResolvedValue([newLogo]); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: existingLogo } }); + }); + + let response; + await act(async () => { + response = await result.current.fetchLogosByIds(['logo1', 'logo2']); + }); + + expect(api.getLogosByIds).toHaveBeenCalledWith(['logo2']); + expect(result.current.logos).toEqual({ + logo1: existingLogo, + logo2: newLogo, + }); + expect(response).toEqual([newLogo]); + }); + + it('should return empty array if all logos exist', async () => { + const logo1 = { id: 'logo1', name: 'Logo 1' }; + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1 } }); + }); + + const response = await act(async () => { + return await result.current.fetchLogosByIds(['logo1']); + }); + + expect(api.getLogosByIds).not.toHaveBeenCalled(); + expect(response).toEqual([]); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Fetch error'); + api.getLogosByIds.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchLogosByIds(['logo1']); + }) + ).rejects.toThrow('Fetch error'); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchLogosInBackground', () => { + it('should fetch logos in background with pagination', async () => { + // vi.useRealTimers(); + + const page1 = { + results: [{ id: 'logo1', name: 'Logo 1' }], + next: 'http://example.com/page2', + }; + const page2 = { + results: [{ id: 'logo2', name: 'Logo 2' }], + next: null, + }; + + api.getLogos + .mockResolvedValueOnce(page1) + .mockResolvedValueOnce(page2); + + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchLogosInBackground(); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.backgroundLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledTimes(2); + expect(api.getLogos).toHaveBeenCalledWith({ page: 1, page_size: 200 }); + expect(api.getLogos).toHaveBeenCalledWith({ page: 2, page_size: 200 }); + }); + + it('should handle errors gracefully without throwing', async () => { + const mockError = new Error('Network error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchLogosInBackground(); + }); + + expect(result.current.backgroundLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError); + + consoleSpy.mockRestore(); + }); + }); + + describe('backgroundLoadAllLogos', () => { + it('should not start if already loading', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ backgroundLoading: true }); + }); + + await act(async () => { + await result.current.backgroundLoadAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should not start if already loaded', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedAll: true }); + }); + + await act(async () => { + await result.current.backgroundLoadAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should load logos in background asynchronously', async () => { + vi.useFakeTimers(); + + const mockLogos = Array.from({ length: 2500 }, (_, i) => ({ + id: `logo${i}`, + name: `Logo ${i}`, + })); + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + // Start background loading + result.current.backgroundLoadAllLogos(); + + // Advance timers to execute setTimeout + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.hasLoadedAll).toBe(true); + expect(result.current.backgroundLoading).toBe(false); + expect(Object.keys(result.current.logos).length).toBe(2500); + + vi.useRealTimers(); + }); + + it('should handle errors gracefully', async () => { + vi.useFakeTimers(); + + const mockError = new Error('Fetch error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + result.current.backgroundLoadAllLogos(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.backgroundLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError); + + consoleSpy.mockRestore(); + + vi.useRealTimers(); + }); + }); + + describe('backgroundLoadChannelLogos', () => { + it('should not start if already loading', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ backgroundLoading: true }); + }); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should not start if already loaded', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedChannelLogos: true }); + }); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should not start if channelLogos already has many items', async () => { + const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]); + const channelLogosObj = Object.fromEntries(channelLogos); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ channelLogos: channelLogosObj }); + }); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should load channel logos in background', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(result.current.hasLoadedChannelLogos).toBe(true); + expect(result.current.backgroundLoading).toBe(false); + expect(Object.keys(result.current.channelLogos).length).toBe(2); + expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...'); + expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos'); + + consoleSpy.mockRestore(); + }); + + it('should handle errors gracefully', async () => { + const mockError = new Error('Fetch error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(result.current.backgroundLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError); + + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + }); + + describe('startBackgroundLoading', () => { + it('should start background loading after delay', async () => { + vi.useFakeTimers(); + + const mockLogos = [{ id: 'logo1', name: 'Logo 1' }]; + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + result.current.startBackgroundLoading(); + + // Advance timer by 3 seconds + await act(async () => { + vi.advanceTimersByTime(3000); + await vi.runAllTimersAsync(); + }); + + expect(result.current.hasLoadedAll).toBe(true); + + vi.useRealTimers(); + }); + + it('should handle errors in background loading', async () => { + vi.useFakeTimers(); + + const mockError = new Error('Background error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + result.current.startBackgroundLoading(); + + await act(async () => { + vi.advanceTimersByTime(3000); + await vi.runAllTimersAsync(); + }); + + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + + vi.useRealTimers(); + }); + }); + + describe('helper methods', () => { + describe('getLogoById', () => { + it('should return logo if it exists', () => { + const logo = { id: 'logo1', name: 'Logo 1' }; + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: logo } }); + }); + + expect(result.current.getLogoById('logo1')).toEqual(logo); + }); + + it('should return null if logo does not exist', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.getLogoById('nonexistent')).toBeNull(); + }); + }); + + describe('hasLogo', () => { + it('should return true if logo exists', () => { + const logo = { id: 'logo1', name: 'Logo 1' }; + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: logo } }); + }); + + expect(result.current.hasLogo('logo1')).toBe(true); + }); + + it('should return false if logo does not exist', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.hasLogo('nonexistent')).toBe(false); + }); + }); + + describe('getLogosCount', () => { + it('should return correct count of logos', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + logos: { + logo1: { id: 'logo1' }, + logo2: { id: 'logo2' }, + logo3: { id: 'logo3' }, + }, + }); + }); + + expect(result.current.getLogosCount()).toBe(3); + }); + + it('should return 0 for empty logos', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.getLogosCount()).toBe(0); + }); + }); + + describe('needsAllLogos', () => { + it('should return true if hasLoadedAll is false', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.needsAllLogos()).toBe(true); + }); + + it('should return true if logos is empty', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedAll: true, logos: {} }); + }); + + expect(result.current.needsAllLogos()).toBe(true); + }); + + it('should return false if hasLoadedAll is true and logos exist', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + hasLoadedAll: true, + logos: { logo1: { id: 'logo1' } }, + }); + }); + + expect(result.current.needsAllLogos()).toBe(false); + }); + }); + }); +}); diff --git a/frontend/src/store/__tests__/playlists.test.jsx b/frontend/src/store/__tests__/playlists.test.jsx new file mode 100644 index 00000000..2d49791e --- /dev/null +++ b/frontend/src/store/__tests__/playlists.test.jsx @@ -0,0 +1,266 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import usePlaylistsStore from '../playlists'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('usePlaylistsStore', () => { + beforeEach(() => { + const { result } = renderHook(() => usePlaylistsStore()); + act(() => { + result.current.playlists = []; + result.current.profiles = {}; + result.current.refreshProgress = {}; + result.current.isLoading = false; + result.current.error = null; + result.current.profileSearchPreview = ''; + result.current.profileResult = ''; + result.current.editPlaylistId = null; + }); + vi.clearAllMocks(); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + expect(result.current.playlists).toEqual([]); + expect(result.current.profiles).toEqual({}); + expect(result.current.refreshProgress).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.profileSearchPreview).toBe(''); + expect(result.current.profileResult).toBe(''); + expect(result.current.editPlaylistId).toBe(null); + }); + + it('should set edit playlist id', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.setEditPlaylistId('playlist1'); + }); + + expect(result.current.editPlaylistId).toBe('playlist1'); + }); + + it('should fetch playlist successfully', async () => { + const mockPlaylist = { + id: 'playlist1', + name: 'Test Playlist', + profiles: ['profile1', 'profile2'], + }; + + api.getPlaylist.mockResolvedValue(mockPlaylist); + + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.playlists = [{ id: 'playlist1', name: 'Old Name' }]; + }); + + await act(async () => { + await result.current.fetchPlaylist('playlist1'); + }); + + expect(api.getPlaylist).toHaveBeenCalledWith('playlist1'); + expect(result.current.playlists).toEqual([mockPlaylist]); + expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch playlist error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + api.getPlaylist.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => usePlaylistsStore()); + + await act(async () => { + await result.current.fetchPlaylist('playlist1'); + }); + + expect(result.current.error).toBe('Failed to load playlists.'); + expect(result.current.isLoading).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should fetch playlists successfully', async () => { + const mockPlaylists = [ + { id: 'playlist1', name: 'Playlist 1', profiles: ['profile1'] }, + { id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] }, + ]; + + api.getPlaylists.mockResolvedValue(mockPlaylists); + + const { result } = renderHook(() => usePlaylistsStore()); + + await act(async () => { + await result.current.fetchPlaylists(); + }); + + expect(api.getPlaylists).toHaveBeenCalled(); + expect(result.current.playlists).toEqual(mockPlaylists); + expect(result.current.profiles).toEqual({ + playlist1: ['profile1'], + playlist2: ['profile2'], + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch playlists error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + api.getPlaylists.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => usePlaylistsStore()); + + await act(async () => { + await result.current.fetchPlaylists(); + }); + + expect(result.current.error).toBe('Failed to load playlists.'); + expect(result.current.isLoading).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should add playlist', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const newPlaylist = { + id: 'playlist1', + name: 'New Playlist', + profiles: ['profile1'], + }; + + act(() => { + result.current.addPlaylist(newPlaylist); + }); + + expect(result.current.playlists).toEqual([newPlaylist]); + expect(result.current.profiles).toEqual({ playlist1: ['profile1'] }); + }); + + it('should update playlist', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] }; + const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] }; + + act(() => { + result.current.playlists = [existingPlaylist]; + result.current.profiles = { playlist1: ['profile1'] }; + }); + + act(() => { + result.current.updatePlaylist(updatedPlaylist); + }); + + expect(result.current.playlists).toEqual([updatedPlaylist]); + expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] }); + }); + + it('should update profiles', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.profiles = { playlist1: ['profile1'] }; + }); + + act(() => { + result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']); + }); + + expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] }); + }); + + it('should remove playlists', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.playlists = [ + { id: 'playlist1', name: 'Playlist 1' }, + { id: 'playlist2', name: 'Playlist 2' }, + { id: 'playlist3', name: 'Playlist 3' }, + ]; + }); + + act(() => { + result.current.removePlaylists(['playlist1', 'playlist3']); + }); + + expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]); + }); + + it('should set refresh progress with two parameters', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const progressData = { action: 'refreshing', progress: 50 }; + + act(() => { + result.current.setRefreshProgress('account1', progressData); + }); + + expect(result.current.refreshProgress).toEqual({ account1: progressData }); + }); + + it('should set refresh progress with WebSocket data', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const wsData = { account: 'account1', action: 'refreshing', progress: 50 }; + + act(() => { + result.current.setRefreshProgress(wsData); + }); + + expect(result.current.refreshProgress).toEqual({ account1: wsData }); + }); + + it('should preserve initializing status until real progress', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.refreshProgress = { + account1: { action: 'initializing', progress: 0 }, + }; + }); + + act(() => { + result.current.setRefreshProgress({ account: 'account1', progress: 0 }); + }); + + expect(result.current.refreshProgress.account1.action).toBe('initializing'); + + act(() => { + result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 }); + }); + + expect(result.current.refreshProgress.account1.action).toBe('refreshing'); + }); + + it('should remove refresh progress', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.refreshProgress = { + account1: { action: 'refreshing', progress: 50 }, + account2: { action: 'refreshing', progress: 75 }, + }; + }); + + act(() => { + result.current.removeRefreshProgress('account1'); + }); + + expect(result.current.refreshProgress).toEqual({ + account2: { action: 'refreshing', progress: 75 }, + }); + }); + + it('should set profile preview', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.setProfilePreview('search text', 'result data'); + }); + + expect(result.current.profileSearchPreview).toBe('search text'); + expect(result.current.profileResult).toBe('result data'); + }); +}); diff --git a/frontend/src/store/__tests__/plugins.test.jsx b/frontend/src/store/__tests__/plugins.test.jsx new file mode 100644 index 00000000..2db48095 --- /dev/null +++ b/frontend/src/store/__tests__/plugins.test.jsx @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { usePluginStore } from '../plugins'; +import API from '../../api'; + +vi.mock('../../api'); + +describe('usePluginStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + + usePluginStore.setState({ + plugins: [], + loading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => usePluginStore()); + + expect(result.current.plugins).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch plugins successfully', async () => { + const mockPlugins = [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ]; + + API.getPlugins.mockResolvedValue(mockPlugins); + + const { result } = renderHook(() => usePluginStore()); + + await act(async () => { + await result.current.fetchPlugins(); + }); + + expect(API.getPlugins).toHaveBeenCalled(); + expect(result.current.plugins).toEqual(mockPlugins); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch plugins with empty response', async () => { + API.getPlugins.mockResolvedValue(null); + + const { result } = renderHook(() => usePluginStore()); + + await act(async () => { + await result.current.fetchPlugins(); + }); + + expect(result.current.plugins).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch plugins error', async () => { + const mockError = new Error('Network error'); + API.getPlugins.mockRejectedValue(mockError); + + const { result } = renderHook(() => usePluginStore()); + + await act(async () => { + await result.current.fetchPlugins(); + }); + + expect(result.current.error).toEqual(mockError); + expect(result.current.loading).toBe(false); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + API.getPlugins.mockReturnValue(promise); + + const { result } = renderHook(() => usePluginStore()); + + // Start the fetch without awaiting + act(() => { + result.current.fetchPlugins(); + }); + + // Check loading is true synchronously + expect(result.current.loading).toBe(true); + + // Resolve the promise and wait for state update + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.loading).toBe(false); + }); + + it('should update plugin', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: false }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ], + }); + }); + + act(() => { + result.current.updatePlugin('plugin1', { enabled: true }); + }); + + expect(result.current.plugins).toEqual([ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ]); + }); + + it('should not modify other plugins when updating', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: false }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ], + }); + }); + + act(() => { + result.current.updatePlugin('plugin1', { name: 'Updated Plugin' }); + }); + + expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false }); + }); + + it('should add plugin', () => { + const { result } = renderHook(() => usePluginStore()); + const newPlugin = { key: 'plugin1', name: 'New Plugin', enabled: true }; + + act(() => { + result.current.addPlugin(newPlugin); + }); + + expect(result.current.plugins).toEqual([newPlugin]); + }); + + it('should add plugin to existing plugins', () => { + const { result } = renderHook(() => usePluginStore()); + const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true }; + const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false }; + + act(() => { + usePluginStore.setState({ plugins: [existingPlugin] }); + }); + + act(() => { + result.current.addPlugin(newPlugin); + }); + + expect(result.current.plugins).toEqual([existingPlugin, newPlugin]); + }); + + it('should remove plugin', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ], + }); + }); + + act(() => { + result.current.removePlugin('plugin1'); + }); + + expect(result.current.plugins).toEqual([ + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ]); + }); + + it('should handle removing non-existent plugin', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + ], + }); + }); + + act(() => { + result.current.removePlugin('nonexistent'); + }); + + expect(result.current.plugins).toEqual([ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + ]); + }); + + it('should invalidate plugins and refetch', async () => { + const mockPlugins = [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + ]; + + API.getPlugins.mockResolvedValue(mockPlugins); + + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'old-plugin', name: 'Old Plugin', enabled: false }, + ], + }); + }); + + await act(async () => { + await result.current.invalidatePlugins(); + }); + + expect(result.current.plugins).toEqual(mockPlugins); + expect(API.getPlugins).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx new file mode 100644 index 00000000..59da4437 --- /dev/null +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -0,0 +1,204 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useSettingsStore from '../settings'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useSettingsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ + settings: {}, + environment: { + public_ip: '', + country_code: '', + country_name: '', + env_mode: 'prod', + }, + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useSettingsStore()); + + expect(result.current.settings).toEqual({}); + expect(result.current.environment).toEqual({ + public_ip: '', + country_code: '', + country_name: '', + env_mode: 'prod', + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch settings successfully', async () => { + const mockSettings = [ + { key: 'setting1', value: 'value1' }, + { key: 'setting2', value: 'value2' }, + ]; + const mockEnv = { + public_ip: '192.168.1.1', + country_code: 'US', + country_name: 'United States', + env_mode: 'dev', + }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getSettings).toHaveBeenCalled(); + expect(api.getEnvironmentSettings).toHaveBeenCalled(); + expect(result.current.settings).toEqual({ + setting1: { key: 'setting1', value: 'value1' }, + setting2: { key: 'setting2', value: 'value2' }, + }); + expect(result.current.environment).toEqual(mockEnv); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle null environment response', async () => { + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(null); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(result.current.environment).toEqual({ + public_ip: '', + country_code: '', + country_name: '', + env_mode: 'prod', + }); + }); + + it('should handle fetch settings error', async () => { + const mockError = new Error('Network error'); + api.getSettings.mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(result.current.error).toBe('Failed to load settings.'); + expect(result.current.isLoading).toBe(false); + }); + + it('should set loading state during fetch', async () => { + let resolveSettingsPromise; + let resolveEnvPromise; + const settingsPromise = new Promise((resolve) => { + resolveSettingsPromise = resolve; + }); + const envPromise = new Promise((resolve) => { + resolveEnvPromise = resolve; + }); + + api.getSettings.mockReturnValue(settingsPromise); + api.getEnvironmentSettings.mockReturnValue(envPromise); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.fetchSettings(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolveSettingsPromise([]); + resolveEnvPromise({}); + await settingsPromise; + await envPromise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should update setting', () => { + useSettingsStore.setState({ + settings: { + setting1: { key: 'setting1', value: 'old_value' }, + setting2: { key: 'setting2', value: 'value2' }, + }, + }); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting({ key: 'setting1', value: 'new_value' }); + }); + + expect(result.current.settings).toEqual({ + setting1: { key: 'setting1', value: 'new_value' }, + setting2: { key: 'setting2', value: 'value2' }, + }); + }); + + it('should add new setting when updating non-existent key', () => { + useSettingsStore.setState({ + settings: { + setting1: { key: 'setting1', value: 'value1' }, + }, + }); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting({ key: 'setting2', value: 'new_value' }); + }); + + expect(result.current.settings).toEqual({ + setting1: { key: 'setting1', value: 'value1' }, + setting2: { key: 'setting2', value: 'new_value' }, + }); + }); + + it('should not modify other settings when updating', () => { + useSettingsStore.setState({ + settings: { + setting1: { key: 'setting1', value: 'value1' }, + setting2: { key: 'setting2', value: 'value2' }, + }, + }); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting({ key: 'setting1', value: 'updated' }); + }); + + expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' }); + }); + + it('should handle empty settings array', async () => { + api.getSettings.mockResolvedValue([]); + api.getEnvironmentSettings.mockResolvedValue({}); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(result.current.settings).toEqual({}); + }); +}); diff --git a/frontend/src/store/__tests__/streamProfiles.test.jsx b/frontend/src/store/__tests__/streamProfiles.test.jsx new file mode 100644 index 00000000..30380664 --- /dev/null +++ b/frontend/src/store/__tests__/streamProfiles.test.jsx @@ -0,0 +1,265 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useStreamProfilesStore from '../streamProfiles'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useStreamProfilesStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useStreamProfilesStore.setState({ + profiles: [], + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamProfilesStore()); + + expect(result.current.profiles).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch profiles successfully', async () => { + const mockProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]; + + api.getStreamProfiles.mockResolvedValue(mockProfiles); + + const { result } = renderHook(() => useStreamProfilesStore()); + + await act(async () => { + await result.current.fetchProfiles(); + }); + + expect(api.getStreamProfiles).toHaveBeenCalled(); + expect(result.current.profiles).toEqual(mockProfiles); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch profiles error', async () => { + const mockError = new Error('Network error'); + api.getStreamProfiles.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useStreamProfilesStore()); + + await act(async () => { + await result.current.fetchProfiles(); + }); + + expect(result.current.error).toBe('Failed to load profiles.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getStreamProfiles.mockReturnValue(promise); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.fetchProfiles(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add stream profile', () => { + useStreamProfilesStore.setState({ + profiles: [{ id: 1, name: 'Profile 1', bitrate: 5000 }], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const newProfile = { id: 2, name: 'Profile 2', bitrate: 8000 }; + + act(() => { + result.current.addStreamProfile(newProfile); + }); + + expect(result.current.profiles).toEqual([ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]); + }); + + it('should add stream profile to empty profiles', () => { + const { result } = renderHook(() => useStreamProfilesStore()); + const newProfile = { id: 1, name: 'Profile 1', bitrate: 5000 }; + + act(() => { + result.current.addStreamProfile(newProfile); + }); + + expect(result.current.profiles).toEqual([newProfile]); + }); + + it('should update stream profile', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 }; + + act(() => { + result.current.updateStreamProfile(updatedProfile); + }); + + expect(result.current.profiles).toEqual([ + { id: 1, name: 'Updated Profile', bitrate: 10000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]); + }); + + it('should not modify other profiles when updating', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 }; + + act(() => { + result.current.updateStreamProfile(updatedProfile); + }); + + expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 }); + }); + + it('should not modify profiles when updating non-existent profile', () => { + const initialProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]; + + useStreamProfilesStore.setState({ + profiles: initialProfiles, + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 }; + + act(() => { + result.current.updateStreamProfile(nonExistentProfile); + }); + + expect(result.current.profiles).toEqual(initialProfiles); + }); + + it('should remove single stream profile', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + { id: 3, name: 'Profile 3', bitrate: 10000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([2]); + }); + + expect(result.current.profiles).toEqual([ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 3, name: 'Profile 3', bitrate: 10000 }, + ]); + }); + + it('should remove multiple stream profiles', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + { id: 3, name: 'Profile 3', bitrate: 10000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([1, 3]); + }); + + expect(result.current.profiles).toEqual([ + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]); + }); + + it('should handle removing non-existent profiles', () => { + const initialProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]; + + useStreamProfilesStore.setState({ + profiles: initialProfiles, + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([999]); + }); + + expect(result.current.profiles).toEqual(initialProfiles); + }); + + it('should handle removing from empty profiles', () => { + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([1, 2]); + }); + + expect(result.current.profiles).toEqual([]); + }); + + it('should handle empty array when removing profiles', () => { + const initialProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + ]; + + useStreamProfilesStore.setState({ + profiles: initialProfiles, + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([]); + }); + + expect(result.current.profiles).toEqual(initialProfiles); + }); +}); diff --git a/frontend/src/store/__tests__/streams.test.jsx b/frontend/src/store/__tests__/streams.test.jsx new file mode 100644 index 00000000..e6ed3952 --- /dev/null +++ b/frontend/src/store/__tests__/streams.test.jsx @@ -0,0 +1,289 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useStreamsStore from '../streams'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useStreamsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useStreamsStore.setState({ + streams: [], + count: 0, + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamsStore()); + + expect(result.current.streams).toEqual([]); + expect(result.current.count).toBe(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch streams successfully', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ], + count: 2, + }; + + api.getStreams.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useStreamsStore()); + + await act(async () => { + await result.current.fetchStreams(); + }); + + expect(api.getStreams).toHaveBeenCalled(); + expect(result.current.streams).toEqual(mockResponse.results); + expect(result.current.count).toBe(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch streams error', async () => { + const mockError = new Error('Network error'); + api.getStreams.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useStreamsStore()); + + await act(async () => { + await result.current.fetchStreams(); + }); + + expect(result.current.error).toBe('Failed to load streams.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getStreams.mockReturnValue(promise); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.fetchStreams(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise({ results: [], count: 0 }); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add stream', () => { + useStreamsStore.setState({ + streams: [{ id: 1, name: 'Stream 1', url: 'http://example.com/1' }], + }); + + const { result } = renderHook(() => useStreamsStore()); + const newStream = { id: 2, name: 'Stream 2', url: 'http://example.com/2' }; + + act(() => { + result.current.addStream(newStream); + }); + + expect(result.current.streams).toEqual([ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]); + }); + + it('should add stream to empty streams', () => { + const { result } = renderHook(() => useStreamsStore()); + const newStream = { id: 1, name: 'Stream 1', url: 'http://example.com/1' }; + + act(() => { + result.current.addStream(newStream); + }); + + expect(result.current.streams).toEqual([newStream]); + }); + + it('should update stream', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }; + + act(() => { + result.current.updateStream(updatedStream); + }); + + expect(result.current.streams).toEqual([ + { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]); + }); + + it('should not modify other streams when updating', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }; + + act(() => { + result.current.updateStream(updatedStream); + }); + + expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' }); + }); + + it('should not modify streams when updating non-existent stream', () => { + const initialStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]; + + useStreamsStore.setState({ + streams: initialStreams, + }); + + const { result } = renderHook(() => useStreamsStore()); + const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' }; + + act(() => { + result.current.updateStream(nonExistentStream); + }); + + expect(result.current.streams).toEqual(initialStreams); + }); + + it('should remove single stream', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + { id: 3, name: 'Stream 3', url: 'http://example.com/3' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([2]); + }); + + expect(result.current.streams).toEqual([ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 3, name: 'Stream 3', url: 'http://example.com/3' }, + ]); + }); + + it('should remove multiple streams', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + { id: 3, name: 'Stream 3', url: 'http://example.com/3' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([1, 3]); + }); + + expect(result.current.streams).toEqual([ + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]); + }); + + it('should handle removing non-existent streams', () => { + const initialStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]; + + useStreamsStore.setState({ + streams: initialStreams, + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([999]); + }); + + expect(result.current.streams).toEqual(initialStreams); + }); + + it('should handle removing from empty streams', () => { + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([1, 2]); + }); + + expect(result.current.streams).toEqual([]); + }); + + it('should handle empty array when removing streams', () => { + const initialStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + ]; + + useStreamsStore.setState({ + streams: initialStreams, + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([]); + }); + + expect(result.current.streams).toEqual(initialStreams); + }); + + it('should handle fetch with empty results', async () => { + const mockResponse = { + results: [], + count: 0, + }; + + api.getStreams.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useStreamsStore()); + + await act(async () => { + await result.current.fetchStreams(); + }); + + expect(result.current.streams).toEqual([]); + expect(result.current.count).toBe(0); + }); +}); diff --git a/frontend/src/store/__tests__/useVODStore.test.jsx b/frontend/src/store/__tests__/useVODStore.test.jsx new file mode 100644 index 00000000..5734e5a2 --- /dev/null +++ b/frontend/src/store/__tests__/useVODStore.test.jsx @@ -0,0 +1,755 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useVODStore from '../useVODStore'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useVODStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useVODStore.setState({ + content: {}, + currentPageContent: [], + episodes: {}, + categories: {}, + loading: false, + error: null, + filters: { + type: 'all', + search: '', + category: '', + }, + currentPage: 1, + totalCount: 0, + pageSize: 24, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useVODStore()); + + expect(result.current.content).toEqual({}); + expect(result.current.currentPageContent).toEqual([]); + expect(result.current.episodes).toEqual({}); + expect(result.current.categories).toEqual({}); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.filters).toEqual({ + type: 'all', + search: '', + category: '', + }); + expect(result.current.currentPage).toBe(1); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageSize).toBe(24); + }); + + it('should set filters and reset to first page', () => { + useVODStore.setState({ currentPage: 5 }); + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ search: 'test', category: 'action' }); + }); + + expect(result.current.filters).toEqual({ + type: 'all', + search: 'test', + category: 'action', + }); + expect(result.current.currentPage).toBe(1); + }); + + it('should set page', () => { + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setPage(3); + }); + + expect(result.current.currentPage).toBe(3); + }); + + it('should set page size and reset to first page', () => { + useVODStore.setState({ currentPage: 3 }); + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setPageSize(50); + }); + + expect(result.current.pageSize).toBe(50); + expect(result.current.currentPage).toBe(1); + }); + + it('should fetch all content successfully', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Movie 1', content_type: 'movie' }, + { id: 2, name: 'Series 1', content_type: 'series' }, + ], + count: 2, + }; + + api.getAllContent.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getAllContent).toHaveBeenCalled(); + expect(result.current.currentPageContent).toEqual([ + { id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' }, + { id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' }, + ]); + expect(result.current.totalCount).toBe(2); + expect(result.current.loading).toBe(false); + }); + + it('should fetch only movies when filter type is movies', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Movie 1' }, + { id: 2, name: 'Movie 2' }, + ], + count: 2, + }; + + api.getMovies.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ type: 'movies' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getMovies).toHaveBeenCalled(); + expect(result.current.currentPageContent).toEqual([ + { id: 1, name: 'Movie 1', contentType: 'movie' }, + { id: 2, name: 'Movie 2', contentType: 'movie' }, + ]); + expect(result.current.totalCount).toBe(2); + }); + + it('should fetch only series when filter type is series', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Series 1' }, + { id: 2, name: 'Series 2' }, + ], + count: 2, + }; + + api.getSeries.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ type: 'series' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getSeries).toHaveBeenCalled(); + expect(result.current.currentPageContent).toEqual([ + { id: 1, name: 'Series 1', contentType: 'series' }, + { id: 2, name: 'Series 2', contentType: 'series' }, + ]); + expect(result.current.totalCount).toBe(2); + }); + + it('should handle fetch content error', async () => { + const mockError = new Error('Network error'); + api.getAllContent.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(result.current.error).toBe('Failed to load content.'); + expect(result.current.loading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should handle invalid response format', async () => { + api.getAllContent.mockResolvedValue({ results: 'not-an-array' }); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(result.current.error).toBe('Failed to load content.'); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch movie details successfully', async () => { + const mockResponse = { + id: 1, + name: 'Test Movie', + description: 'A test movie', + year: 2023, + url: 'http://example.com/movie.mp4', + }; + + api.getMovieDetails.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + let movieDetails; + await act(async () => { + movieDetails = await result.current.fetchMovieDetails(1); + }); + + expect(api.getMovieDetails).toHaveBeenCalledWith(1); + expect(movieDetails.id).toBe(1); + expect(movieDetails.name).toBe('Test Movie'); + expect(movieDetails.stream_url).toBe('http://example.com/movie.mp4'); + expect(result.current.content['movie_1']).toBeDefined(); + expect(result.current.content['movie_1'].contentType).toBe('movie'); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch movie details error', async () => { + const mockError = new Error('Not found'); + api.getMovieDetails.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchMovieDetails(999); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(result.current.error).toBe('Failed to load movie details.'); + expect(result.current.loading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch movie details from provider without merging to store', async () => { + const mockResponse = { + id: 1, + name: 'Provider Movie', + plot: 'From provider', + stream_url: 'http://provider.com/movie.mp4', + backdrop_path: ['path1', 'path2'], + }; + + api.getMovieProviderInfo.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + let movieDetails; + await act(async () => { + movieDetails = await result.current.fetchMovieDetailsFromProvider(1); + }); + + expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1); + expect(movieDetails.name).toBe('Provider Movie'); + expect(movieDetails.description).toBe('From provider'); + expect(movieDetails.backdrop_path).toEqual(['path1', 'path2']); + expect(result.current.content['movie_1']).toBeUndefined(); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch movie provider error', async () => { + const mockError = new Error('Provider error'); + api.getMovieProviderInfo.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchMovieDetailsFromProvider(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(result.current.error).toBe('Failed to load movie details from provider.'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch movie providers successfully', async () => { + const mockProviders = [ + { id: 1, name: 'Provider 1' }, + { id: 2, name: 'Provider 2' }, + ]; + + api.getMovieProviders.mockResolvedValue(mockProviders); + + const { result } = renderHook(() => useVODStore()); + + let providers; + await act(async () => { + providers = await result.current.fetchMovieProviders(1); + }); + + expect(api.getMovieProviders).toHaveBeenCalledWith(1); + expect(providers).toEqual(mockProviders); + }); + + it('should handle fetch movie providers error', async () => { + const mockError = new Error('Providers error'); + api.getMovieProviders.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchMovieProviders(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch series providers successfully', async () => { + const mockProviders = [{ id: 1, name: 'Series Provider 1' }]; + + api.getSeriesProviders.mockResolvedValue(mockProviders); + + const { result } = renderHook(() => useVODStore()); + + let providers; + await act(async () => { + providers = await result.current.fetchSeriesProviders(1); + }); + + expect(api.getSeriesProviders).toHaveBeenCalledWith(1); + expect(providers).toEqual(mockProviders); + }); + + it('should handle fetch series providers error', async () => { + const mockError = new Error('Series providers error'); + api.getSeriesProviders.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchSeriesProviders(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch series info successfully', async () => { + const mockResponse = { + id: 1, + name: 'Test Series', + description: 'A test series', + year: 2023, + cover: 'http://example.com/cover.jpg', + episodes: { + 1: [ + { + id: 101, + title: 'Episode 1', + episode_number: 1, + plot: 'First episode', + }, + { + id: 102, + title: 'Episode 2', + episode_number: 2, + plot: 'Second episode', + }, + ], + }, + }; + + api.getSeriesInfo.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + let seriesInfo; + await act(async () => { + seriesInfo = await result.current.fetchSeriesInfo(1); + }); + + expect(api.getSeriesInfo).toHaveBeenCalledWith(1); + expect(seriesInfo.id).toBe(1); + expect(seriesInfo.name).toBe('Test Series'); + expect(seriesInfo.episodesList).toHaveLength(2); + expect(result.current.content['series_1']).toBeDefined(); + expect(result.current.content['series_1'].contentType).toBe('series'); + expect(result.current.episodes[101]).toBeDefined(); + expect(result.current.episodes[102]).toBeDefined(); + expect(result.current.episodes[101].name).toBe('Episode 1'); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch series info error', async () => { + const mockError = new Error('Series not found'); + api.getSeriesInfo.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchSeriesInfo(999); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(result.current.error).toBe('Failed to load series details.'); + expect(result.current.loading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch categories successfully with array response', async () => { + const mockCategories = [ + { id: 1, name: 'Action' }, + { id: 2, name: 'Comedy' }, + ]; + + api.getVODCategories.mockResolvedValue(mockCategories); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchCategories(); + }); + + expect(api.getVODCategories).toHaveBeenCalled(); + expect(result.current.categories).toEqual({ + 1: { id: 1, name: 'Action' }, + 2: { id: 2, name: 'Comedy' }, + }); + }); + + it('should fetch categories successfully with paginated response', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Drama' }, + { id: 2, name: 'Thriller' }, + ], + }; + + api.getVODCategories.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchCategories(); + }); + + expect(result.current.categories).toEqual({ + 1: { id: 1, name: 'Drama' }, + 2: { id: 2, name: 'Thriller' }, + }); + }); + + it('should handle fetch categories error', async () => { + const mockError = new Error('Categories error'); + api.getVODCategories.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchCategories(); + }); + + expect(result.current.error).toBe('Failed to load categories.'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should add movie to content', () => { + const { result } = renderHook(() => useVODStore()); + const movie = { id: 1, name: 'New Movie' }; + + act(() => { + result.current.addMovie(movie); + }); + + expect(result.current.content['movie_1']).toEqual({ + id: 1, + name: 'New Movie', + contentType: 'movie', + }); + }); + + it('should update movie in content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Old Movie', contentType: 'movie' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const updatedMovie = { id: 1, name: 'Updated Movie' }; + + act(() => { + result.current.updateMovie(updatedMovie); + }); + + expect(result.current.content['movie_1']).toEqual({ + id: 1, + name: 'Updated Movie', + contentType: 'movie', + }); + }); + + it('should remove movie from content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie to Remove', contentType: 'movie' }, + movie_2: { id: 2, name: 'Movie to Keep', contentType: 'movie' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.removeMovie(1); + }); + + expect(result.current.content['movie_1']).toBeUndefined(); + expect(result.current.content['movie_2']).toBeDefined(); + }); + + it('should add series to content', () => { + const { result } = renderHook(() => useVODStore()); + const series = { id: 1, name: 'New Series' }; + + act(() => { + result.current.addSeries(series); + }); + + expect(result.current.content['series_1']).toEqual({ + id: 1, + name: 'New Series', + contentType: 'series', + }); + }); + + it('should update series in content', () => { + useVODStore.setState({ + content: { + series_1: { id: 1, name: 'Old Series', contentType: 'series' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const updatedSeries = { id: 1, name: 'Updated Series' }; + + act(() => { + result.current.updateSeries(updatedSeries); + }); + + expect(result.current.content['series_1']).toEqual({ + id: 1, + name: 'Updated Series', + contentType: 'series', + }); + }); + + it('should remove series from content', () => { + useVODStore.setState({ + content: { + series_1: { id: 1, name: 'Series to Remove', contentType: 'series' }, + series_2: { id: 2, name: 'Series to Keep', contentType: 'series' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.removeSeries(1); + }); + + expect(result.current.content['series_1']).toBeUndefined(); + expect(result.current.content['series_2']).toBeDefined(); + }); + + it('should get filtered content from current page', () => { + const mockContent = [ + { id: 1, name: 'Movie 1', contentType: 'movie' }, + { id: 2, name: 'Series 1', contentType: 'series' }, + ]; + + useVODStore.setState({ + currentPageContent: mockContent, + }); + + const { result } = renderHook(() => useVODStore()); + const filtered = result.current.getFilteredContent(); + + expect(filtered).toEqual(mockContent); + }); + + it('should get only movies from content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' }, + series_1: { id: 2, name: 'Series 1', contentType: 'series' }, + movie_2: { id: 3, name: 'Movie 2', contentType: 'movie' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const movies = result.current.getMovies(); + + expect(movies).toHaveLength(2); + expect(movies.every((item) => item.contentType === 'movie')).toBe(true); + }); + + it('should get only series from content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' }, + series_1: { id: 2, name: 'Series 1', contentType: 'series' }, + series_2: { id: 3, name: 'Series 2', contentType: 'series' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const series = result.current.getSeries(); + + expect(series).toHaveLength(2); + expect(series.every((item) => item.contentType === 'series')).toBe(true); + }); + + it('should clear all content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' }, + series_1: { id: 2, name: 'Series 1', contentType: 'series' }, + }, + totalCount: 2, + }); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.clearContent(); + }); + + expect(result.current.content).toEqual({}); + expect(result.current.totalCount).toBe(0); + }); + + it('should handle fetch content with search filter', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Searched Movie', content_type: 'movie' }], + count: 1, + }; + + api.getAllContent.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ search: 'Searched' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getAllContent).toHaveBeenCalled(); + const callArgs = api.getAllContent.mock.calls[0][0]; + expect(callArgs.get('search')).toBe('Searched'); + }); + + it('should handle fetch content with category filter', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Action Movie', content_type: 'movie' }], + count: 1, + }; + + api.getAllContent.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ category: 'action' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + const callArgs = api.getAllContent.mock.calls[0][0]; + expect(callArgs.get('category')).toBe('action'); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getAllContent.mockReturnValue(promise); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.fetchContent(); + }); + + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise({ results: [], count: 0 }); + await promise; + }); + + expect(result.current.loading).toBe(false); + }); +}); diff --git a/frontend/src/store/__tests__/useVideoStore.test.jsx b/frontend/src/store/__tests__/useVideoStore.test.jsx new file mode 100644 index 00000000..b1f87d6d --- /dev/null +++ b/frontend/src/store/__tests__/useVideoStore.test.jsx @@ -0,0 +1,182 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import useVideoStore from '../useVideoStore'; + +describe('useVideoStore', () => { + beforeEach(() => { + useVideoStore.setState({ + isVisible: false, + streamUrl: null, + contentType: 'live', + metadata: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useVideoStore()); + + expect(result.current.isVisible).toBe(false); + expect(result.current.streamUrl).toBe(null); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should show video with live stream', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/stream.ts'; + + act(() => { + result.current.showVideo(streamUrl); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should show video with VOD content', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/video.mp4'; + const metadata = { title: 'Test Video', duration: 120 }; + + act(() => { + result.current.showVideo(streamUrl, 'vod', metadata); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('vod'); + expect(result.current.metadata).toEqual(metadata); + }); + + it('should show video with custom content type', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/video.mkv'; + + act(() => { + result.current.showVideo(streamUrl, 'vod'); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('vod'); + expect(result.current.metadata).toBe(null); + }); + + it('should hide video and reset state', () => { + const { result } = renderHook(() => useVideoStore()); + + act(() => { + result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' }); + }); + + expect(result.current.isVisible).toBe(true); + + act(() => { + result.current.hideVideo(); + }); + + expect(result.current.isVisible).toBe(false); + expect(result.current.streamUrl).toBe(null); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should update stream when showing different video', () => { + const { result } = renderHook(() => useVideoStore()); + const firstUrl = 'http://example.com/stream1.ts'; + const secondUrl = 'http://example.com/stream2.ts'; + + act(() => { + result.current.showVideo(firstUrl); + }); + + expect(result.current.streamUrl).toBe(firstUrl); + + act(() => { + result.current.showVideo(secondUrl); + }); + + expect(result.current.streamUrl).toBe(secondUrl); + expect(result.current.isVisible).toBe(true); + }); + + it('should handle showing video with null metadata explicitly', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/stream.ts'; + + act(() => { + result.current.showVideo(streamUrl, 'live', null); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should preserve metadata when showing VOD content', () => { + const { result } = renderHook(() => useVideoStore()); + const metadata = { + title: 'Test Video', + duration: 120, + thumbnailUrl: 'http://example.com/thumb.jpg' + }; + + act(() => { + result.current.showVideo('http://example.com/video.mp4', 'vod', metadata); + }); + + expect(result.current.metadata).toEqual(metadata); + expect(result.current.metadata.title).toBe('Test Video'); + expect(result.current.metadata.duration).toBe(120); + }); + + it('should override previous metadata when showing new video', () => { + const { result } = renderHook(() => useVideoStore()); + const firstMetadata = { title: 'First Video' }; + const secondMetadata = { title: 'Second Video' }; + + act(() => { + result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata); + }); + + expect(result.current.metadata).toEqual(firstMetadata); + + act(() => { + result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata); + }); + + expect(result.current.metadata).toEqual(secondMetadata); + }); + + it('should handle hiding video when already hidden', () => { + const { result } = renderHook(() => useVideoStore()); + + expect(result.current.isVisible).toBe(false); + + act(() => { + result.current.hideVideo(); + }); + + expect(result.current.isVisible).toBe(false); + expect(result.current.streamUrl).toBe(null); + }); + + it('should handle showing video multiple times consecutively', () => { + const { result } = renderHook(() => useVideoStore()); + const url1 = 'http://example.com/stream1.ts'; + const url2 = 'http://example.com/stream2.ts'; + const url3 = 'http://example.com/stream3.ts'; + + act(() => { + result.current.showVideo(url1); + result.current.showVideo(url2); + result.current.showVideo(url3); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(url3); + }); +}); diff --git a/frontend/src/store/__tests__/userAgents.test.jsx b/frontend/src/store/__tests__/userAgents.test.jsx new file mode 100644 index 00000000..8e804bec --- /dev/null +++ b/frontend/src/store/__tests__/userAgents.test.jsx @@ -0,0 +1,277 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useUserAgentsStore from '../userAgents'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useUserAgentsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUserAgentsStore.setState({ + userAgents: [], + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useUserAgentsStore()); + + expect(result.current.userAgents).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch user agents successfully', async () => { + const mockUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]; + + api.getUserAgents.mockResolvedValue(mockUserAgents); + + const { result } = renderHook(() => useUserAgentsStore()); + + await act(async () => { + await result.current.fetchUserAgents(); + }); + + expect(api.getUserAgents).toHaveBeenCalled(); + expect(result.current.userAgents).toEqual(mockUserAgents); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch user agents error', async () => { + const mockError = new Error('Network error'); + api.getUserAgents.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useUserAgentsStore()); + + await act(async () => { + await result.current.fetchUserAgents(); + }); + + expect(result.current.error).toBe('Failed to load userAgents.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getUserAgents.mockReturnValue(promise); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.fetchUserAgents(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add user agent', () => { + useUserAgentsStore.setState({ + userAgents: [{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const newUserAgent = { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }; + + act(() => { + result.current.addUserAgent(newUserAgent); + }); + + expect(result.current.userAgents).toEqual([ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should add user agent to empty user agents', () => { + const { result } = renderHook(() => useUserAgentsStore()); + const newUserAgent = { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }; + + act(() => { + result.current.addUserAgent(newUserAgent); + }); + + expect(result.current.userAgents).toEqual([newUserAgent]); + }); + + it('should update user agent', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }; + + act(() => { + result.current.updateUserAgent(updatedUserAgent); + }); + + expect(result.current.userAgents).toEqual([ + { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should not modify other user agents when updating', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }; + + act(() => { + result.current.updateUserAgent(updatedUserAgent); + }); + + expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }); + }); + + it('should not modify user agents when updating non-existent user agent', () => { + const initialUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]; + + useUserAgentsStore.setState({ + userAgents: initialUserAgents, + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' }; + + act(() => { + result.current.updateUserAgent(nonExistentUserAgent); + }); + + expect(result.current.userAgents).toEqual(initialUserAgents); + }); + + it('should remove single user agent', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + { id: 3, name: 'Safari', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([2]); + }); + + expect(result.current.userAgents).toEqual([ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 3, name: 'Safari', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should remove multiple user agents', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + { id: 3, name: 'Safari', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([1, 3]); + }); + + expect(result.current.userAgents).toEqual([ + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should handle removing non-existent user agents', () => { + const initialUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]; + + useUserAgentsStore.setState({ + userAgents: initialUserAgents, + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([999]); + }); + + expect(result.current.userAgents).toEqual(initialUserAgents); + }); + + it('should handle removing from empty user agents', () => { + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([1, 2]); + }); + + expect(result.current.userAgents).toEqual([]); + }); + + it('should handle empty array when removing user agents', () => { + const initialUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + ]; + + useUserAgentsStore.setState({ + userAgents: initialUserAgents, + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([]); + }); + + expect(result.current.userAgents).toEqual(initialUserAgents); + }); + + it('should handle fetch with empty results', async () => { + api.getUserAgents.mockResolvedValue([]); + + const { result } = renderHook(() => useUserAgentsStore()); + + await act(async () => { + await result.current.fetchUserAgents(); + }); + + expect(result.current.userAgents).toEqual([]); + }); +}); diff --git a/frontend/src/store/__tests__/users.test.jsx b/frontend/src/store/__tests__/users.test.jsx new file mode 100644 index 00000000..58b343ba --- /dev/null +++ b/frontend/src/store/__tests__/users.test.jsx @@ -0,0 +1,258 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useUsersStore from '../users'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useUsersStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUsersStore.setState({ + users: [], + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useUsersStore()); + + expect(result.current.users).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch users successfully', async () => { + const mockUsers = [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]; + + api.getUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useUsersStore()); + + await act(async () => { + await result.current.fetchUsers(); + }); + + expect(api.getUsers).toHaveBeenCalled(); + expect(result.current.users).toEqual(mockUsers); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch users error', async () => { + const mockError = new Error('Network error'); + api.getUsers.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useUsersStore()); + + await act(async () => { + await result.current.fetchUsers(); + }); + + expect(result.current.error).toBe('Failed to load users.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getUsers.mockReturnValue(promise); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.fetchUsers(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add user', () => { + useUsersStore.setState({ + users: [{ id: 1, name: 'User 1', email: 'user1@example.com' }], + }); + + const { result } = renderHook(() => useUsersStore()); + const newUser = { id: 2, name: 'User 2', email: 'user2@example.com' }; + + act(() => { + result.current.addUser(newUser); + }); + + expect(result.current.users).toEqual([ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]); + }); + + it('should add user to empty users', () => { + const { result } = renderHook(() => useUsersStore()); + const newUser = { id: 1, name: 'User 1', email: 'user1@example.com' }; + + act(() => { + result.current.addUser(newUser); + }); + + expect(result.current.users).toEqual([newUser]); + }); + + it('should update user', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' }; + + act(() => { + result.current.updateUser(updatedUser); + }); + + expect(result.current.users).toEqual([ + { id: 1, name: 'Updated User', email: 'updated@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]); + }); + + it('should not modify other users when updating', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' }; + + act(() => { + result.current.updateUser(updatedUser); + }); + + expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' }); + }); + + it('should not modify users when updating non-existent user', () => { + const initialUsers = [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]; + + useUsersStore.setState({ + users: initialUsers, + }); + + const { result } = renderHook(() => useUsersStore()); + const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' }; + + act(() => { + result.current.updateUser(nonExistentUser); + }); + + expect(result.current.users).toEqual(initialUsers); + }); + + it('should remove user', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + { id: 3, name: 'User 3', email: 'user3@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(2); + }); + + expect(result.current.users).toEqual([ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 3, name: 'User 3', email: 'user3@example.com' }, + ]); + }); + + it('should handle removing non-existent user', () => { + const initialUsers = [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]; + + useUsersStore.setState({ + users: initialUsers, + }); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(999); + }); + + expect(result.current.users).toEqual(initialUsers); + }); + + it('should handle removing from empty users', () => { + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(1); + }); + + expect(result.current.users).toEqual([]); + }); + + it('should handle fetch with empty results', async () => { + api.getUsers.mockResolvedValue([]); + + const { result } = renderHook(() => useUsersStore()); + + await act(async () => { + await result.current.fetchUsers(); + }); + + expect(result.current.users).toEqual([]); + }); + + it('should not modify other users when removing', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + { id: 3, name: 'User 3', email: 'user3@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(2); + }); + + expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' }); + expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' }); + }); +}); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx new file mode 100644 index 00000000..6ac3cc44 --- /dev/null +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -0,0 +1,480 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useVODLogosStore from '../vodLogos'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useVODLogosStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useVODLogosStore.setState({ + vodLogos: {}, + logos: [], + isLoading: false, + hasLoaded: false, + error: null, + totalCount: 0, + currentPage: 1, + pageSize: 25, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useVODLogosStore()); + + expect(result.current.vodLogos).toEqual({}); + expect(result.current.logos).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLoaded).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.totalCount).toBe(0); + expect(result.current.currentPage).toBe(1); + expect(result.current.pageSize).toBe(25); + }); + + it('should set VOD logos with normalized structure', () => { + const { result } = renderHook(() => useVODLogosStore()); + const mockLogos = [ + { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + ]; + + act(() => { + result.current.setVODLogos(mockLogos, 2); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + 2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + }); + expect(result.current.totalCount).toBe(2); + expect(result.current.hasLoaded).toBe(true); + }); + + it('should fetch VOD logos successfully with array response', async () => { + const mockLogos = [ + { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + ]; + + api.getVODLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos(); + }); + + expect(api.getVODLogos).toHaveBeenCalled(); + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + 2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + }); + expect(result.current.logos).toEqual(mockLogos); + expect(result.current.totalCount).toBe(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLoaded).toBe(true); + }); + + it('should fetch VOD logos successfully with paginated response', async () => { + const mockLogos = [ + { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + ]; + const mockResponse = { + results: mockLogos, + count: 10, + }; + + api.getVODLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos({ page: 1, page_size: 25 }); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + }); + expect(result.current.logos).toEqual(mockLogos); + expect(result.current.totalCount).toBe(10); + }); + + it('should handle fetch VOD logos error', async () => { + const mockError = new Error('Network error'); + api.getVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.fetchVODLogos(); + } catch (error) { + // Expected error + } + }); + + expect(result.current.error).toBe('Failed to load VOD logos.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getVODLogos.mockReturnValue(promise); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.fetchVODLogos(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should remove single VOD logo from state', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + 3: { id: 3, name: 'Logo 3' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + { id: 3, name: 'Logo 3' }, + ], + totalCount: 3, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.removeVODLogo(2); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1' }, + 3: { id: 3, name: 'Logo 3' }, + }); + expect(result.current.logos).toEqual([ + { id: 1, name: 'Logo 1' }, + { id: 3, name: 'Logo 3' }, + ]); + expect(result.current.totalCount).toBe(2); + }); + + it('should remove multiple VOD logos from state using _removeLogosFromState', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + 3: { id: 3, name: 'Logo 3' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + { id: 3, name: 'Logo 3' }, + ], + totalCount: 3, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current._removeLogosFromState([1, 3]); + }); + + expect(result.current.vodLogos).toEqual({ + 2: { id: 2, name: 'Logo 2' }, + }); + expect(result.current.logos).toEqual([ + { id: 2, name: 'Logo 2' }, + ]); + expect(result.current.totalCount).toBe(1); + }); + + it('should delete single VOD logo successfully', async () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + ], + totalCount: 2, + }); + + api.deleteVODLogo.mockResolvedValue({}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.deleteVODLogo(1); + }); + + expect(api.deleteVODLogo).toHaveBeenCalledWith(1); + expect(result.current.vodLogos).toEqual({ + 2: { id: 2, name: 'Logo 2' }, + }); + expect(result.current.logos).toEqual([ + { id: 2, name: 'Logo 2' }, + ]); + expect(result.current.totalCount).toBe(1); + }); + + it('should handle delete VOD logo error', async () => { + const mockError = new Error('Delete failed'); + api.deleteVODLogo.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.deleteVODLogo(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should delete multiple VOD logos successfully', async () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + 3: { id: 3, name: 'Logo 3' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + { id: 3, name: 'Logo 3' }, + ], + totalCount: 3, + }); + + api.deleteVODLogos.mockResolvedValue({}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.deleteVODLogos([1, 2]); + }); + + expect(api.deleteVODLogos).toHaveBeenCalledWith([1, 2]); + expect(result.current.vodLogos).toEqual({ + 3: { id: 3, name: 'Logo 3' }, + }); + expect(result.current.logos).toEqual([ + { id: 3, name: 'Logo 3' }, + ]); + expect(result.current.totalCount).toBe(1); + }); + + it('should handle delete multiple VOD logos error', async () => { + const mockError = new Error('Bulk delete failed'); + api.deleteVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.deleteVODLogos([1, 2]); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should cleanup unused VOD logos and refresh', async () => { + useVODLogosStore.setState({ + currentPage: 2, + pageSize: 10, + }); + + const mockCleanupResult = { deleted: 5 }; + const mockRefreshedLogos = [{ id: 1, name: 'Logo 1' }]; + + api.cleanupUnusedVODLogos.mockResolvedValue(mockCleanupResult); + api.getVODLogos.mockResolvedValue(mockRefreshedLogos); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + const cleanupResult = await result.current.cleanupUnusedVODLogos(); + expect(cleanupResult).toEqual(mockCleanupResult); + }); + + expect(api.cleanupUnusedVODLogos).toHaveBeenCalled(); + expect(api.getVODLogos).toHaveBeenCalledWith({ + page: 2, + page_size: 10, + }); + expect(result.current.logos).toEqual(mockRefreshedLogos); + }); + + it('should handle cleanup unused VOD logos error', async () => { + const mockError = new Error('Cleanup failed'); + api.cleanupUnusedVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.cleanupUnusedVODLogos(); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should clear VOD logos', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + hasLoaded: true, + totalCount: 1, + error: 'Some error', + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.clearVODLogos(); + }); + + expect(result.current.vodLogos).toEqual({}); + expect(result.current.logos).toEqual([]); + expect(result.current.hasLoaded).toBe(false); + expect(result.current.totalCount).toBe(0); + expect(result.current.error).toBe(null); + }); + + it('should handle removing non-existent logo', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + totalCount: 1, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.removeVODLogo(999); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1' }, + }); + expect(result.current.logos).toEqual([{ id: 1, name: 'Logo 1' }]); + expect(result.current.totalCount).toBe(1); + }); + + it('should handle fetch with empty response', async () => { + api.getVODLogos.mockResolvedValue([]); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos(); + }); + + expect(result.current.vodLogos).toEqual({}); + expect(result.current.logos).toEqual([]); + expect(result.current.totalCount).toBe(0); + }); + + it('should not allow totalCount to go below zero', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + totalCount: 1, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current._removeLogosFromState([1, 2, 3]); // Remove more than exist + }); + + expect(result.current.totalCount).toBe(0); + }); + + it('should handle _removeLogosFromState with empty array', () => { + const initialState = { + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + totalCount: 1, + }; + + useVODLogosStore.setState(initialState); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current._removeLogosFromState([]); + }); + + expect(result.current.vodLogos).toEqual(initialState.vodLogos); + expect(result.current.logos).toEqual(initialState.logos); + expect(result.current.totalCount).toBe(1); + }); + + it('should fetch with custom params', async () => { + const mockLogos = [{ id: 1, name: 'Logo 1' }]; + api.getVODLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos({ search: 'test', page: 2 }); + }); + + expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 }); + }); +}); diff --git a/frontend/src/store/__tests__/warnings.test.jsx b/frontend/src/store/__tests__/warnings.test.jsx new file mode 100644 index 00000000..500fbd8c --- /dev/null +++ b/frontend/src/store/__tests__/warnings.test.jsx @@ -0,0 +1,195 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import useWarningsStore from '../warnings'; + +describe('useWarningsStore', () => { + beforeEach(() => { + useWarningsStore.setState({ + suppressedWarnings: {}, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.suppressedWarnings).toEqual({}); + }); + + it('should suppress a warning', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + }); + }); + + it('should suppress multiple warnings', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + deleteProfile: true, + }); + }); + + it('should suppress warning with explicit true value', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream', true); + }); + + expect(result.current.suppressedWarnings.deleteStream).toBe(true); + }); + + it('should unsuppress a warning with explicit false value', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.suppressedWarnings.deleteStream).toBe(true); + + act(() => { + result.current.suppressWarning('deleteStream', false); + }); + + expect(result.current.suppressedWarnings.deleteStream).toBe(false); + }); + + it('should check if warning is suppressed', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(false); + + act(() => { + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(true); + }); + + it('should return false for non-existent warning', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.isWarningSuppressed('nonExistentAction')).toBe(false); + }); + + it('should reset all suppressions', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + result.current.suppressWarning('deleteUser'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + deleteProfile: true, + deleteUser: true, + }); + + act(() => { + result.current.resetSuppressions(); + }); + + expect(result.current.suppressedWarnings).toEqual({}); + }); + + it('should maintain other suppressions when adding new one', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + deleteProfile: true, + }); + }); + + it('should handle resetting when already empty', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.suppressedWarnings).toEqual({}); + + act(() => { + result.current.resetSuppressions(); + }); + + expect(result.current.suppressedWarnings).toEqual({}); + }); + + it('should handle checking suppression after unsuppressing', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream', true); + }); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(true); + + act(() => { + result.current.suppressWarning('deleteStream', false); + }); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(false); + }); + + it('should preserve other warnings when unsuppressing one', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + }); + + act(() => { + result.current.suppressWarning('deleteStream', false); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: false, + deleteProfile: true, + }); + }); + + it('should handle suppressing same warning multiple times', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + }); + }); + + it('should handle different action keys independently', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('action1'); + result.current.suppressWarning('action2', false); + }); + + expect(result.current.isWarningSuppressed('action1')).toBe(true); + expect(result.current.isWarningSuppressed('action2')).toBe(false); + expect(result.current.isWarningSuppressed('action3')).toBe(false); + }); +}); From cf1625fab91568beb3cadcdd95f852c530c18a4f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:41:25 -0800 Subject: [PATCH 69/75] Slight refactoring --- frontend/src/hooks/useLocalStorage.jsx | 2 +- frontend/src/store/auth.jsx | 17 +- frontend/src/store/channels.jsx | 196 ++++++++++---------- frontend/src/store/channelsTable.jsx | 3 - frontend/src/store/epgs.jsx | 55 +++--- frontend/src/store/logos.jsx | 33 ++-- frontend/src/store/useVODStore.jsx | 240 +++++++++++++------------ frontend/src/store/vodLogos.jsx | 61 +++---- 8 files changed, 312 insertions(+), 295 deletions(-) diff --git a/frontend/src/hooks/useLocalStorage.jsx b/frontend/src/hooks/useLocalStorage.jsx index 450c55df..d559d960 100644 --- a/frontend/src/hooks/useLocalStorage.jsx +++ b/frontend/src/hooks/useLocalStorage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; const useLocalStorage = (key, defaultValue) => { const localKey = key; diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 8fe943b7..e97e7e53 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import api from '../api'; import useSettingsStore from './settings'; import useChannelsStore from './channels'; import usePlaylistsStore from './playlists'; @@ -82,20 +81,16 @@ const useAuthStore = create((set, get) => ({ getToken: async () => { const tokenExpiration = localStorage.getItem('tokenExpiration'); - let accessToken = null; - if (isTokenExpired(tokenExpiration)) { - accessToken = await get().getRefreshToken(); - } else { - accessToken = localStorage.getItem('accessToken'); - } - return accessToken; + return isTokenExpired(tokenExpiration) + ? await get().getRefreshToken() + : localStorage.getItem('accessToken'); }, // Action to login login: async ({ username, password }) => { try { - const response = await api.login(username, password); + const response = await API.login(username, password); if (response.access) { const expiration = decodeToken(response.access); set({ @@ -121,8 +116,8 @@ const useAuthStore = create((set, get) => ({ if (!refreshToken) return false; // Add explicit return here try { - const data = await api.refreshToken(refreshToken); - if (data && data.access) { + const data = await API.refreshToken(refreshToken); + if (data?.access) { set({ accessToken: data.access, tokenExpiration: decodeToken(data.access), diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 9fb958b2..a95959b2 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -1,9 +1,91 @@ import { create } from 'zustand'; import api from '../api'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../utils/notificationUtils.js'; const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } }; +const reduceChannels = (channels) => { + const channelsByUUID = {}; + const channelsByID = channels.reduce((acc, channel) => { + acc[channel.id] = channel; + channelsByUUID[channel.uuid] = channel.id; + return acc; + }, {}); + return { channelsByUUID, channelsByID }; +} + +const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => { + if (currentStats.channels) { + if (oldChannels[ch.channel_id] === undefined) { + // Add null checks to prevent accessing properties on undefined + const channelId = channelsByUUID[ch.channel_id]; + const channel = channelId ? channels[channelId] : null; + + if (channel) { + showNotification({ + title: 'New channel streaming', + message: channel.name, + color: 'blue.5', + }); + } + } + } +} + +const showNotificationIfNewClient = (currentStats, oldClients, client) => { + // This check prevents the notifications if streams are active on page load + if (currentStats.channels) { + if (oldClients[client.client_id] === undefined) { + showNotification({ + title: 'New client started streaming', + message: `Client streaming from ${client.ip_address}`, + color: 'blue.5', + }); + } + } +} + +const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => { + // This check prevents the notifications if streams are active on page load + if (currentStats.channels) { + for (const uuid in oldChannels) { + if (newChannels[uuid] === undefined) { + // Add null check for channel name + const channelId = channelsByUUID[uuid]; + const channel = channelId && channels[channelId]; + + if (channel) { + showNotification({ + title: 'Channel streaming stopped', + message: channel.name, + color: 'blue.5', + }); + } else { + showNotification({ + title: 'Channel streaming stopped', + message: `Channel (${uuid})`, + color: 'blue.5', + }); + } + } + } + } +} + +const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => { + if (currentStats.channels) { + for (const clientId in oldClients) { + if (newClients[clientId] === undefined) { + showNotification({ + title: 'Client stopped streaming', + message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, + color: 'blue.5', + }); + } + } + } +} + const useChannelsStore = create((set, get) => ({ channels: [], channelsByUUID: {}, @@ -28,12 +110,7 @@ const useChannelsStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const channels = await api.getChannels(); - const channelsByUUID = {}; - const channelsByID = channels.reduce((acc, channel) => { - acc[channel.id] = channel; - channelsByUUID[channel.uuid] = channel.id; - return acc; - }, {}); + const { channelsByUUID, channelsByID } = reduceChannels(channels); set({ channels: channelsByID, channelsByUUID, @@ -113,16 +190,7 @@ const useChannelsStore = create((set, get) => ({ addChannels: (newChannels) => set((state) => { - const channelsByUUID = {}; - const profileChannels = new Set(); - - const channelsByID = newChannels.reduce((acc, channel) => { - acc[channel.id] = channel; - channelsByUUID[channel.uuid] = channel.id; - profileChannels.add(channel.id); - - return acc; - }, {}); + const { channelsByUUID, channelsByID } = reduceChannels(newChannels); // Don't automatically add to all profiles anymore - let the backend handle profile assignments // Just maintain the existing profile structure @@ -160,12 +228,8 @@ const useChannelsStore = create((set, get) => ({ ); return; } - const channelsByUUID = {}; - const updatedChannels = channels.reduce((acc, chan) => { - channelsByUUID[chan.uuid] = chan.id; - acc[chan.id] = chan; - return acc; - }, {}); + + const { channelsByUUID, updatedChannels } = reduceChannels(channels); set((state) => ({ channels: { @@ -249,12 +313,9 @@ const useChannelsStore = create((set, get) => ({ delete updatedProfiles[id]; } - let additionalUpdates = {}; - if (profileIds.includes(state.selectedProfileId)) { - additionalUpdates = { - selectedProfileId: '0', - }; - } + const additionalUpdates = profileIds.includes(state.selectedProfileId) + ? { selectedProfileId: '0' } + : {}; return { profiles: updatedProfiles, @@ -296,14 +357,12 @@ const useChannelsStore = create((set, get) => ({ channels: currentChannelsSet, }; - const updates = { + return { profiles: { ...state.profiles, [profileId]: updatedProfile, }, }; - - return updates; }), setChannelsPageSelection: (channelsPageSelection) => @@ -324,71 +383,24 @@ const useChannelsStore = create((set, get) => ({ channelsByUUID, } = state; const newClients = {}; + const newChannels = stats.channels.reduce((acc, ch) => { acc[ch.channel_id] = ch; - if (currentStats.channels) { - if (oldChannels[ch.channel_id] === undefined) { - // Add null checks to prevent accessing properties on undefined - const channelId = channelsByUUID[ch.channel_id]; - const channel = channelId ? channels[channelId] : null; - - if (channel) { - notifications.show({ - title: 'New channel streaming', - message: channel.name, - color: 'blue.5', - }); - } - } - } - ch.clients.map((client) => { - newClients[client.client_id] = client; - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - if (oldClients[client.client_id] === undefined) { - notifications.show({ - title: 'New client started streaming', - message: `Client streaming from ${client.ip_address}`, - color: 'blue.5', - }); - } - } - }); return acc; }, {}); - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - for (const uuid in oldChannels) { - if (newChannels[uuid] === undefined) { - // Add null check for channel name - const channelId = channelsByUUID[uuid]; - const channel = channelId && channels[channelId]; - if (channel) { - notifications.show({ - title: 'Channel streaming stopped', - message: channel.name, - color: 'blue.5', - }); - } else { - notifications.show({ - title: 'Channel streaming stopped', - message: `Channel (${uuid})`, - color: 'blue.5', - }); - } - } - } - for (const clientId in oldClients) { - if (newClients[clientId] === undefined) { - notifications.show({ - title: 'Client stopped streaming', - message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, - color: 'blue.5', - }); - } - } - } + stats.channels.forEach(ch => { + showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels); + + ch.clients.forEach(client => { + newClients[client.client_id] = client; + showNotificationIfNewClient(currentStats, oldClients, client); + }); + }); + + showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels); + showNotificationIfClientStopped(currentStats, oldClients, newClients); + return { stats, activeChannels: newChannels, diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index 75e14c63..c205a9be 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -1,7 +1,4 @@ import { create } from 'zustand'; -import api from '../api'; -import { notifications } from '@mantine/notifications'; -import API from '../api'; const useChannelsTableStore = create((set, get) => ({ channels: [], diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index 1760bc45..e8f5e2ad 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -1,6 +1,14 @@ import { create } from 'zustand'; import api from '../api'; +const determineEPGStatus = (data, currentEpg) => { + if (data.status) return data.status; + if (data.action === 'downloading') return 'fetching'; + if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing'; + if (data.progress === 100) return 'success'; + return currentEpg?.status || 'idle'; +}; + const useEPGsStore = create((set) => ({ epgs: {}, tvgs: [], @@ -92,7 +100,7 @@ const useEPGsStore = create((set) => ({ } // Create a new refreshProgress object that includes the current update - const newRefreshProgress = { + const refreshProgress = { ...state.refreshProgress, [data.source]: { action: data.action, @@ -106,45 +114,30 @@ const useEPGsStore = create((set) => ({ // Set the EPG source status based on the update // First prioritize explicit status values from the backend - const sourceStatus = data.status - ? data.status // Use explicit status if provided - : data.action === 'downloading' - ? 'fetching' - : data.action === 'parsing_channels' || - data.action === 'parsing_programs' - ? 'parsing' - : data.progress === 100 - ? 'success' // Mark as success when progress is 100% - : state.epgs[data.source]?.status || 'idle'; + const status = determineEPGStatus(data, state.epgs[data.source]); // Only update epgs object if status or last_message actually changed // This prevents unnecessary re-renders on every progress update - const currentEpg = state.epgs[data.source]; - const newLastMessage = - data.status === 'error' - ? data.error || 'Unknown error' - : currentEpg?.last_message; + const lastMessage = data.status === 'error' + ? (data.error || 'Unknown error') + : state.epgs[data.source]?.last_message; - let newEpgs = state.epgs; - if ( - currentEpg && - (currentEpg.status !== sourceStatus || - currentEpg.last_message !== newLastMessage) - ) { - newEpgs = { + const currentEpg = state.epgs[data.source]; + const shouldUpdateEpg = currentEpg && + (currentEpg.status !== status || currentEpg.last_message !== lastMessage); + + const epgs = shouldUpdateEpg + ? { ...state.epgs, [data.source]: { ...currentEpg, - status: sourceStatus, - last_message: newLastMessage, + status, + last_message: lastMessage, }, - }; - } + } + : state.epgs; - return { - refreshProgress: newRefreshProgress, - epgs: newEpgs, - }; + return { refreshProgress, epgs }; }), })); diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 5843b113..5e09cf02 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -1,6 +1,12 @@ import { create } from 'zustand'; import api from '../api'; +const getLogosArray = (response) => { + return Array.isArray(response) + ? response + : response.results || []; +} + const useLogosStore = create((set, get) => ({ logos: {}, channelLogos: {}, // Separate cache for channel forms to avoid reloading @@ -24,13 +30,12 @@ const useLogosStore = create((set, get) => ({ // Add to channelLogos if the user has loaded channel logos // This means they're using channel forms and the new logo should be available there - let newChannelLogos = state.channelLogos; - if (state.hasLoadedChannelLogos) { - newChannelLogos = { - ...state.channelLogos, - [newLogo.id]: { ...newLogo }, - }; - } + const newChannelLogos = state.hasLoadedChannelLogos + ? { + ...state.channelLogos, + [newLogo.id]: { ...newLogo }, + } + : state.channelLogos; return { logos: newLogos, @@ -75,7 +80,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogos({ page_size: pageSize }); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set({ logos: logos.reduce((acc, logo) => { @@ -109,9 +114,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogos({ no_pagination: 'true' }); // Handle both paginated and non-paginated responses - const logosArray = Array.isArray(response) - ? response - : response.results || []; + const logosArray = getLogosArray(response); set({ logos: logosArray.reduce((acc, logo) => { @@ -139,7 +142,7 @@ const useLogosStore = create((set, get) => ({ }); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set((state) => ({ logos: { @@ -190,7 +193,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogosByIds(missingIds); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set((state) => ({ logos: { @@ -262,9 +265,7 @@ const useLogosStore = create((set, get) => ({ try { // Use the API directly to avoid interfering with the main isLoading state const response = await api.getLogos({ no_pagination: 'true' }); - const logosArray = Array.isArray(response) - ? response - : response.results || []; + const logosArray = getLogosArray(response); // Process logos in smaller chunks to avoid blocking the main thread const chunkSize = 1000; diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index 43edb1c9..defbfe17 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -1,6 +1,124 @@ import { create } from 'zustand'; import api from '../api'; +const getFetchContentParams = (state) => { + const params = new URLSearchParams(); + params.append('page', state.currentPage); + params.append('page_size', state.pageSize); + + if (state.filters.search) { + params.append('search', state.filters.search); + } + + if (state.filters.category) { + params.append('category', state.filters.category); + } + return params; +} + +const getMovieDetails = (response, movieId) => { + return { + id: response.id || movieId, + name: response.name || '', + description: response.description || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration_secs: response.duration_secs || null, + stream_url: response.url || '', + logo: response.logo_url || null, + type: 'movie', + director: response.director || '', + actors: response.actors || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + imdb_id: response.imdb_id || '', + m3u_account: response.m3u_account || '', + }; +} + +const getMovieDetailsWithProvider = (response, movieId) => { + return { + id: response.id || movieId, + name: response.name || '', + description: response.description || response.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration_secs: response.duration_secs || null, + stream_url: response.stream_url || '', + logo: response.logo || response.cover || null, + type: 'movie', + director: response.director || '', + actors: response.actors || response.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + youtube_trailer: response.youtube_trailer || '', + // Additional provider fields + backdrop_path: response.backdrop_path || [], + release_date: response.release_date || response.releasedate || '', + movie_image: response.movie_image || null, + o_name: response.o_name || '', + age: response.age || '', + episode_run_time: response.episode_run_time || null, + bitrate: response.bitrate || 0, + video: response.video || {}, + audio: response.audio || {}, + }; +} + +const getSeriesDetails = (response, seriesId) => { + return { + id: response.id || seriesId, + name: response.name || '', + description: response.description || response.custom_properties?.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + logo: response.cover || null, + type: 'series', + director: response.custom_properties?.director || '', + cast: response.custom_properties?.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + imdb_id: response.imdb_id || '', + episode_count: response.episode_count || 0, + // Additional provider fields + backdrop_path: response.custom_properties?.backdrop_path || [], + release_date: response.release_date || '', + series_image: response.series_image || null, + o_name: response.o_name || '', + age: response.age || '', + m3u_account: response.m3u_account || '', + youtube_trailer: response.custom_properties?.youtube_trailer || '', + }; +} + +const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => { + return { + id: episode.id, + stream_id: episode.id, + name: episode.title || '', + description: episode.plot || '', + season_number: parseInt(seasonNumber) || 0, + episode_number: episode.episode_number || 0, + duration_secs: episode.duration_secs || null, + rating: episode.rating || '', + container_extension: episode.container_extension || '', + series: { + id: seriesInfo.id, + name: seriesInfo.name, + }, + type: 'episode', + uuid: episode.uuid, + logo: episode.movie_image ? { url: episode.movie_image } : null, + air_date: episode.air_date || null, + movie_image: episode.movie_image || null, + tmdb_id: episode.tmdb_id || '', + imdb_id: episode.imdb_id || '', + }; +} + const useVODStore = create((set, get) => ({ content: {}, // Store for individual content details (when fetching movie/series details) currentPageContent: [], // Store the current page's results @@ -39,17 +157,7 @@ const useVODStore = create((set, get) => ({ set({ loading: true, error: null }); const state = get(); - const params = new URLSearchParams(); - params.append('page', state.currentPage); - params.append('page_size', state.pageSize); - - if (state.filters.search) { - params.append('search', state.filters.search); - } - - if (state.filters.category) { - params.append('category', state.filters.category); - } + const params = getFetchContentParams(state); let allResults = []; let totalCount = 0; @@ -58,12 +166,14 @@ const useVODStore = create((set, get) => ({ // Fetch only movies const response = await api.getMovies(params); const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'movie' })); totalCount = response.count || results.length; } else if (state.filters.type === 'series') { // Fetch only series const response = await api.getSeries(params); const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'series', @@ -73,16 +183,9 @@ const useVODStore = create((set, get) => ({ // Use the new unified backend endpoint for 'all' view const response = await api.getAllContent(params); console.log('getAllContent response:', response); - console.log('response type:', typeof response); - console.log( - 'response keys:', - response ? Object.keys(response) : 'no response' - ); const results = response.results || response; console.log('results:', results); - console.log('results type:', typeof results); - console.log('results is array:', Array.isArray(results)); // Check if results is actually an array before calling map if (!Array.isArray(results)) { @@ -140,24 +243,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getMovieDetails(movieId); // Transform the response data to match our expected format - const movieDetails = { - id: response.id || movieId, - name: response.name || '', - description: response.description || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - duration_secs: response.duration_secs || null, - stream_url: response.url || '', - logo: response.logo_url || null, - type: 'movie', - director: response.director || '', - actors: response.actors || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - imdb_id: response.imdb_id || '', - m3u_account: response.m3u_account || '', - }; + const movieDetails = getMovieDetails(response, movieId); console.log('Fetched Movie Details:', movieDetails); set((state) => ({ content: { @@ -184,33 +270,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getMovieProviderInfo(movieId); // Transform the response data to match our expected format - const movieDetails = { - id: response.id || movieId, - name: response.name || '', - description: response.description || response.plot || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - duration_secs: response.duration_secs || null, - stream_url: response.stream_url || '', - logo: response.logo || response.cover || null, - type: 'movie', - director: response.director || '', - actors: response.actors || response.cast || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - youtube_trailer: response.youtube_trailer || '', - // Additional provider fields - backdrop_path: response.backdrop_path || [], - release_date: response.release_date || response.releasedate || '', - movie_image: response.movie_image || null, - o_name: response.o_name || '', - age: response.age || '', - episode_run_time: response.episode_run_time || null, - bitrate: response.bitrate || 0, - video: response.video || {}, - audio: response.audio || {}, - }; + const movieDetails = getMovieDetailsWithProvider(response, movieId); set({ loading: false }); // Only update loading state @@ -316,31 +376,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getSeriesInfo(seriesId); // Transform the response data to match our expected format - const seriesInfo = { - id: response.id || seriesId, - name: response.name || '', - description: - response.description || response.custom_properties?.plot || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - logo: response.cover || null, - type: 'series', - director: response.custom_properties?.director || '', - cast: response.custom_properties?.cast || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - imdb_id: response.imdb_id || '', - episode_count: response.episode_count || 0, - // Additional provider fields - backdrop_path: response.custom_properties?.backdrop_path || [], - release_date: response.release_date || '', - series_image: response.series_image || null, - o_name: response.o_name || '', - age: response.age || '', - m3u_account: response.m3u_account || '', - youtube_trailer: response.custom_properties?.youtube_trailer || '', - }; + const seriesInfo = getSeriesDetails(response, seriesId); let episodesData = {}; @@ -349,29 +385,11 @@ const useVODStore = create((set, get) => ({ Object.entries(response.episodes).forEach( ([seasonNumber, seasonEpisodes]) => { seasonEpisodes.forEach((episode) => { - const episodeData = { - id: episode.id, - stream_id: episode.id, - name: episode.title || '', - description: episode.plot || '', - season_number: parseInt(seasonNumber) || 0, - episode_number: episode.episode_number || 0, - duration_secs: episode.duration_secs || null, - rating: episode.rating || '', - container_extension: episode.container_extension || '', - series: { - id: seriesInfo.id, - name: seriesInfo.name, - }, - type: 'episode', - uuid: episode.uuid, - logo: episode.movie_image ? { url: episode.movie_image } : null, - air_date: episode.air_date || null, - movie_image: episode.movie_image || null, - tmdb_id: episode.tmdb_id || '', - imdb_id: episode.imdb_id || '', - }; - episodesData[episode.id] = episodeData; + episodesData[episode.id] = getEpisodeDetails( + episode, + seasonNumber, + seriesInfo + ); }); } ); diff --git a/frontend/src/store/vodLogos.jsx b/frontend/src/store/vodLogos.jsx index 4df2dd17..34a30f2a 100644 --- a/frontend/src/store/vodLogos.jsx +++ b/frontend/src/store/vodLogos.jsx @@ -11,6 +11,29 @@ const useVODLogosStore = create((set) => ({ currentPage: 1, pageSize: 25, + _removeLogosFromState: (logoIds) => { + set((state) => { + const newVODLogos = { ...state.vodLogos }; + const logoIdSet = new Set(Array.isArray(logoIds) ? logoIds : [logoIds]); + + let removedCount = 0; + logoIdSet.forEach((id) => { + if (newVODLogos[id]) { + delete newVODLogos[id]; + removedCount++; + } + }); + + const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id)); + + return { + vodLogos: newVODLogos, + logos: newLogos, + totalCount: Math.max(0, state.totalCount - removedCount), + }; + }); + }, + setVODLogos: (logos, totalCount = 0) => { set({ vodLogos: logos.reduce((acc, logo) => { @@ -22,15 +45,10 @@ const useVODLogosStore = create((set) => ({ }); }, - removeVODLogo: (logoId) => - set((state) => { - const newVODLogos = { ...state.vodLogos }; - delete newVODLogos[logoId]; - return { - vodLogos: newVODLogos, - totalCount: Math.max(0, state.totalCount - 1), - }; - }), + removeVODLogo: (logoId) => { + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoId); + }, fetchVODLogos: async (params = {}) => { set({ isLoading: true, error: null }); @@ -62,16 +80,8 @@ const useVODLogosStore = create((set) => ({ deleteVODLogo: async (logoId) => { try { await api.deleteVODLogo(logoId); - set((state) => { - const newVODLogos = { ...state.vodLogos }; - delete newVODLogos[logoId]; - const newLogos = state.logos.filter((logo) => logo.id !== logoId); - return { - vodLogos: newVODLogos, - logos: newLogos, - totalCount: Math.max(0, state.totalCount - 1), - }; - }); + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoId); } catch (error) { console.error('Failed to delete VOD logo:', error); throw error; @@ -81,17 +91,8 @@ const useVODLogosStore = create((set) => ({ deleteVODLogos: async (logoIds) => { try { await api.deleteVODLogos(logoIds); - set((state) => { - const newVODLogos = { ...state.vodLogos }; - logoIds.forEach((id) => delete newVODLogos[id]); - const logoIdSet = new Set(logoIds); - const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id)); - return { - vodLogos: newVODLogos, - logos: newLogos, - totalCount: Math.max(0, state.totalCount - logoIds.length), - }; - }); + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoIds); } catch (error) { console.error('Failed to delete VOD logos:', error); throw error; From 525ae60157ff4ea9f4de4db63e36b8313e2ad8af Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:41:50 -0800 Subject: [PATCH 70/75] Removed functionality not implemented --- .../src/components/tables/StreamsTable.jsx | 2 -- frontend/src/store/logos.jsx | 3 --- frontend/src/store/useVODStore.jsx | 24 ------------------- 3 files changed, 29 deletions(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 21b13baf..c3b2f677 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -702,8 +702,6 @@ const StreamsTable = ({ onReady }) => { channel_profile_ids: channelProfileIds, }); await API.requeryChannels(); - const fetchLogos = useChannelsStore.getState().fetchLogos; - fetchLogos(); }; // Handle confirming the single channel numbering modal diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 5e09cf02..f821c424 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -72,9 +72,6 @@ const useLogosStore = create((set, get) => ({ // Smart loading methods fetchLogos: async (pageSize = 100) => { - // Don't fetch if logo fetching is not allowed yet - if (!get().allowLogoFetching) return []; - set({ isLoading: true, error: null }); try { const response = await api.getLogos({ page_size: pageSize }); diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index defbfe17..0d8fe446 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -213,30 +213,6 @@ const useVODStore = create((set, get) => ({ } }, - fetchSeriesEpisodes: async (seriesId) => { - set({ loading: true, error: null }); - try { - const response = await api.getSeriesEpisodes(seriesId); - - set((state) => ({ - episodes: { - ...state.episodes, - ...response.reduce((acc, episode) => { - acc[episode.id] = episode; - return acc; - }, {}), - }, - loading: false, - })); - - return response; - } catch (error) { - console.error('Failed to fetch series episodes:', error); - set({ error: 'Failed to load episodes.', loading: false }); - throw error; // Re-throw to allow calling component to handle - } - }, - fetchMovieDetails: async (movieId) => { set({ loading: true, error: null }); try { From 04e2886ba53ea0655bf3587f2cd327c785323d6f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:09:01 -0800 Subject: [PATCH 71/75] Updated tests after sync --- frontend/src/store/__tests__/auth.test.jsx | 170 +++++++++++++++++- .../store/__tests__/channelsTable.test.jsx | 107 +++++++++++ .../src/store/__tests__/settings.test.jsx | 149 +++++++++++++++ .../src/store/__tests__/vodLogos.test.jsx | 70 ++++++++ 4 files changed, 487 insertions(+), 9 deletions(-) diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx index 4ae99e4b..7dc8277b 100644 --- a/frontend/src/store/__tests__/auth.test.jsx +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -40,9 +40,6 @@ const localStorageMock = (() => { global.localStorage = localStorageMock; -// Mock console methods -global.console.error = vi.fn(); - // Helper to create a mock JWT token const createMockToken = (expiresInSeconds = 3600) => { const now = Math.floor(Date.now() / 1000); @@ -161,7 +158,9 @@ describe('useAuthStore', () => { await result.current.login({ username: 'testuser', password: 'wrong' }); }); - expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + expect(API.login).toHaveBeenCalledWith('testuser', 'wrong'); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); }); }); @@ -183,7 +182,6 @@ describe('useAuthStore', () => { expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token'); expect(newToken).toBe(mockNewAccessToken); - expect(result.current.isAuthenticated).toBe(true); expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken); }); @@ -212,7 +210,6 @@ describe('useAuthStore', () => { await result.current.getRefreshToken(); }); - expect(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error)); expect(result.current.isAuthenticated).toBe(false); expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); @@ -292,7 +289,6 @@ describe('useAuthStore', () => { await result.current.logout(); }); - expect(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error)); expect(result.current.isAuthenticated).toBe(false); expect(localStorageMock.removeItem).toHaveBeenCalled(); }); @@ -435,8 +431,6 @@ describe('useAuthStore', () => { await act(async () => { await result.current.initData(); }); - - expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error)); }); }); @@ -476,4 +470,162 @@ describe('useAuthStore', () => { expect(result.current.superuserExists).toBe(false); }); }); + + describe('getRefreshToken edge cases', () => { + it('should return false if API response has no access token', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + let response; + await act(async () => { + response = await result.current.getRefreshToken(); + }); + + expect(response).toBe(false); + }); + }); + + describe('login edge cases', () => { + it('should not update state if response has no access token', async () => { + API.login.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'password' }); + }); + + expect(result.current.accessToken).toBeNull(); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('logout edge cases', () => { + it('should reset isInitializing flag on logout', async () => { + useAuthStore.setState({ isInitializing: true }); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.isInitializing).toBe(false); + }); + }); + + describe('initializeAuth edge cases', () => { + it('should return false if refresh token API call fails', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockRejectedValue(new Error('Token expired')); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + + it('should return false if refresh returns no access token', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + }); + + describe('initData edge cases', () => { + it('should skip initialization if already initialized', async () => { + useAuthStore.setState({ isInitialized: true }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).not.toHaveBeenCalled(); + }); + + it('should skip initialization if already initializing', async () => { + useAuthStore.setState({ isInitializing: true }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).not.toHaveBeenCalled(); + }); + + it('should set isInitializing to false on error', async () => { + // Reset state before the test + useAuthStore.setState({ + isInitializing: false, + isInitialized: false + }); + + API.me.mockRejectedValue(new Error('API error')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + try { + await result.current.initData(); + } catch { + // Expected error + } + }); + + expect(result.current.isInitializing).toBe(false); + expect(result.current.isInitialized).toBe(false); + }); + + it('should call fetchChannels in background after initialization', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + const fetchChannels = vi.fn().mockResolvedValue(); + useChannelsStore.getState = () => ({ + fetchChannels, + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + }); + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + // Wait for the background call to complete + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + // The background fetchChannels is called synchronously without await + // so we just need to verify it was called + expect(fetchChannels).toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx index 171f0672..0bf3e31a 100644 --- a/frontend/src/store/__tests__/channelsTable.test.jsx +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -292,4 +292,111 @@ describe('useChannelsTableStore', () => { expect(result.current.sorting).toEqual([]); }); }); + + describe('isUnlocked', () => { + it('should initialize with default false value', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + expect(result.current.isUnlocked).toBe(false); + }); + }); + + describe('setIsUnlocked', () => { + it('should update isUnlocked to true', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setIsUnlocked(true); + }); + + expect(result.current.isUnlocked).toBe(true); + }); + + it('should update isUnlocked to false', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setIsUnlocked(true); + }); + + expect(result.current.isUnlocked).toBe(true); + + act(() => { + result.current.setIsUnlocked(false); + }); + + expect(result.current.isUnlocked).toBe(false); + }); + }); + + describe('updateChannel', () => { + it('should update an existing channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Channel 2', channel_number: 2 }, + { id: 3, name: 'Channel 3', channel_number: 3 }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels).toEqual([ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Updated Channel 2', channel_number: 22 }, + { id: 3, name: 'Channel 3', channel_number: 3 }, + ]); + }); + + it('should not modify channels when updating non-existent channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Channel 2', channel_number: 2 }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels).toEqual(mockChannels); + }); + + it('should preserve other channels when updating one channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1, streams: ['stream1'] }, + { id: 2, name: 'Channel 2', channel_number: 2, streams: ['stream2'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels[0]).toEqual(updatedChannel); + expect(result.current.channels[1]).toEqual(mockChannels[1]); + }); + }); }); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx index 59da4437..58f64724 100644 --- a/frontend/src/store/__tests__/settings.test.jsx +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -16,6 +16,10 @@ describe('useSettingsStore', () => { country_name: '', env_mode: 'prod', }, + version: { + version: '', + timestamp: null, + }, isLoading: false, error: null, }); @@ -201,4 +205,149 @@ describe('useSettingsStore', () => { expect(result.current.settings).toEqual({}); }); + + it('should initialize version with default state', () => { + const { result } = renderHook(() => useSettingsStore()); + + expect(result.current.version).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should fetch version successfully', async () => { + const mockVersion = { + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }; + + api.getVersion.mockResolvedValue(mockVersion); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(api.getVersion).toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }); + expect(versionResult).toEqual({ + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }); + }); + + it('should skip fetching version if already loaded', async () => { + useSettingsStore.setState({ + version: { + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }, + }); + + api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(api.getVersion).not.toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + expect(versionResult).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + }); + + it('should handle null version response', async () => { + api.getVersion.mockResolvedValue(null); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchVersion(); + }); + + expect(result.current.version).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should handle fetch version error', async () => { + const mockError = new Error('Version fetch failed'); + api.getVersion.mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(versionResult).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should fetch version with settings when version not loaded', async () => { + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + const mockEnv = { public_ip: '192.168.1.1' }; + const mockVersion = { version: '1.0.0', timestamp: '2024-01-01T00:00:00Z' }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + api.getVersion.mockResolvedValue(mockVersion); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getVersion).toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2024-01-01T00:00:00Z', + }); + }); + + it('should skip fetching version with settings when already loaded', async () => { + useSettingsStore.setState({ + version: { + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }, + }); + + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + const mockEnv = { public_ip: '192.168.1.1' }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getVersion).not.toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + }); }); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx index 6ac3cc44..116cbf9d 100644 --- a/frontend/src/store/__tests__/vodLogos.test.jsx +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -477,4 +477,74 @@ describe('useVODLogosStore', () => { expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 }); }); + + it('should get unused logos count successfully', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Unused Logo' }], + count: 42, + }; + + api.getVODLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODLogosStore()); + + let unusedCount; + await act(async () => { + unusedCount = await result.current.getUnusedLogosCount(); + }); + + expect(api.getVODLogos).toHaveBeenCalledWith({ + used: 'false', + page_size: 1, + }); + expect(unusedCount).toBe(42); + }); + + it('should return 0 when unused logos count response has no count', async () => { + api.getVODLogos.mockResolvedValue({ results: [] }); + + const { result } = renderHook(() => useVODLogosStore()); + + let unusedCount; + await act(async () => { + unusedCount = await result.current.getUnusedLogosCount(); + }); + + expect(unusedCount).toBe(0); + }); + + it('should handle get unused logos count error', async () => { + const mockError = new Error('Failed to fetch count'); + api.getVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.getUnusedLogosCount(); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should update currentPage and pageSize state', () => { + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + useVODLogosStore.setState({ + currentPage: 3, + pageSize: 50, + }); + }); + + expect(result.current.currentPage).toBe(3); + expect(result.current.pageSize).toBe(50); + }); }); From c8430760c419bd5efa64e7d9478d3c77f9a457a7 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:15:26 -0800 Subject: [PATCH 72/75] Added tests after sync --- .../__tests__/useTablePreferences.test.jsx | 390 ++++++++++++++++++ .../src/store/__tests__/streamsTable.test.jsx | 349 ++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useTablePreferences.test.jsx create mode 100644 frontend/src/store/__tests__/streamsTable.test.jsx diff --git a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx new file mode 100644 index 00000000..2158df54 --- /dev/null +++ b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useTablePreferences from '../useTablePreferences'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +global.localStorage = localStorageMock; + +describe('useTablePreferences', () => { + let consoleErrorSpy; + + beforeEach(() => { + // Spy on console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + localStorageMock.clear(); + + // Mock window.addEventListener and removeEventListener + vi.spyOn(window, 'addEventListener'); + vi.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + describe('Initial State', () => { + it('should initialize with default values when localStorage is empty', () => { + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(false); + expect(result.current.tableSize).toBe('default'); + }); + + it('should initialize headerPinned from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should initialize tableSize from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should initialize both preferences from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should migrate tableSize from old localStorage location', () => { + localStorageMock.setItem('table-size', JSON.stringify('compact')); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should prefer new localStorage location over old location', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' })); + localStorageMock.setItem('table-size', JSON.stringify('compact')); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should handle malformed JSON in localStorage gracefully', () => { + localStorageMock.setItem('table-preferences', 'invalid json'); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(false); + expect(result.current.tableSize).toBe('default'); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('setHeaderPinned', () => { + it('should update headerPinned state', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should persist headerPinned to localStorage', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); + }); + + it('should preserve existing preferences when updating headerPinned', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ tableSize: 'compact', headerPinned: true }) + ); + }); + + it('should dispatch custom event when updating headerPinned', () => { + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'table-preferences-changed', + detail: { headerPinned: true }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('should handle localStorage errors gracefully', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving headerPinned to localStorage:', + expect.any(Error) + ); + }); + }); + + describe('setTableSize', () => { + it('should update tableSize state', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should persist tableSize to localStorage', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('comfortable'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ tableSize: 'comfortable' }) + ); + }); + + it('should preserve existing preferences when updating tableSize', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'compact' }) + ); + }); + + it('should dispatch custom event when updating tableSize', () => { + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'table-preferences-changed', + detail: { tableSize: 'compact' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('should handle localStorage errors gracefully', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving tableSize to localStorage:', + expect.any(Error) + ); + }); + }); + + describe('Event Listeners', () => { + it('should register event listener on mount', () => { + renderHook(() => useTablePreferences()); + + expect(window.addEventListener).toHaveBeenCalledWith( + 'table-preferences-changed', + expect.any(Function) + ); + }); + + it('should remove event listener on unmount', () => { + const { unmount } = renderHook(() => useTablePreferences()); + + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith( + 'table-preferences-changed', + expect.any(Function) + ); + }); + + it('should update headerPinned from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should update tableSize from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { tableSize: 'compact' }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should update both preferences from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true, tableSize: 'comfortable' }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should not update if value is the same', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + const initialHeaderPinned = result.current.headerPinned; + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(initialHeaderPinned); + }); + }); + + describe('Integration Tests', () => { + it('should handle complete workflow', () => { + const { result } = renderHook(() => useTablePreferences()); + + // Update headerPinned + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); + + // Update tableSize + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'compact' }) + ); + + // Verify both preferences are maintained + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('compact'); + }); + + it('should sync changes across multiple hook instances via events', () => { + const { result: result1 } = renderHook(() => useTablePreferences()); + const { result: result2 } = renderHook(() => useTablePreferences()); + + // Update from first instance + act(() => { + result1.current.setHeaderPinned(true); + }); + + // Second instance should receive the event + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result2.current.headerPinned).toBe(true); + }); + }); +}); diff --git a/frontend/src/store/__tests__/streamsTable.test.jsx b/frontend/src/store/__tests__/streamsTable.test.jsx new file mode 100644 index 00000000..62818107 --- /dev/null +++ b/frontend/src/store/__tests__/streamsTable.test.jsx @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useStreamsTableStore from '../streamsTable'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +globalThis.localStorage = localStorageMock; + +describe('useStreamsTableStore', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initial State', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.streams).toEqual([]); + expect(result.current.pageCount).toBe(0); + expect(result.current.totalCount).toBe(0); + expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]); + expect(result.current.pagination).toEqual({ + pageIndex: 0, + pageSize: 50, + }); + expect(result.current.selectedStreamIds).toEqual([]); + expect(result.current.allQueryIds).toEqual([]); + expect(result.current.lastQueryParams).toBeNull(); + }); + + it('should initialize pagination with localStorage value if available', () => { + // Set localStorage BEFORE getting the store state + localStorageMock.setItem('streams-page-size', JSON.stringify(100)); + + // Now update the store to re-read from localStorage + useStreamsTableStore.setState({ + pagination: { + pageIndex: 0, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, + } + }); + + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.pagination.pageSize).toBe(100); + }); + + it('should use default page size if localStorage is empty', () => { + // Ensure localStorage is empty + localStorageMock.clear(); + + useStreamsTableStore.setState({ + pagination: { + pageIndex: 0, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, + } + }); + + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.pagination.pageSize).toBe(50); + }); + }); + + describe('queryStreams', () => { + it('should update streams, totalCount, and pageCount', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockResults = [ + { id: 1, name: 'Stream 1' }, + { id: 2, name: 'Stream 2' }, + ]; + const mockCount = 100; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryStreams( + { results: mockResults, count: mockCount }, + mockParams + ); + }); + + expect(result.current.streams).toEqual(mockResults); + expect(result.current.totalCount).toBe(100); + expect(result.current.pageCount).toBe(2); // Math.ceil(100 / 50) + }); + + it('should calculate pageCount correctly with different page sizes', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryStreams( + { results: [], count: 75 }, + mockParams + ); + }); + + expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25) + }); + + it('should handle empty results', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryStreams( + { results: [], count: 0 }, + mockParams + ); + }); + + expect(result.current.streams).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageCount).toBe(0); + }); + }); + + describe('setAllQueryIds', () => { + it('should update allQueryIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockIds = [1, 2, 3, 4, 5]; + + act(() => { + result.current.setAllQueryIds(mockIds); + }); + + expect(result.current.allQueryIds).toEqual(mockIds); + }); + + it('should replace previous allQueryIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setAllQueryIds([1, 2, 3]); + }); + + act(() => { + result.current.setAllQueryIds([4, 5, 6]); + }); + + expect(result.current.allQueryIds).toEqual([4, 5, 6]); + }); + }); + + describe('setSelectedStreamIds', () => { + it('should update selectedStreamIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockIds = [1, 3, 5]; + + act(() => { + result.current.setSelectedStreamIds(mockIds); + }); + + expect(result.current.selectedStreamIds).toEqual(mockIds); + }); + + it('should handle empty selection', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setSelectedStreamIds([1, 2, 3]); + }); + + act(() => { + result.current.setSelectedStreamIds([]); + }); + + expect(result.current.selectedStreamIds).toEqual([]); + }); + }); + + describe('setPagination', () => { + it('should update pagination state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newPagination = { + pageIndex: 2, + pageSize: 100, + }; + + act(() => { + result.current.setPagination(newPagination); + }); + + expect(result.current.pagination).toEqual(newPagination); + }); + + it('should replace entire pagination object', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setPagination({ pageIndex: 1, pageSize: 25 }); + }); + + act(() => { + result.current.setPagination({ pageIndex: 3, pageSize: 75 }); + }); + + expect(result.current.pagination).toEqual({ pageIndex: 3, pageSize: 75 }); + }); + }); + + describe('setSorting', () => { + it('should update sorting state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newSorting = [{ id: 'created_at', desc: true }]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle multiple sorting columns', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newSorting = [ + { id: 'name', desc: false }, + { id: 'created_at', desc: true }, + ]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle empty sorting array', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setSorting([]); + }); + + expect(result.current.sorting).toEqual([]); + }); + }); + + describe('setLastQueryParams', () => { + it('should update lastQueryParams', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page: '1', search: 'test' }); + + act(() => { + result.current.setLastQueryParams(mockParams); + }); + + expect(result.current.lastQueryParams).toBe(mockParams); + }); + + it('should handle null value', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setLastQueryParams(new URLSearchParams()); + }); + + act(() => { + result.current.setLastQueryParams(null); + }); + + expect(result.current.lastQueryParams).toBeNull(); + }); + }); + + describe('Integration Tests', () => { + it('should handle a typical query flow', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + // Set pagination + act(() => { + result.current.setPagination({ pageIndex: 0, pageSize: 25 }); + }); + + // Set sorting + act(() => { + result.current.setSorting([{ id: 'created_at', desc: true }]); + }); + + // Query streams + const mockResults = [ + { id: 1, name: 'Stream 1' }, + { id: 2, name: 'Stream 2' }, + ]; + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryStreams( + { results: mockResults, count: 50 }, + mockParams + ); + }); + + // Set query IDs + act(() => { + result.current.setAllQueryIds([1, 2, 3, 4, 5]); + }); + + // Select streams + act(() => { + result.current.setSelectedStreamIds([1, 2]); + }); + + // Set last query params + act(() => { + result.current.setLastQueryParams(mockParams); + }); + + expect(result.current.streams).toEqual(mockResults); + expect(result.current.totalCount).toBe(50); + expect(result.current.pageCount).toBe(2); + expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 }); + expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]); + expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]); + expect(result.current.selectedStreamIds).toEqual([1, 2]); + expect(result.current.lastQueryParams).toBe(mockParams); + }); + }); +}); From 37a2a7b52b047d61908373cd2d0c388ca01e6048 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 28 Jan 2026 20:15:03 -0600 Subject: [PATCH 73/75] fix: Escape XML special characters in EPG channel IDs Fixed XML parsing errors in EPG output when channel IDs contain special characters. Changes: - Added html.escape() to apps/output/views.py where channel_id is used in XML attributes - Added unit tests in apps/output/tests.py to validate XML escaping for &, <, >, and " characters --- apps/output/tests.py | 106 +++++++++++++++++++++++++++++++++++++++++++ apps/output/views.py | 10 ++-- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/apps/output/tests.py b/apps/output/tests.py index f87c8340..b1c7d589 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -1,5 +1,8 @@ from django.test import TestCase, Client from django.urls import reverse +from apps.channels.models import Channel, ChannelGroup +from apps.epg.models import EPGData, EPGSource +import xml.etree.ElementTree as ET class OutputM3UTest(TestCase): def setUp(self): @@ -37,3 +40,106 @@ class OutputM3UTest(TestCase): self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) + + +class OutputEPGXMLEscapingTest(TestCase): + """Test XML escaping of channel_id attributes in EPG generation""" + + def setUp(self): + self.client = Client() + self.group = ChannelGroup.objects.create(name="Test Group") + + def test_channel_id_with_ampersand(self): + """Test channel ID with ampersand is properly escaped""" + channel = Channel.objects.create( + channel_number=1.0, + name="Test Channel", + tvg_id="News & Sports", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # Should contain escaped ampersand + self.assertIn('id="News & Sports"', content) + self.assertNotIn('id="News & Sports"', content) + + # Verify XML is parseable + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG is not valid XML: {e}") + + def test_channel_id_with_angle_brackets(self): + """Test channel ID with < and > characters""" + channel = Channel.objects.create( + channel_number=2.0, + name="HD Channel", + tvg_id="Channel ", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Channel <HD>"', content) + + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG with < > is not valid XML: {e}") + + def test_channel_id_with_all_special_chars(self): + """Test channel ID with all XML special characters""" + channel = Channel.objects.create( + channel_number=3.0, + name="Complex Channel", + tvg_id='Test & "Special" ', + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Test & "Special" <Chars>"', content) + + try: + tree = ET.fromstring(content) + # Verify we can find the channel with correct ID in parsed tree + channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" "]') + self.assertIsNotNone(channel_elem) + except ET.ParseError as e: + self.fail(f"Generated EPG with all special chars is not valid XML: {e}") + + def test_program_channel_attribute_escaping(self): + """Test that programme elements also have escaped channel attributes""" + epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy") + epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source) + channel = Channel.objects.create( + channel_number=4.0, + name="Program Test", + tvg_id="News & Sports", + epg_data=epg_data, + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + + # Check programme elements have escaped channel attributes + self.assertIn('channel="News & Sports"', content) + + try: + tree = ET.fromstring(content) + programmes = tree.findall('.//programme[@channel="News & Sports"]') + self.assertGreater(len(programmes), 0) + except ET.ParseError as e: + self.fail(f"Generated EPG with programme elements is not valid XML: {e}") diff --git a/apps/output/views.py b/apps/output/views.py index ba75246a..58707ae2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1203,7 +1203,7 @@ def generate_dummy_epg( # Create program entry with escaped channel name xml_lines.append( - f' ' + f' ' ) xml_lines.append(f" {html.escape(program['title'])}") xml_lines.append(f" {html.escape(program['description'])}") @@ -1448,7 +1448,7 @@ def generate_epg(request, profile_name=None, user=None): else: tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) display_name = channel.name - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') xml_lines.append(" ") @@ -1523,7 +1523,7 @@ def generate_epg(request, profile_name=None, user=None): stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") # Create program entry with escaped channel name - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" @@ -1572,7 +1572,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" @@ -1634,7 +1634,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - program_xml = [f' '] + program_xml = [f' '] program_xml.append(f' {html.escape(prog.title)}') # Add subtitle if available From 5f4e645a4896b128329821e1a2eec34eff1fe002 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 13:12:08 -0600 Subject: [PATCH 74/75] changelog: Update changelog for frontend tests/refactoring PR. --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 960302d2..a81a83bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including: + - `useLocalStorage` hook tests with localStorage mocking and error handling + - `useSmartLogos` hook tests for logo loading and management + - `useTablePreferences` hook tests for table settings persistence + - `useAuthStore` tests for authentication flow and token management + - `useChannelsStore` tests for channel data management + - `useUserAgentsStore` tests for user agent CRUD operations + - `useUsersStore` tests for user management functionality + - `useVODLogosStore` tests for VOD logo operations + - `useVideoStore` tests for video player state management + - `useWarningsStore` tests for warning suppression functionality + - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) + ### Changed - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: From 573b7d12abf163299121a2e0994500d48c2349c2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 13:26:16 -0600 Subject: [PATCH 75/75] changelog: Update changelog for EPG Channel ID xml escaping. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81a83bf..e28ecb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). +- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) ## [0.18.1] - 2026-01-27