+ {`Are you sure you want to delete the following stream?
+
+Name: ${streamToDelete.name}
+${streamToDelete.channel_group ? `Group: ${channelGroups[streamToDelete.channel_group]?.name || 'Unknown'}` : ''}
+${streamToDelete.m3u_account ? `M3U Account: ${playlists.find((p) => p.id === streamToDelete.m3u_account)?.name || 'Unknown'}` : ''}
+
+This action cannot be undone.`}
+
+ ) : (
+ 'Are you sure you want to delete this stream? This action cannot be undone.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ actionKey={isBulkDelete ? 'delete-streams' : 'delete-stream'}
+ onSuppressChange={suppressWarning}
+ loading={deleting}
+ size="md"
+ />
>
);
};
diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx
index e0c9b504..ffd47719 100644
--- a/frontend/src/components/tables/UserAgentsTable.jsx
+++ b/frontend/src/components/tables/UserAgentsTable.jsx
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
-import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
@@ -18,17 +17,43 @@ import {
Button,
Stack,
} from '@mantine/core';
-import { IconSquarePlus } from '@tabler/icons-react';
-import { SquareMinus, SquarePen, Check, X } from 'lucide-react';
+import { SquareMinus, SquarePen, Check, X, SquarePlus } from 'lucide-react';
+import { CustomTable, useTable } from './CustomTable';
+import useLocalStorage from '../../hooks/useLocalStorage';
+
+const RowActions = ({ row, editUserAgent, deleteUserAgent }) => {
+ return (
+ <>
+ {
header: 'Description',
accessorKey: 'description',
enableSorting: false,
- Cell: ({ cell }) => (
+ cell: ({ cell }) => (
{
{
header: 'Active',
accessorKey: 'is_active',
- size: 10,
sortingFn: 'basic',
enableSorting: false,
- mantineTableHeadCellProps: {
- align: 'right',
- },
- mantineTableBodyCellProps: {
- align: 'right',
- },
- Cell: ({ cell }) => (
+ size: 60,
+ cell: ({ cell }) => (
{cell.getValue() ? : }
),
- Filter: ({ column }) => (
-
{
- setActiveFilterValue(e.target.value);
- column.setFilterValue(e.target.value);
- }}
- displayEmpty
- data={[
- {
- value: 'all',
- label: 'All',
- },
- {
- value: 'active',
- label: 'Active',
- },
- {
- value: 'inactive',
- label: 'Inactive',
- },
- ]}
- />
- ),
- filterFn: (row, _columnId, activeFilterValue) => {
- if (activeFilterValue == 'all') return true; // Show all if no filter
- return String(row.getValue('is_active')) === activeFilterValue;
- },
+ },
+ {
+ id: 'actions',
+ header: 'Actions',
+ size: tableSize == 'compact' ? 50 : 75,
},
],
[]
);
- //optionally access the underlying virtualizer instance
- const rowVirtualizerInstanceRef = useRef(null);
-
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
@@ -134,7 +127,7 @@ const UserAgentsTable = () => {
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
- if (ids.includes(settings['default-user-agent'].value)) {
+ if (ids.includes(settings.default_user_agent)) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
@@ -144,7 +137,7 @@ const UserAgentsTable = () => {
await API.deleteUserAgents(ids);
} else {
- if (ids == settings['default-user-agent'].value) {
+ if (ids == settings.default_user_agent) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
@@ -167,128 +160,60 @@ const UserAgentsTable = () => {
}
}, []);
- useEffect(() => {
- //scroll to the top of the table when the sorting changes
- try {
- rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
- } catch (error) {
- console.error(error);
+ const renderHeaderCell = (header) => {
+ switch (header.id) {
+ default:
+ return (
+
+ {header.column.columnDef.header}
+
+ );
}
- }, [sorting]);
+ };
- const table = useMantineReactTable({
- ...TableHelper.defaultProperties,
+ const renderBodyCell = ({ cell, row }) => {
+ switch (cell.column.id) {
+ case 'actions':
+ return (
+
+ );
+ }
+ };
+
+ const table = useTable({
columns,
data: userAgents,
- enablePagination: false,
- enableRowVirtualization: true,
- // enableRowSelection: true,
- renderTopToolbar: false,
- // onRowSelectionChange: setRowSelection,
- onSortingChange: setSorting,
- state: {
- isLoading,
- sorting,
- // rowSelection,
+ allRowIds: userAgents.map((ua) => ua.id),
+ bodyCellRenderFns: {
+ actions: renderBodyCell,
},
- rowVirtualizerInstanceRef, //optional
- rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
- initialState: {
- density: 'compact',
- },
- enableRowActions: true,
- renderRowActions: ({ row }) => (
- <>
- {
- editUserAgent(row.original);
- }}
- >
- {/* Small icon size */}
-
- deleteUserAgent(row.original.id)}
- >
- {/* Small icon size */}
-
- >
- ),
- mantineTableContainerProps: {
- style: {
- height: 'calc(60vh - 100px)',
- overflowY: 'auto',
- // margin: 5,
- },
- },
- displayColumnDefOptions: {
- 'mrt-row-actions': {
- size: 10,
- },
+ headerCellRenderFns: {
+ name: renderHeaderCell,
+ user_agent: renderHeaderCell,
+ description: renderHeaderCell,
+ is_active: renderHeaderCell,
+ actions: renderHeaderCell,
},
});
return (
-
-
-
- User-Agents
-
-
-
-
- {/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
+
+
}
+ leftSection={ }
variant="light"
size="xs"
onClick={() => editUserAgent()}
@@ -307,7 +232,30 @@ const UserAgentsTable = () => {
-
+
+
+
+
+
+
+
+
{
+ const [tableSize, _] = useLocalStorage('table-size', 'default');
+ const authUser = useAuthStore((s) => s.user);
+
+ const onEdit = useCallback(() => {
+ editUser(row.original);
+ }, [row.original, editUser]);
+
+ const onDelete = useCallback(() => {
+ deleteUser(row.original.id);
+ }, [row.original.id, deleteUser]);
+
+ const iconSize =
+ tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const UsersTable = () => {
+ const theme = useMantineTheme();
+
+ /**
+ * STORES
+ */
+ const users = useUsersStore((s) => s.users);
+ const authUser = useAuthStore((s) => s.user);
+ const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
+ const suppressWarning = useWarningsStore((s) => s.suppressWarning);
+
+ /**
+ * useState
+ */
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [userModalOpen, setUserModalOpen] = useState(false);
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [userToDelete, setUserToDelete] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [visiblePasswords, setVisiblePasswords] = useState({});
+
+ /**
+ * Functions
+ */
+ const togglePasswordVisibility = useCallback((userId) => {
+ setVisiblePasswords((prev) => ({
+ ...prev,
+ [userId]: !prev[userId],
+ }));
+ }, []);
+
+ const executeDeleteUser = useCallback(async (id) => {
+ setIsLoading(true);
+ setDeleting(true);
+ try {
+ await API.deleteUser(id);
+ } finally {
+ setDeleting(false);
+ setIsLoading(false);
+ setConfirmDeleteOpen(false);
+ }
+ }, []);
+
+ const editUser = useCallback(async (user = null) => {
+ setSelectedUser(user);
+ setUserModalOpen(true);
+ }, []);
+
+ const deleteUser = useCallback(
+ async (id) => {
+ const user = users.find((u) => u.id === id);
+ setUserToDelete(user);
+ setDeleteTarget(id);
+
+ if (isWarningSuppressed('delete-user')) {
+ return executeDeleteUser(id);
+ }
+
+ setConfirmDeleteOpen(true);
+ },
+ [users, isWarningSuppressed, executeDeleteUser]
+ );
+
+ /**
+ * useMemo
+ */
+ const columns = useMemo(
+ () => [
+ {
+ header: 'User Level',
+ accessorKey: 'user_level',
+ size: 120,
+ cell: ({ getValue }) => (
+ {USER_LEVEL_LABELS[getValue()]}
+ ),
+ },
+ {
+ header: 'Username',
+ accessorKey: 'username',
+ size: 150,
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ },
+ {
+ id: 'name',
+ header: 'Name',
+ accessorFn: (row) =>
+ `${row.first_name || ''} ${row.last_name || ''}`.trim(),
+ cell: ({ getValue }) => (
+
+ {getValue() || '-'}
+
+ ),
+ },
+ {
+ header: 'Email',
+ accessorKey: 'email',
+ grow: true,
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ },
+ {
+ header: 'Date Joined',
+ accessorKey: 'date_joined',
+ size: 125,
+ cell: ({ getValue }) => {
+ const date = getValue();
+ return (
+
+ {date ? new Date(date).toLocaleDateString() : '-'}
+
+ );
+ },
+ },
+ {
+ header: 'Last Login',
+ accessorKey: 'last_login',
+ size: 175,
+ cell: ({ getValue }) => {
+ const date = getValue();
+ return (
+
+ {date ? new Date(date).toLocaleString() : 'Never'}
+
+ );
+ },
+ },
+ {
+ header: 'XC Password',
+ accessorKey: 'custom_properties',
+ size: 125,
+ enableSorting: false,
+ cell: ({ getValue, row }) => {
+ const userId = row.original.id;
+ const isVisible = visiblePasswords[userId];
+
+ // Extract xc_password from custom_properties
+ let password = 'N/A';
+ const customProps = getValue() || {};
+ password = customProps.xc_password || 'N/A';
+
+ return (
+
+
+ {password === 'N/A' ? 'N/A' : isVisible ? password : '••••••••'}
+
+ {password !== 'N/A' && (
+ togglePasswordVisibility(userId)}
+ >
+ {isVisible ? : }
+
+ )}
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ size: 80,
+ header: 'Actions',
+ enableSorting: false,
+ cell: ({ row }) => (
+
+ ),
+ },
+ ],
+ [theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility]
+ );
+
+ const closeUserForm = () => {
+ setSelectedUser(null);
+ setUserModalOpen(false);
+ };
+
+ const data = useMemo(() => {
+ return users.sort((a, b) => a.id - b.id);
+ }, [users]);
+
+ const renderHeaderCell = (header) => {
+ return (
+
+ {header.column.columnDef.header}
+
+ );
+ };
+
+ const table = useTable({
+ columns,
+ data,
+ allRowIds: data.map((user) => user.id),
+ enablePagination: false,
+ enableRowSelection: false,
+ enableRowVirtualization: false,
+ renderTopToolbar: false,
+ manualSorting: false,
+ manualFiltering: false,
+ manualPagination: false,
+ headerCellRenderFns: {
+ actions: renderHeaderCell,
+ username: renderHeaderCell,
+ name: renderHeaderCell,
+ email: renderHeaderCell,
+ user_level: renderHeaderCell,
+ last_login: renderHeaderCell,
+ date_joined: renderHeaderCell,
+ custom_properties: renderHeaderCell,
+ },
+ });
+
+ return (
+ <>
+
+
+
+
+ Users
+
+
+
+
+ {/* Top toolbar */}
+
+ }
+ variant="light"
+ size="xs"
+ onClick={() => editUser()}
+ p={5}
+ color={theme.tailwind.green[5]}
+ style={{
+ borderWidth: '1px',
+ borderColor: theme.tailwind.green[5],
+ color: 'white',
+ }}
+ disabled={authUser.user_level !== USER_LEVELS.ADMIN}
+ >
+ Add User
+
+
+
+ {/* Table container */}
+
+
+
+
+
+
+
+
+
+
+
+
+ setConfirmDeleteOpen(false)}
+ onConfirm={() => executeDeleteUser(deleteTarget)}
+ loading={deleting}
+ title="Confirm User Deletion"
+ message={
+ userToDelete ? (
+
+ {`Are you sure you want to delete the following user?
+
+Username: ${userToDelete.username}
+Email: ${userToDelete.email}
+User Level: ${USER_LEVEL_LABELS[userToDelete.user_level]}
+
+This action cannot be undone.`}
+
+ ) : (
+ 'Are you sure you want to delete this user? This action cannot be undone.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ actionKey="delete-user"
+ onSuppressChange={suppressWarning}
+ size="md"
+ />
+ >
+ );
+};
+
+export default UsersTable;
diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx
new file mode 100644
index 00000000..75b322f5
--- /dev/null
+++ b/frontend/src/components/tables/VODLogosTable.jsx
@@ -0,0 +1,664 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Center,
+ Checkbox,
+ Flex,
+ Group,
+ Image,
+ LoadingOverlay,
+ NativeSelect,
+ Pagination,
+ Paper,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+ Tooltip,
+ useMantineTheme,
+} from '@mantine/core';
+import { ExternalLink, Search, Trash2, Trash, SquareMinus } from 'lucide-react';
+import useVODLogosStore from '../../store/vodLogos';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import { CustomTable, useTable } from './CustomTable';
+import ConfirmationDialog from '../ConfirmationDialog';
+import { notifications } from '@mantine/notifications';
+
+const VODLogoRowActions = ({ theme, row, deleteLogo }) => {
+ const [tableSize] = useLocalStorage('table-size', 'default');
+
+ const onDelete = useCallback(() => {
+ deleteLogo(row.original.id);
+ }, [row.original.id, deleteLogo]);
+
+ const iconSize =
+ tableSize === 'default' ? 'sm' : tableSize === 'compact' ? 'xs' : 'md';
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default function VODLogosTable() {
+ const theme = useMantineTheme();
+
+ const {
+ logos,
+ totalCount,
+ isLoading,
+ fetchVODLogos,
+ deleteVODLogo,
+ deleteVODLogos,
+ cleanupUnusedVODLogos,
+ } = useVODLogosStore();
+
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(25);
+ const [nameFilter, setNameFilter] = useState('');
+ const [usageFilter, setUsageFilter] = useState('all');
+ const [selectedRows, setSelectedRows] = useState(new Set());
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [paginationString, setPaginationString] = useState('');
+ const [isCleaningUp, setIsCleaningUp] = useState(false);
+ const tableRef = React.useRef(null);
+
+ // Calculate unused logos count
+ const unusedLogosCount = useMemo(() => {
+ return logos.filter(
+ (logo) => logo.movie_count === 0 && logo.series_count === 0
+ ).length;
+ }, [logos]);
+ useEffect(() => {
+ fetchVODLogos({
+ page: currentPage,
+ page_size: pageSize,
+ name: nameFilter,
+ usage: usageFilter === 'all' ? undefined : usageFilter,
+ });
+ }, [currentPage, pageSize, nameFilter, usageFilter, fetchVODLogos]);
+
+ const handleSelectAll = useCallback(
+ (checked) => {
+ if (checked) {
+ setSelectedRows(new Set(logos.map((logo) => logo.id)));
+ } else {
+ setSelectedRows(new Set());
+ }
+ },
+ [logos]
+ );
+
+ const handleSelectRow = useCallback((id, checked) => {
+ setSelectedRows((prev) => {
+ const newSet = new Set(prev);
+ if (checked) {
+ newSet.add(id);
+ } else {
+ newSet.delete(id);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const deleteLogo = useCallback((id) => {
+ setDeleteTarget([id]);
+ setConfirmDeleteOpen(true);
+ }, []);
+
+ const handleDeleteSelected = useCallback(() => {
+ setDeleteTarget(Array.from(selectedRows));
+ setConfirmDeleteOpen(true);
+ }, [selectedRows]);
+
+ const onRowSelectionChange = useCallback((newSelection) => {
+ setSelectedRows(new Set(newSelection));
+ }, []);
+
+ const clearSelections = useCallback(() => {
+ setSelectedRows(new Set());
+ // Clear table's internal selection state if table is initialized
+ if (tableRef.current?.setSelectedTableIds) {
+ tableRef.current.setSelectedTableIds([]);
+ }
+ }, []);
+
+ const handleConfirmDelete = async () => {
+ setDeleting(true);
+ try {
+ if (deleteTarget.length === 1) {
+ await deleteVODLogo(deleteTarget[0]);
+ notifications.show({
+ title: 'Success',
+ message: 'VOD logo deleted successfully',
+ color: 'green',
+ });
+ } else {
+ await deleteVODLogos(deleteTarget);
+ notifications.show({
+ title: 'Success',
+ message: `${deleteTarget.length} VOD logos deleted successfully`,
+ color: 'green',
+ });
+ }
+ } catch (error) {
+ notifications.show({
+ title: 'Error',
+ message: error.message || 'Failed to delete VOD logos',
+ color: 'red',
+ });
+ } finally {
+ setDeleting(false);
+ // Always clear selections and close dialog, even on error
+ clearSelections();
+ setConfirmDeleteOpen(false);
+ setDeleteTarget(null);
+ }
+ };
+
+ const handleCleanupUnused = useCallback(() => {
+ setConfirmCleanupOpen(true);
+ }, []);
+
+ const handleConfirmCleanup = async () => {
+ setIsCleaningUp(true);
+ try {
+ const result = await cleanupUnusedVODLogos();
+ notifications.show({
+ title: 'Success',
+ message: `Cleaned up ${result.deleted_count} unused VOD logos`,
+ color: 'green',
+ });
+ } catch (error) {
+ notifications.show({
+ title: 'Error',
+ message: error.message || 'Failed to cleanup unused VOD logos',
+ color: 'red',
+ });
+ } finally {
+ setIsCleaningUp(false);
+ setConfirmCleanupOpen(false);
+ clearSelections(); // Clear selections after cleanup
+ }
+ };
+
+ // Clear selections only when filters change (not on every data fetch)
+ useEffect(() => {
+ clearSelections();
+ }, [nameFilter, usageFilter, clearSelections]);
+
+ useEffect(() => {
+ const startItem = (currentPage - 1) * pageSize + 1;
+ const endItem = Math.min(currentPage * pageSize, totalCount);
+ setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
+ }, [currentPage, pageSize, totalCount]);
+
+ const pageCount = useMemo(() => {
+ return Math.ceil(totalCount / pageSize);
+ }, [totalCount, pageSize]);
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'select',
+ header: () => (
+ 0 && selectedRows.size === logos.length
+ }
+ indeterminate={
+ selectedRows.size > 0 && selectedRows.size < logos.length
+ }
+ onChange={(event) => handleSelectAll(event.currentTarget.checked)}
+ size="sm"
+ />
+ ),
+ cell: ({ row }) => (
+
+ handleSelectRow(row.original.id, event.currentTarget.checked)
+ }
+ size="sm"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ },
+ {
+ header: 'Preview',
+ accessorKey: 'cache_url',
+ size: 80,
+ enableSorting: false,
+ cell: ({ getValue, row }) => (
+
+ {
+ e.target.style.transform = 'scale(1.5)';
+ }}
+ onMouseLeave={(e) => {
+ e.target.style.transform = 'scale(1)';
+ }}
+ />
+
+ ),
+ },
+ {
+ header: 'Name',
+ accessorKey: 'name',
+ size: 250,
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ },
+ {
+ header: 'Usage',
+ accessorKey: 'usage',
+ size: 120,
+ cell: ({ row }) => {
+ const { movie_count, series_count, item_names } = row.original;
+ const totalUsage = movie_count + series_count;
+
+ if (totalUsage === 0) {
+ return (
+
+ Unused
+
+ );
+ }
+
+ // Build usage description
+ const usageParts = [];
+ if (movie_count > 0) {
+ usageParts.push(
+ `${movie_count} movie${movie_count !== 1 ? 's' : ''}`
+ );
+ }
+ if (series_count > 0) {
+ usageParts.push(`${series_count} series`);
+ }
+
+ const label =
+ usageParts.length === 1
+ ? usageParts[0]
+ : `${totalUsage} item${totalUsage !== 1 ? 's' : ''}`;
+
+ return (
+
+
+ Used by {usageParts.join(' & ')}:
+
+ {item_names &&
+ item_names.map((name, index) => (
+
+ • {name}
+
+ ))}
+
+ }
+ multiline
+ width={220}
+ >
+
+ {label}
+
+
+ );
+ },
+ },
+ {
+ header: 'URL',
+ accessorKey: 'url',
+ grow: true,
+ cell: ({ getValue }) => (
+
+
+
+ {getValue()}
+
+
+ {getValue()?.startsWith('http') && (
+ window.open(getValue(), '_blank')}
+ >
+
+
+ )}
+
+ ),
+ },
+ {
+ id: 'actions',
+ size: 80,
+ header: 'Actions',
+ enableSorting: false,
+ cell: ({ row }) => (
+
+ ),
+ },
+ ],
+ [theme, deleteLogo, selectedRows, handleSelectAll, handleSelectRow, logos]
+ );
+
+ const renderHeaderCell = (header) => {
+ return (
+
+ {header.column.columnDef.header}
+
+ );
+ };
+
+ const table = useTable({
+ data: logos,
+ columns,
+ manualPagination: true,
+ pageCount: pageCount,
+ allRowIds: logos.map((logo) => logo.id),
+ enablePagination: false,
+ enableRowSelection: true,
+ enableRowVirtualization: false,
+ renderTopToolbar: false,
+ manualSorting: false,
+ manualFiltering: false,
+ onRowSelectionChange: onRowSelectionChange,
+ headerCellRenderFns: {
+ actions: renderHeaderCell,
+ cache_url: renderHeaderCell,
+ name: renderHeaderCell,
+ url: renderHeaderCell,
+ usage: renderHeaderCell,
+ },
+ });
+
+ // Store table reference for clearing selections
+ React.useEffect(() => {
+ tableRef.current = table;
+ }, [table]);
+
+ // Helper to get single logo when confirming single-delete
+ const logoToDelete =
+ deleteTarget && deleteTarget.length === 1
+ ? logos.find((l) => l.id === deleteTarget[0])
+ : null;
+ return (
+
+
+
+ {/* Top toolbar */}
+
+
+ {
+ const value = event.target.value;
+ setNameFilter(value);
+ }}
+ size="xs"
+ style={{ width: 200 }}
+ />
+ setUsageFilter(value)}
+ data={[
+ { value: 'all', label: 'All logos' },
+ { value: 'used', label: 'Used only' },
+ { value: 'unused', label: 'Unused only' },
+ { value: 'movies', label: 'Movies logos' },
+ { value: 'series', label: 'Series logos' },
+ ]}
+ size="xs"
+ style={{ width: 120 }}
+ />
+
+
+
+ }
+ variant="light"
+ size="xs"
+ color="orange"
+ onClick={handleCleanupUnused}
+ loading={isCleaningUp}
+ disabled={unusedLogosCount === 0}
+ >
+ Cleanup Unused{' '}
+ {unusedLogosCount > 0 ? `(${unusedLogosCount})` : ''}
+
+
+ }
+ variant="default"
+ size="xs"
+ onClick={handleDeleteSelected}
+ disabled={selectedRows.size === 0}
+ >
+ Delete {selectedRows.size > 0 ? `(${selectedRows.size})` : ''}
+
+
+
+
+ {/* Table container */}
+
+
+
+
+
+
+
+
+ {/* Pagination Controls */}
+
+
+ Page Size
+ {
+ setPageSize(Number(event.target.value));
+ setCurrentPage(1);
+ }}
+ style={{ paddingRight: 20 }}
+ />
+
+ {paginationString}
+
+
+
+
+
+
+ {
+ setConfirmDeleteOpen(false);
+ setDeleteTarget(null);
+ }}
+ onConfirm={(deleteFiles) => {
+ // pass deleteFiles option through
+ handleConfirmDelete(deleteFiles);
+ }}
+ loading={deleting}
+ title={
+ deleteTarget && deleteTarget.length > 1
+ ? 'Delete Multiple Logos'
+ : 'Delete Logo'
+ }
+ message={
+ deleteTarget && deleteTarget.length > 1 ? (
+
+ Are you sure you want to delete {deleteTarget.length} selected
+ logos?
+
+ Any movies or series using these logos will have their logo
+ removed.
+
+
+ This action cannot be undone.
+
+
+ ) : logoToDelete ? (
+
+ Are you sure you want to delete the logo "{logoToDelete.name}"?
+ {logoToDelete.movie_count + logoToDelete.series_count > 0 && (
+
+ This logo is currently used by{' '}
+ {logoToDelete.movie_count + logoToDelete.series_count} item
+ {logoToDelete.movie_count + logoToDelete.series_count !== 1
+ ? 's'
+ : ''}
+ . They will have their logo removed.
+
+ )}
+
+ This action cannot be undone.
+
+
+ ) : (
+ 'Are you sure you want to delete this logo?'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ size="md"
+ showDeleteFileOption={
+ deleteTarget && deleteTarget.length > 1
+ ? Array.from(deleteTarget).some((id) => {
+ const logo = logos.find((l) => l.id === id);
+ return logo && logo.url && logo.url.startsWith('/data/logos');
+ })
+ : logoToDelete &&
+ logoToDelete.url &&
+ logoToDelete.url.startsWith('/data/logos')
+ }
+ deleteFileLabel={
+ deleteTarget && deleteTarget.length > 1
+ ? 'Also delete local logo files from disk'
+ : 'Also delete logo file from disk'
+ }
+ />
+
+ setConfirmCleanupOpen(false)}
+ loading={isCleaningUp}
+ onConfirm={handleConfirmCleanup}
+ title="Cleanup Unused Logos"
+ message={
+
+ Are you sure you want to cleanup {unusedLogosCount} unused logo
+ {unusedLogosCount !== 1 ? 's' : ''}?
+
+ This will permanently delete all logos that are not currently used
+ by any series or movies.
+
+
+ This action cannot be undone.
+
+
+ }
+ confirmLabel="Cleanup"
+ cancelLabel="Cancel"
+ size="md"
+ showDeleteFileOption={true}
+ deleteFileLabel="Also delete local logo files from disk"
+ />
+
+ );
+}
diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css
index c3651246..30fd033a 100644
--- a/frontend/src/components/tables/table.css
+++ b/frontend/src/components/tables/table.css
@@ -8,8 +8,8 @@ html {
}
.divTable {
+ width: 100%;
/* border: 1px solid lightgray; */
- /* width: fit-content; */
/* display: flex;
flex-direction: column; */
}
@@ -45,10 +45,24 @@ html {
}
.td {
- height: 28px;
border-bottom: solid 1px rgb(68, 68, 68);
}
+.divTable.table-size-compact .td {
+ min-height: 28px;
+ font-size: var(--mantine-font-size-sm);
+}
+
+.divTable.table-size-default .td {
+ min-height: 40px;
+ font-size: var(--mantine-font-size-md);
+}
+
+.divTable.table-size-large .td {
+ min-height: 48px;
+ font-size: var(--mantine-font-size-md);
+}
+
.resizer {
position: absolute;
top: 0;
@@ -93,6 +107,19 @@ html {
background-color: #27272A;
}
+/* Style for rows with no streams */
+.no-streams-row {
+ background-color: rgba(239, 68, 68, 0.15) !important;
+ box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.3);
+ /* Add subtle border */
+}
+
+/* Special hover effect for rows with no streams */
+.table-striped .tbody .tr.no-streams-row:hover {
+ background-color: rgba(239, 68, 68, 0.3) !important;
+ /* Darker red on hover */
+}
+
/* Prevent text selection when shift key is pressed */
.shift-key-active {
cursor: pointer !important;
@@ -152,4 +179,36 @@ html {
-moz-user-select: text !important;
-ms-user-select: text !important;
cursor: text !important;
-}
\ No newline at end of file
+}
+
+/* Column resize handle styles */
+.resizer {
+ position: absolute;
+ right: 0;
+ top: 0;
+ height: 100%;
+ width: 5px;
+ cursor: col-resize;
+ user-select: none;
+ touch-action: none;
+ background-color: transparent;
+ opacity: 0;
+ transition: all 0.2s ease;
+}
+
+.resizer:hover {
+ opacity: 1 !important;
+ background-color: #6b7280 !important;
+}
+
+.resizer.isResizing {
+ opacity: 1 !important;
+ background-color: #3b82f6 !important;
+ width: 3px;
+}
+
+/* Show resize handle on header hover */
+.th:hover .resizer {
+ opacity: 0.5;
+ background-color: #6b7280;
+}
diff --git a/frontend/src/components/theme/Button.jsx b/frontend/src/components/theme/Button.jsx
new file mode 100644
index 00000000..3b62c6fa
--- /dev/null
+++ b/frontend/src/components/theme/Button.jsx
@@ -0,0 +1,19 @@
+import { Button as MantineButton } from '@mantine/core';
+
+const Button = (props) => {
+ return (
+
+ );
+};
+
+export default Button;
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
new file mode 100644
index 00000000..528c5f04
--- /dev/null
+++ b/frontend/src/constants.js
@@ -0,0 +1,347 @@
+export const USER_LEVELS = {
+ STREAMER: 0,
+ STANDARD: 1,
+ ADMIN: 10,
+};
+
+export const USER_LEVEL_LABELS = {
+ [USER_LEVELS.STREAMER]: 'Streamer',
+ [USER_LEVELS.STANDARD]: 'Standard User',
+ [USER_LEVELS.ADMIN]: 'Admin',
+};
+
+export const NETWORK_ACCESS_OPTIONS = {
+ M3U_EPG: {
+ label: 'M3U / EPG Endpoints',
+ description: 'Limit access to M3U, EPG, and HDHR URLs',
+ },
+ STREAMS: {
+ label: 'Stream Endpoints',
+ description:
+ 'Limit network access to stream URLs, including XC stream URLs',
+ },
+ XC_API: {
+ label: 'XC API',
+ description: 'Limit access to the XC API',
+ },
+ UI: {
+ label: 'UI',
+ description: 'Limit access to the Dispatcharr UI',
+ },
+};
+
+export const PROXY_SETTINGS_OPTIONS = {
+ buffering_timeout: {
+ label: 'Buffering Timeout',
+ description:
+ 'Maximum time (in seconds) to wait for buffering before switching streams',
+ },
+ buffering_speed: {
+ label: 'Buffering Speed',
+ description:
+ 'Speed threshold below which buffering is detected (1.0 = normal speed)',
+ },
+ redis_chunk_ttl: {
+ label: 'Buffer Chunk TTL',
+ description:
+ 'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
+ },
+ channel_shutdown_delay: {
+ label: 'Channel Shutdown Delay',
+ description:
+ 'Delay in seconds before shutting down a channel after last client disconnects',
+ },
+ channel_init_grace_period: {
+ label: 'Channel Initialization Grace Period',
+ description: 'Grace period in seconds during channel initialization',
+ },
+};
+
+export const M3U_FILTER_TYPES = [
+ {
+ label: 'Group',
+ value: 'group',
+ },
+ {
+ label: 'Stream Name',
+ value: 'name',
+ },
+ {
+ label: 'Stream URL',
+ value: 'url',
+ },
+];
+
+export const REGION_CHOICES = [
+ { value: 'ad', label: 'AD' },
+ { value: 'ae', label: 'AE' },
+ { value: 'af', label: 'AF' },
+ { value: 'ag', label: 'AG' },
+ { value: 'ai', label: 'AI' },
+ { value: 'al', label: 'AL' },
+ { value: 'am', label: 'AM' },
+ { value: 'ao', label: 'AO' },
+ { value: 'aq', label: 'AQ' },
+ { value: 'ar', label: 'AR' },
+ { value: 'as', label: 'AS' },
+ { value: 'at', label: 'AT' },
+ { value: 'au', label: 'AU' },
+ { value: 'aw', label: 'AW' },
+ { value: 'ax', label: 'AX' },
+ { value: 'az', label: 'AZ' },
+ { value: 'ba', label: 'BA' },
+ { value: 'bb', label: 'BB' },
+ { value: 'bd', label: 'BD' },
+ { value: 'be', label: 'BE' },
+ { value: 'bf', label: 'BF' },
+ { value: 'bg', label: 'BG' },
+ { value: 'bh', label: 'BH' },
+ { value: 'bi', label: 'BI' },
+ { value: 'bj', label: 'BJ' },
+ { value: 'bl', label: 'BL' },
+ { value: 'bm', label: 'BM' },
+ { value: 'bn', label: 'BN' },
+ { value: 'bo', label: 'BO' },
+ { value: 'bq', label: 'BQ' },
+ { value: 'br', label: 'BR' },
+ { value: 'bs', label: 'BS' },
+ { value: 'bt', label: 'BT' },
+ { value: 'bv', label: 'BV' },
+ { value: 'bw', label: 'BW' },
+ { value: 'by', label: 'BY' },
+ { value: 'bz', label: 'BZ' },
+ { value: 'ca', label: 'CA' },
+ { value: 'cc', label: 'CC' },
+ { value: 'cd', label: 'CD' },
+ { value: 'cf', label: 'CF' },
+ { value: 'cg', label: 'CG' },
+ { value: 'ch', label: 'CH' },
+ { value: 'ci', label: 'CI' },
+ { value: 'ck', label: 'CK' },
+ { value: 'cl', label: 'CL' },
+ { value: 'cm', label: 'CM' },
+ { value: 'cn', label: 'CN' },
+ { value: 'co', label: 'CO' },
+ { value: 'cr', label: 'CR' },
+ { value: 'cu', label: 'CU' },
+ { value: 'cv', label: 'CV' },
+ { value: 'cw', label: 'CW' },
+ { value: 'cx', label: 'CX' },
+ { value: 'cy', label: 'CY' },
+ { value: 'cz', label: 'CZ' },
+ { value: 'de', label: 'DE' },
+ { value: 'dj', label: 'DJ' },
+ { value: 'dk', label: 'DK' },
+ { value: 'dm', label: 'DM' },
+ { value: 'do', label: 'DO' },
+ { value: 'dz', label: 'DZ' },
+ { value: 'ec', label: 'EC' },
+ { value: 'ee', label: 'EE' },
+ { value: 'eg', label: 'EG' },
+ { value: 'eh', label: 'EH' },
+ { value: 'er', label: 'ER' },
+ { value: 'es', label: 'ES' },
+ { value: 'et', label: 'ET' },
+ { value: 'fi', label: 'FI' },
+ { value: 'fj', label: 'FJ' },
+ { value: 'fk', label: 'FK' },
+ { value: 'fm', label: 'FM' },
+ { value: 'fo', label: 'FO' },
+ { value: 'fr', label: 'FR' },
+ { value: 'ga', label: 'GA' },
+ { value: 'gb', label: 'GB' },
+ { value: 'gd', label: 'GD' },
+ { value: 'ge', label: 'GE' },
+ { value: 'gf', label: 'GF' },
+ { value: 'gg', label: 'GG' },
+ { value: 'gh', label: 'GH' },
+ { value: 'gi', label: 'GI' },
+ { value: 'gl', label: 'GL' },
+ { value: 'gm', label: 'GM' },
+ { value: 'gn', label: 'GN' },
+ { value: 'gp', label: 'GP' },
+ { value: 'gq', label: 'GQ' },
+ { value: 'gr', label: 'GR' },
+ { value: 'gs', label: 'GS' },
+ { value: 'gt', label: 'GT' },
+ { value: 'gu', label: 'GU' },
+ { value: 'gw', label: 'GW' },
+ { value: 'gy', label: 'GY' },
+ { value: 'hk', label: 'HK' },
+ { value: 'hm', label: 'HM' },
+ { value: 'hn', label: 'HN' },
+ { value: 'hr', label: 'HR' },
+ { value: 'ht', label: 'HT' },
+ { value: 'hu', label: 'HU' },
+ { value: 'id', label: 'ID' },
+ { value: 'ie', label: 'IE' },
+ { value: 'il', label: 'IL' },
+ { value: 'im', label: 'IM' },
+ { value: 'in', label: 'IN' },
+ { value: 'io', label: 'IO' },
+ { value: 'iq', label: 'IQ' },
+ { value: 'ir', label: 'IR' },
+ { value: 'is', label: 'IS' },
+ { value: 'it', label: 'IT' },
+ { value: 'je', label: 'JE' },
+ { value: 'jm', label: 'JM' },
+ { value: 'jo', label: 'JO' },
+ { value: 'jp', label: 'JP' },
+ { value: 'ke', label: 'KE' },
+ { value: 'kg', label: 'KG' },
+ { value: 'kh', label: 'KH' },
+ { value: 'ki', label: 'KI' },
+ { value: 'km', label: 'KM' },
+ { value: 'kn', label: 'KN' },
+ { value: 'kp', label: 'KP' },
+ { value: 'kr', label: 'KR' },
+ { value: 'kw', label: 'KW' },
+ { value: 'ky', label: 'KY' },
+ { value: 'kz', label: 'KZ' },
+ { value: 'la', label: 'LA' },
+ { value: 'lb', label: 'LB' },
+ { value: 'lc', label: 'LC' },
+ { value: 'li', label: 'LI' },
+ { value: 'lk', label: 'LK' },
+ { value: 'lr', label: 'LR' },
+ { value: 'ls', label: 'LS' },
+ { value: 'lt', label: 'LT' },
+ { value: 'lu', label: 'LU' },
+ { value: 'lv', label: 'LV' },
+ { value: 'ly', label: 'LY' },
+ { value: 'ma', label: 'MA' },
+ { value: 'mc', label: 'MC' },
+ { value: 'md', label: 'MD' },
+ { value: 'me', label: 'ME' },
+ { value: 'mf', label: 'MF' },
+ { value: 'mg', label: 'MG' },
+ { value: 'mh', label: 'MH' },
+ { value: 'ml', label: 'ML' },
+ { value: 'mm', label: 'MM' },
+ { value: 'mn', label: 'MN' },
+ { value: 'mo', label: 'MO' },
+ { value: 'mp', label: 'MP' },
+ { value: 'mq', label: 'MQ' },
+ { value: 'mr', label: 'MR' },
+ { value: 'ms', label: 'MS' },
+ { value: 'mt', label: 'MT' },
+ { value: 'mu', label: 'MU' },
+ { value: 'mv', label: 'MV' },
+ { value: 'mw', label: 'MW' },
+ { value: 'mx', label: 'MX' },
+ { value: 'my', label: 'MY' },
+ { value: 'mz', label: 'MZ' },
+ { value: 'na', label: 'NA' },
+ { value: 'nc', label: 'NC' },
+ { value: 'ne', label: 'NE' },
+ { value: 'nf', label: 'NF' },
+ { value: 'ng', label: 'NG' },
+ { value: 'ni', label: 'NI' },
+ { value: 'nl', label: 'NL' },
+ { value: 'no', label: 'NO' },
+ { value: 'np', label: 'NP' },
+ { value: 'nr', label: 'NR' },
+ { value: 'nu', label: 'NU' },
+ { value: 'nz', label: 'NZ' },
+ { value: 'om', label: 'OM' },
+ { value: 'pa', label: 'PA' },
+ { value: 'pe', label: 'PE' },
+ { value: 'pf', label: 'PF' },
+ { value: 'pg', label: 'PG' },
+ { value: 'ph', label: 'PH' },
+ { value: 'pk', label: 'PK' },
+ { value: 'pl', label: 'PL' },
+ { value: 'pm', label: 'PM' },
+ { value: 'pn', label: 'PN' },
+ { value: 'pr', label: 'PR' },
+ { value: 'ps', label: 'PS' },
+ { value: 'pt', label: 'PT' },
+ { value: 'pw', label: 'PW' },
+ { value: 'py', label: 'PY' },
+ { value: 'qa', label: 'QA' },
+ { value: 're', label: 'RE' },
+ { value: 'ro', label: 'RO' },
+ { value: 'rs', label: 'RS' },
+ { value: 'ru', label: 'RU' },
+ { value: 'rw', label: 'RW' },
+ { value: 'sa', label: 'SA' },
+ { value: 'sb', label: 'SB' },
+ { value: 'sc', label: 'SC' },
+ { value: 'sd', label: 'SD' },
+ { value: 'se', label: 'SE' },
+ { value: 'sg', label: 'SG' },
+ { value: 'sh', label: 'SH' },
+ { value: 'si', label: 'SI' },
+ { value: 'sj', label: 'SJ' },
+ { value: 'sk', label: 'SK' },
+ { value: 'sl', label: 'SL' },
+ { value: 'sm', label: 'SM' },
+ { value: 'sn', label: 'SN' },
+ { value: 'so', label: 'SO' },
+ { value: 'sr', label: 'SR' },
+ { value: 'ss', label: 'SS' },
+ { value: 'st', label: 'ST' },
+ { value: 'sv', label: 'SV' },
+ { value: 'sx', label: 'SX' },
+ { value: 'sy', label: 'SY' },
+ { value: 'sz', label: 'SZ' },
+ { value: 'tc', label: 'TC' },
+ { value: 'td', label: 'TD' },
+ { value: 'tf', label: 'TF' },
+ { value: 'tg', label: 'TG' },
+ { value: 'th', label: 'TH' },
+ { value: 'tj', label: 'TJ' },
+ { value: 'tk', label: 'TK' },
+ { value: 'tl', label: 'TL' },
+ { value: 'tm', label: 'TM' },
+ { value: 'tn', label: 'TN' },
+ { value: 'to', label: 'TO' },
+ { value: 'tr', label: 'TR' },
+ { value: 'tt', label: 'TT' },
+ { value: 'tv', label: 'TV' },
+ { value: 'tw', label: 'TW' },
+ { value: 'tz', label: 'TZ' },
+ { value: 'ua', label: 'UA' },
+ { value: 'ug', label: 'UG' },
+ { value: 'uk', label: 'UK' },
+ { value: 'um', label: 'UM' },
+ { value: 'us', label: 'US' },
+ { value: 'uy', label: 'UY' },
+ { value: 'uz', label: 'UZ' },
+ { value: 'va', label: 'VA' },
+ { value: 'vc', label: 'VC' },
+ { value: 've', label: 'VE' },
+ { value: 'vg', label: 'VG' },
+ { value: 'vi', label: 'VI' },
+ { value: 'vn', label: 'VN' },
+ { value: 'vu', label: 'VU' },
+ { value: 'wf', label: 'WF' },
+ { value: 'ws', label: 'WS' },
+ { value: 'ye', label: 'YE' },
+ { value: 'yt', label: 'YT' },
+ { value: 'za', label: 'ZA' },
+ { value: 'zm', label: 'ZM' },
+ { value: 'zw', label: 'ZW' },
+];
+
+export const VOD_TYPES = {
+ MOVIE: 'movie',
+ EPISODE: 'episode'
+};
+
+export const VOD_FILTERS = {
+ ALL: 'all',
+ MOVIES: 'movies',
+ SERIES: 'series'
+};
+
+export const VOD_SORT_OPTIONS = [
+ { value: 'name', label: 'Name' },
+ { value: 'year', label: 'Year' },
+ { value: 'created_at', label: 'Date Added' },
+ { value: 'rating', label: 'Rating' }
+];
+
+export const CONTAINER_EXTENSIONS = [
+ 'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
+];
diff --git a/frontend/src/helpers/index.jsx b/frontend/src/helpers/index.jsx
index 7ec0abb7..d751c4d4 100644
--- a/frontend/src/helpers/index.jsx
+++ b/frontend/src/helpers/index.jsx
@@ -1,3 +1,3 @@
-import table from "./table";
+import table from './table';
export const TableHelper = table;
diff --git a/frontend/src/hooks/useSmartLogos.jsx b/frontend/src/hooks/useSmartLogos.jsx
new file mode 100644
index 00000000..83957e46
--- /dev/null
+++ b/frontend/src/hooks/useSmartLogos.jsx
@@ -0,0 +1,130 @@
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import useLogosStore from '../store/logos';
+
+/**
+ * Hook for components that need to display all logos (like logo selection popovers)
+ * Loads logos on-demand when the component is opened
+ */
+export const useLogoSelection = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ const logos = useLogosStore((s) => s.logos);
+ const fetchLogos = useLogosStore((s) => s.fetchLogos); // Check if we have a reasonable number of logos loaded
+ const hasEnoughLogos = Object.keys(logos).length > 0;
+
+ const ensureLogosLoaded = useCallback(async () => {
+ if (isLoading || (hasEnoughLogos && isInitialized)) {
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ await fetchLogos();
+ setIsInitialized(true);
+ } catch (error) {
+ console.error('Failed to load logos for selection:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [isLoading, hasEnoughLogos, isInitialized, fetchLogos]);
+
+ return {
+ logos,
+ isLoading,
+ ensureLogosLoaded,
+ hasLogos: hasEnoughLogos,
+ };
+};
+
+/**
+ * Hook for channel forms that need channel logos
+ */
+export const useChannelLogoSelection = () => {
+ const [isInitialized, setIsInitialized] = useState(false);
+ const channelLogos = useLogosStore((s) => s.channelLogos);
+ const hasLoadedChannelLogos = useLogosStore((s) => s.hasLoadedChannelLogos);
+ const backgroundLoading = useLogosStore((s) => s.backgroundLoading);
+ const fetchChannelAssignableLogos = useLogosStore(
+ (s) => s.fetchChannelAssignableLogos
+ );
+
+ const hasLogos = Object.keys(channelLogos).length > 0;
+
+ const ensureLogosLoaded = useCallback(async () => {
+ if (backgroundLoading) {
+ return;
+ }
+
+ if ((hasLoadedChannelLogos && hasLogos) || isInitialized) {
+ return;
+ }
+
+ try {
+ await fetchChannelAssignableLogos();
+ setIsInitialized(true);
+ } catch (error) {
+ console.error('Failed to load channel logos:', error);
+ }
+ }, [
+ backgroundLoading,
+ hasLoadedChannelLogos,
+ hasLogos,
+ fetchChannelAssignableLogos,
+ isInitialized,
+ ]);
+
+ return {
+ logos: channelLogos,
+ isLoading: backgroundLoading,
+ ensureLogosLoaded,
+ hasLogos,
+ };
+};
+
+/**
+ * Hook for components that need specific logos by IDs
+ */
+export const useLogosById = (logoIds = []) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [loadedIds, setLoadedIds] = useState(new Set());
+
+ const logos = useLogosStore((s) => s.logos);
+ const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds);
+
+ // Memoize missing IDs calculation to prevent infinite loops
+ const missingIds = useMemo(() => {
+ return logoIds.filter((id) => id && !logos[id] && !loadedIds.has(id));
+ }, [logoIds, logos, loadedIds]);
+
+ // Stringify logoIds to prevent array reference issues
+ const logoIdsString = logoIds.join(',');
+
+ useEffect(() => {
+ if (missingIds.length > 0 && !isLoading) {
+ setIsLoading(true);
+
+ // Track that we're loading these IDs to prevent re-requests
+ setLoadedIds((prev) => new Set([...prev, ...missingIds]));
+
+ fetchLogosByIds(missingIds)
+ .then(() => setIsLoading(false))
+ .catch((error) => {
+ console.error('Failed to load logos by IDs:', error);
+ // Remove failed IDs from loaded set so they can be retried
+ setLoadedIds((prev) => {
+ const newSet = new Set(prev);
+ missingIds.forEach((id) => newSet.delete(id));
+ return newSet;
+ });
+ setIsLoading(false);
+ });
+ }
+ }, [logoIdsString, missingIds, isLoading, fetchLogosByIds]);
+
+ return {
+ logos,
+ isLoading,
+ missingLogos: missingIds.length,
+ };
+};
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 57fdaf18..5c37b48b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,6 +1,7 @@
/* frontend/src/index.css */
:root {
- --separator-border: transparent !important; /* Override Allotment's default border */
+ --separator-border: transparent !important;
+ /* Override Allotment's default border */
--sash-hover-size: 3px !important;
}
@@ -11,7 +12,8 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- background-color: #2E2F34; /* Ensure the global background is dark */
+ background-color: #18181b;
+ /* Ensure the global background is dark */
color: #ffffff;
}
@@ -24,12 +26,15 @@ code {
::-webkit-scrollbar {
width: 8px;
}
+
::-webkit-scrollbar-track {
background: #3B3C41;
}
+
::-webkit-scrollbar-thumb {
background: #555;
}
+
::-webkit-scrollbar-thumb:hover {
background: #777;
}
@@ -45,7 +50,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
}
.table-input-header input::placeholder {
- color: rgb(207,207,207);
+ color: rgb(207, 207, 207);
font-weight: normal;
font-size: 14px;
}
@@ -59,7 +64,8 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
/* Styling for the sash (splitter) */
.sash.sash-vertical {
position: relative;
- width: 4px; /* Thin invisible divider */
+ width: 4px;
+ /* Thin invisible divider */
background: transparent;
}
@@ -70,9 +76,12 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
- width: 6px; /* Width of the short bar */
- height: 50px; /* Short bar length */
- background: rgba(255, 255, 255, 0.2); /* Light color similar to your screenshot */
+ width: 6px;
+ /* Width of the short bar */
+ height: 50px;
+ /* Short bar length */
+ background: rgba(255, 255, 255, 0.2);
+ /* Light color similar to your screenshot */
border-radius: 4px;
}
@@ -88,6 +97,16 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
overflow: auto;
}
+/* Fix for "will-change memory consumption is too high" error */
+* {
+ will-change: auto !important;
+}
+
+/* For elements that specifically need transforms (like draggable elements) */
+[data-draggable="true"] {
+ transform: translate3d(0, 0, 0) !important;
+}
+
/* styles.css */
.custom-multiselect-input {
display: flex;
@@ -98,6 +117,8 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
}
.custom-multiselect .mantine-MultiSelect-input {
- min-height: 30px; /* Set a minimum height */
- max-height: 30px; /* Set max height */
-}
+ min-height: 30px;
+ /* Set a minimum height */
+ max-height: 30px;
+ /* Set max height */
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Channels-test.jsx b/frontend/src/pages/Channels-test.jsx
deleted file mode 100644
index 14e3319c..00000000
--- a/frontend/src/pages/Channels-test.jsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-import { Allotment } from 'allotment';
-import { Box, Container } from '@mantine/core';
-import 'allotment/dist/style.css';
-
-const ChannelsPage = () => {
- return (
-
- Pane 1
- Pane 1
-
- );
-};
-
-export default ChannelsPage;
diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx
index e5ce7170..b7b87b17 100644
--- a/frontend/src/pages/Channels.jsx
+++ b/frontend/src/pages/Channels.jsx
@@ -1,33 +1,110 @@
-import React, { useState } from 'react';
+import React, { useCallback, useRef } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
-import { Box, Grid } from '@mantine/core';
+import { Box } from '@mantine/core';
import { Allotment } from 'allotment';
+import { USER_LEVELS } from '../constants';
+import useAuthStore from '../store/auth';
+import useLogosStore from '../store/logos';
+import useLocalStorage from '../hooks/useLocalStorage';
+import ErrorBoundary from '../components/ErrorBoundary';
+
+const PageContent = () => {
+ const authUser = useAuthStore((s) => s.user);
+ const fetchChannelAssignableLogos = useLogosStore(
+ (s) => s.fetchChannelAssignableLogos
+ );
+ const enableLogoRendering = useLogosStore((s) => s.enableLogoRendering);
+
+ const channelsReady = useRef(false);
+ const streamsReady = useRef(false);
+ const logosTriggered = useRef(false);
+
+ const [allotmentSizes, setAllotmentSizes] = useLocalStorage(
+ 'channels-splitter-sizes',
+ [50, 50]
+ );
+
+ // Only load logos when BOTH tables are ready
+ const tryLoadLogos = useCallback(() => {
+ if (
+ channelsReady.current &&
+ streamsReady.current &&
+ !logosTriggered.current
+ ) {
+ logosTriggered.current = true;
+ // Use requestAnimationFrame to defer logo loading until after browser paint
+ // This ensures EPG column is fully rendered before logos start loading
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ enableLogoRendering();
+ fetchChannelAssignableLogos();
+ });
+ });
+ }
+ }, [fetchChannelAssignableLogos, enableLogoRendering]);
+
+ const handleChannelsReady = useCallback(() => {
+ channelsReady.current = true;
+ tryLoadLogos();
+ }, [tryLoadLogos]);
+
+ const handleStreamsReady = useCallback(() => {
+ streamsReady.current = true;
+ tryLoadLogos();
+ }, [tryLoadLogos]);
+
+ const handleSplitChange = (sizes) => {
+ setAllotmentSizes(sizes);
+ };
+
+ const handleResize = (sizes) => {
+ setAllotmentSizes(sizes);
+ };
+
+ if (!authUser.id) return <>>;
+
+ if (authUser.user_level <= USER_LEVELS.STANDARD) {
+ handleStreamsReady();
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
const ChannelsPage = () => {
return (
-
+
+
+
);
};
diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx
index 24e736d4..c9eaaffc 100644
--- a/frontend/src/pages/ContentSources.jsx
+++ b/frontend/src/pages/ContentSources.jsx
@@ -1,21 +1,21 @@
-import React, { useState } from 'react';
import useUserAgentsStore from '../store/userAgents';
import M3UsTable from '../components/tables/M3UsTable';
import EPGsTable from '../components/tables/EPGsTable';
import { Box, Stack } from '@mantine/core';
+import ErrorBoundary from '../components/ErrorBoundary'
-const M3UPage = () => {
- const isLoading = useUserAgentsStore((state) => state.isLoading);
+const PageContent = () => {
const error = useUserAgentsStore((state) => state.error);
-
- if (isLoading) return
Loading...
;
- if (error) return
Error: {error}
;
+ if (error) throw new Error(error);
return (
@@ -28,6 +28,14 @@ const M3UPage = () => {
);
-};
+}
+
+const M3UPage = () => {
+ return (
+
+
+
+ );
+}
export default M3UPage;
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
index 9180a229..7bd6e07f 100644
--- a/frontend/src/pages/DVR.jsx
+++ b/frontend/src/pages/DVR.jsx
@@ -1,111 +1,60 @@
-import React, { useMemo, useState, useEffect } from 'react';
+import React, { useMemo, useState, useEffect, lazy, Suspense } from 'react';
import {
- ActionIcon,
Box,
Button,
- Card,
- Center,
- Container,
- Flex,
+ Badge,
Group,
SimpleGrid,
Stack,
Text,
Title,
- Tooltip,
useMantineTheme,
} from '@mantine/core';
import {
- Gauge,
- HardDriveDownload,
- HardDriveUpload,
SquarePlus,
- SquareX,
- Timer,
- Users,
- Video,
} from 'lucide-react';
-import dayjs from 'dayjs';
-import duration from 'dayjs/plugin/duration';
-import relativeTime from 'dayjs/plugin/relativeTime';
import useChannelsStore from '../store/channels';
+import useSettingsStore from '../store/settings';
+import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
-import API from '../api';
+import {
+ isAfter,
+ isBefore,
+ useTimeHelpers,
+} from '../utils/dateTimeUtils.js';
+const RecordingDetailsModal = lazy(() =>
+ import('../components/forms/RecordingDetailsModal'));
+import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
+import RecordingCard from '../components/cards/RecordingCard.jsx';
+import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
+import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
-dayjs.extend(duration);
-dayjs.extend(relativeTime);
-
-const RecordingCard = ({ recording }) => {
- const channels = useChannelsStore((s) => s.channels);
-
- const deleteRecording = (id) => {
- API.deleteRecording(id);
- };
-
- const customProps = JSON.parse(recording.custom_properties);
- let recordingName = 'Custom Recording';
- if (customProps.program) {
- recordingName = customProps.program.title;
- }
-
- console.log(recording);
-
- return (
-
-
-
- {recordingName}
-
-
-
-
- deleteRecording(recording.id)}
- >
-
-
-
-
-
-
-
- Channel:
- {channels[recording.channel].name}
-
-
-
- Start:
-
- {dayjs(new Date(recording.start_time)).format('MMMM D, YYYY h:MMa')}
-
-
-
- End:
-
- {dayjs(new Date(recording.end_time)).format('MMMM D, YYYY h:MMa')}
-
-
-
- );
+const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
+ return list.map((rec) => (
+
+ ));
};
const DVRPage = () => {
const theme = useMantineTheme();
-
const recordings = useChannelsStore((s) => s.recordings);
+ const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
+ const channels = useChannelsStore((s) => s.channels);
+ const fetchChannels = useChannelsStore((s) => s.fetchChannels);
+ const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
+ const { toUserTime, userNow } = useTimeHelpers();
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
+ const [detailsOpen, setDetailsOpen] = useState(false);
+ const [detailsRecording, setDetailsRecording] = useState(null);
+ const [ruleModal, setRuleModal] = useState({ open: false, ruleId: null });
+ const [editRecording, setEditRecording] = useState(null);
const openRecordingModal = () => {
setRecordingModalOpen(true);
@@ -115,8 +64,83 @@ const DVRPage = () => {
setRecordingModalOpen(false);
};
+ const openDetails = (recording) => {
+ setDetailsRecording(recording);
+ setDetailsOpen(true);
+ };
+ const closeDetails = () => setDetailsOpen(false);
+
+ const openRuleModal = (recording) => {
+ const ruleId = recording?.custom_properties?.rule?.id;
+ if (!ruleId) {
+ openDetails(recording);
+ return;
+ }
+ setDetailsOpen(false);
+ setDetailsRecording(null);
+ setEditRecording(null);
+ setRuleModal({ open: true, ruleId });
+ };
+
+ const closeRuleModal = () => setRuleModal({ open: false, ruleId: null });
+
+ useEffect(() => {
+ if (!channels || Object.keys(channels).length === 0) {
+ fetchChannels();
+ }
+ fetchRecordings();
+ fetchRecurringRules();
+ }, [channels, fetchChannels, fetchRecordings, fetchRecurringRules]);
+
+ // Re-render every second so time-based bucketing updates without a refresh
+ const [now, setNow] = useState(userNow());
+ useEffect(() => {
+ const interval = setInterval(() => setNow(userNow()), 1000);
+ return () => clearInterval(interval);
+ }, [userNow]);
+
+ useEffect(() => {
+ setNow(userNow());
+ }, [userNow]);
+
+ // Categorize recordings
+ const { inProgress, upcoming, completed } = useMemo(() => {
+ return categorizeRecordings(recordings, toUserTime, now);
+ }, [recordings, now, toUserTime]);
+
+ const handleOnWatchLive = () => {
+ const rec = detailsRecording;
+ const now = userNow();
+ const s = toUserTime(rec.start_time);
+ const e = toUserTime(rec.end_time);
+ if(isAfter(now, s) && isBefore(now, e)) {
+ // call into child RecordingCard behavior by constructing a URL like there
+ const channel = channels[rec.channel];
+ if (!channel) return;
+ const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode);
+ useVideoStore.getState().showVideo(url, 'live');
+ }
+ }
+
+ const handleOnWatchRecording = () => {
+ const url = getRecordingUrl(
+ detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode);
+ if(!url) return;
+ useVideoStore.getState().showVideo(url, 'vod', {
+ name:
+ detailsRecording.custom_properties?.program?.title ||
+ 'Recording',
+ logo: {
+ url: getPosterUrl(
+ detailsRecording.custom_properties?.poster_logo_id,
+ undefined,
+ channels[detailsRecording.channel]?.logo?.cache_url
+ )
+ },
+ });
+ }
return (
-
+
}
variant="light"
@@ -132,18 +156,134 @@ const DVRPage = () => {
>
New Recording
-
- {Object.values(recordings).map((recording) => (
-
- ))}
-
+
+
+
+ Currently Recording
+ {inProgress.length}
+
+
+ { }
+ {inProgress.length === 0 && (
+
+ Nothing recording right now.
+
+ )}
+
+
+
+
+
+ Upcoming Recordings
+ {upcoming.length}
+
+
+ { }
+ {upcoming.length === 0 && (
+
+ No upcoming recordings.
+
+ )}
+
+
+
+
+
+ Previously Recorded
+ {completed.length}
+
+
+ { }
+ {completed.length === 0 && (
+
+ No completed recordings yet.
+
+ )}
+
+
+
+
+ setEditRecording(null)}
+ />
+
+ {
+ setRuleModal({ open: false, ruleId: null });
+ setEditRecording(occ);
+ }}
+ />
+
+ {/* Details Modal */}
+ {detailsRecording && (
+
+ Loading...}>
+ {
+ setEditRecording(rec);
+ closeDetails();
+ }}
+ />
+
+
+ )}
);
};
-export default DVRPage;
+export default DVRPage;
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
deleted file mode 100644
index af2104e1..00000000
--- a/frontend/src/pages/Dashboard.jsx
+++ /dev/null
@@ -1,27 +0,0 @@
-// src/components/Dashboard.js
-import React, { useState } from "react";
-
-const Dashboard = () => {
- const [newStream, setNewStream] = useState("");
-
- return (
-
-
Dashboard Page
-
setNewStream(e.target.value)}
- placeholder="Enter Stream"
- />
-
-
Streams:
-
- {state.streams.map((stream, index) => (
- {stream}
- ))}
-
-
- );
-};
-
-export default Dashboard;
diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx
index f01254bf..ac0fdf82 100644
--- a/frontend/src/pages/Guide.jsx
+++ b/frontend/src/pages/Guide.jsx
@@ -1,54 +1,113 @@
// frontend/src/pages/Guide.js
-import React, { useMemo, useState, useEffect, useRef } from 'react';
-import dayjs from 'dayjs';
-import API from '../api';
+import React, {
+ useMemo,
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+ Suspense,
+} from 'react';
import useChannelsStore from '../store/channels';
-import logo from '../images/logo.png';
+import useLogosStore from '../store/logos';
import useVideoStore from '../store/useVideoStore'; // NEW import
-import { notifications } from '@mantine/notifications';
import useSettingsStore from '../store/settings';
import {
- Title,
- Box,
- Flex,
- Button,
- Text,
- Paper,
- Group,
- TextInput,
- Select,
ActionIcon,
+ Box,
+ Button,
+ Flex,
+ Group,
+ LoadingOverlay,
+ Paper,
+ Select,
+ Text,
+ TextInput,
+ Title,
Tooltip,
- Transition,
} from '@mantine/core';
-import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react';
+import { Calendar, Clock, Search, Video, X } from 'lucide-react';
import './guide.css';
import useEPGsStore from '../store/epgs';
-
-/** Layout constants */
-const CHANNEL_WIDTH = 120; // Width of the channel/logo column
-const PROGRAM_HEIGHT = 90; // Height of each channel row
-const EXPANDED_PROGRAM_HEIGHT = 180; // Height for expanded program rows
-const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
-const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
-const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
+import { useElementSize } from '@mantine/hooks';
+import { VariableSizeList } from 'react-window';
+import {
+ buildChannelIdMap,
+ calculateDesiredScrollPosition,
+ calculateEarliestProgramStart,
+ calculateEnd,
+ calculateHourTimeline,
+ calculateLatestProgramEnd,
+ calculateLeftScrollPosition,
+ calculateNowPosition,
+ calculateScrollPosition,
+ calculateScrollPositionByTimeClick,
+ calculateStart,
+ CHANNEL_WIDTH,
+ computeRowHeights,
+ createRecording,
+ createSeriesRule,
+ evaluateSeriesRule,
+ EXPANDED_PROGRAM_HEIGHT,
+ fetchPrograms,
+ fetchRules,
+ filterGuideChannels,
+ formatTime,
+ getGroupOptions,
+ getProfileOptions,
+ getRuleByProgram,
+ HOUR_WIDTH,
+ mapChannelsById,
+ mapProgramsByChannel,
+ mapRecordingsByProgramId,
+ matchChannelByTvgId,
+ MINUTE_BLOCK_WIDTH,
+ MINUTE_INCREMENT,
+ PROGRAM_HEIGHT,
+ sortChannels,
+} from './guideUtils';
+import {
+ getShowVideoUrl,
+} from '../utils/cards/RecordingCardUtils.js';
+import {
+ add,
+ convertToMs,
+ format,
+ getNow,
+ initializeTime,
+ startOfDay,
+ useDateTimeFormat,
+} from '../utils/dateTimeUtils.js';
+import GuideRow from '../components/GuideRow.jsx';
+import HourTimeline from '../components/HourTimeline';
+const ProgramRecordingModal = React.lazy(() =>
+ import('../components/forms/ProgramRecordingModal'));
+const SeriesRecordingModal = React.lazy(() =>
+ import('../components/forms/SeriesRecordingModal'));
+import { showNotification } from '../utils/notificationUtils.js';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
export default function TVChannelGuide({ startDate, endDate }) {
const channels = useChannelsStore((s) => s.channels);
const recordings = useChannelsStore((s) => s.recordings);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
- const logos = useChannelsStore((s) => s.logos);
+ const isLoading = useChannelsStore((s) => s.isLoading);
+ const [isProgramsLoading, setIsProgramsLoading] = useState(true);
+ const logos = useLogosStore((s) => s.logos);
const tvgsById = useEPGsStore((s) => s.tvgsById);
+ const epgs = useEPGsStore((s) => s.epgs);
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
- const [filteredChannels, setFilteredChannels] = useState([]);
- const [now, setNow] = useState(dayjs());
+ const [now, setNow] = useState(getNow());
const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
const [recordingForProgram, setRecordingForProgram] = useState(null);
- const [loading, setLoading] = useState(true);
+ const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
+ const [recordChoiceProgram, setRecordChoiceProgram] = useState(null);
+ const [existingRuleMode, setExistingRuleMode] = useState(null);
+ const [rulesOpen, setRulesOpen] = useState(false);
+ const [rules, setRules] = useState([]);
const [initialScrollComplete, setInitialScrollComplete] = useState(false);
// New filter states
@@ -60,91 +119,54 @@ export default function TVChannelGuide({ startDate, endDate }) {
const guideRef = useRef(null);
const timelineRef = useRef(null); // New ref for timeline scrolling
+ const listRef = useRef(null);
+ const tvGuideRef = useRef(null); // Ref for the main tv-guide wrapper
+ const isSyncingScroll = useRef(false);
+ const guideScrollLeftRef = useRef(0);
+ const {
+ ref: guideContainerRef,
+ width: guideWidth,
+ height: guideHeight,
+ } = useElementSize();
+ const [guideScrollLeft, setGuideScrollLeft] = useState(0);
// Add new state to track hovered logo
const [hoveredChannelId, setHoveredChannelId] = useState(null);
// Load program data once
useEffect(() => {
- if (!Object.keys(channels).length === 0) {
+ if (Object.keys(channels).length === 0) {
console.warn('No channels provided or empty channels array');
- notifications.show({ title: 'No channels available', color: 'red.5' });
- setLoading(false);
+ showNotification({ title: 'No channels available', color: 'red.5' });
+ setIsProgramsLoading(false);
return;
}
- const fetchPrograms = async () => {
- console.log('Fetching program grid...');
- const fetched = await API.getGrid(); // GETs your EPG grid
- console.log(`Received ${fetched.length} programs`);
+ const sortedChannels = sortChannels(channels);
+ setGuideChannels(sortedChannels);
- // Unique tvg_ids from returned programs
- const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
-
- // Filter your Redux/Zustand channels by matching tvg_id
- const filteredChannels = Object.values(channels)
- // Include channels with matching tvg_ids OR channels with null epg_data
- .filter(
- (ch) =>
- programIds.includes(tvgsById[ch.epg_data_id]?.tvg_id) ||
- programIds.includes(ch.uuid) ||
- ch.epg_data_id === null
- )
- // Add sorting by channel_number
- .sort(
- (a, b) =>
- (a.channel_number || Infinity) - (b.channel_number || Infinity)
- );
-
- console.log(
- `found ${filteredChannels.length} channels with matching tvg_ids`
- );
-
- setGuideChannels(filteredChannels);
- setFilteredChannels(filteredChannels); // Initialize filtered channels
- console.log(fetched);
- setPrograms(fetched);
- setLoading(false);
- };
-
- fetchPrograms();
+ fetchPrograms()
+ .then((data) => {
+ setPrograms(data);
+ setIsProgramsLoading(false);
+ })
+ .catch((error) => {
+ console.error('Failed to fetch programs:', error);
+ setIsProgramsLoading(false);
+ });
}, [channels]);
// Apply filters when search, group, or profile changes
- useEffect(() => {
- if (!guideChannels.length) return;
+ const filteredChannels = useMemo(() => {
+ if (!guideChannels.length) return [];
- let result = [...guideChannels];
-
- // Apply search filter
- if (searchQuery) {
- const query = searchQuery.toLowerCase();
- result = result.filter((channel) =>
- channel.name.toLowerCase().includes(query)
- );
- }
-
- // Apply channel group filter
- if (selectedGroupId !== 'all') {
- result = result.filter(
- (channel) => channel.channel_group?.id === parseInt(selectedGroupId)
- );
- }
-
- // Apply profile filter
- if (selectedProfileId !== 'all') {
- // Get the profile's enabled channels
- const profileChannels = profiles[selectedProfileId]?.channels || [];
- const enabledChannelIds = profileChannels
- .filter((pc) => pc.enabled)
- .map((pc) => pc.id);
-
- result = result.filter((channel) =>
- enabledChannelIds.includes(channel.id)
- );
- }
-
- setFilteredChannels(result);
+ return filterGuideChannels(
+ guideChannels,
+ searchQuery,
+ selectedGroupId,
+ selectedProfileId,
+ profiles
+ );
}, [
searchQuery,
selectedGroupId,
@@ -154,250 +176,460 @@ export default function TVChannelGuide({ startDate, endDate }) {
]);
// Use start/end from props or default to "today at midnight" +24h
- const defaultStart = dayjs(startDate || dayjs().startOf('day'));
- const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
+ const defaultStart = initializeTime(startDate || startOfDay(getNow()));
+ const defaultEnd = endDate
+ ? initializeTime(endDate)
+ : add(defaultStart, 24, 'hour');
// Expand timeline if needed based on actual earliest/ latest program
- const earliestProgramStart = useMemo(() => {
- if (!programs.length) return defaultStart;
- return programs.reduce((acc, p) => {
- const s = dayjs(p.start_time);
- return s.isBefore(acc) ? s : acc;
- }, defaultStart);
- }, [programs, defaultStart]);
+ const earliestProgramStart = useMemo(
+ () => calculateEarliestProgramStart(programs, defaultStart),
+ [programs, defaultStart]
+ );
- const latestProgramEnd = useMemo(() => {
- if (!programs.length) return defaultEnd;
- return programs.reduce((acc, p) => {
- const e = dayjs(p.end_time);
- return e.isAfter(acc) ? e : acc;
- }, defaultEnd);
- }, [programs, defaultEnd]);
+ const latestProgramEnd = useMemo(
+ () => calculateLatestProgramEnd(programs, defaultEnd),
+ [programs, defaultEnd]
+ );
- const start = earliestProgramStart.isBefore(defaultStart)
- ? earliestProgramStart
- : defaultStart;
- const end = latestProgramEnd.isAfter(defaultEnd)
- ? latestProgramEnd
- : defaultEnd;
+ const start = calculateStart(earliestProgramStart, defaultStart);
+ const end = calculateEnd(latestProgramEnd, defaultEnd);
- // Time increments in 15-min steps (for placing programs)
- const programTimeline = useMemo(() => {
- const times = [];
- let current = start;
- while (current.isBefore(end)) {
- times.push(current);
- current = current.add(MINUTE_INCREMENT, 'minute');
- }
- return times;
- }, [start, end]);
+ const channelIdByTvgId = useMemo(
+ () => buildChannelIdMap(guideChannels, tvgsById, epgs),
+ [guideChannels, tvgsById, epgs]
+ );
+
+ const channelById = useMemo(
+ () => mapChannelsById(guideChannels),
+ [guideChannels]
+ );
+
+ const programsByChannelId = useMemo(
+ () => mapProgramsByChannel(programs, channelIdByTvgId),
+ [programs, channelIdByTvgId]
+ );
+
+ const recordingsByProgramId = useMemo(
+ () => mapRecordingsByProgramId(recordings),
+ [recordings]
+ );
+
+ const rowHeights = useMemo(
+ () =>
+ computeRowHeights(
+ filteredChannels,
+ programsByChannelId,
+ expandedProgramId
+ ),
+ [filteredChannels, programsByChannelId, expandedProgramId]
+ );
+
+ const getItemSize = useCallback(
+ (index) => rowHeights[index] ?? PROGRAM_HEIGHT,
+ [rowHeights]
+ );
+
+ const [timeFormat, dateFormat] = useDateTimeFormat();
// Format day label using relative terms when possible (Today, Tomorrow, etc)
- const formatDayLabel = (time) => {
- const today = dayjs().startOf('day');
- const tomorrow = today.add(1, 'day');
- const dayAfterTomorrow = today.add(2, 'day');
- const weekLater = today.add(7, 'day');
-
- const day = time.startOf('day');
-
- if (day.isSame(today, 'day')) {
- return 'Today';
- } else if (day.isSame(tomorrow, 'day')) {
- return 'Tomorrow';
- } else if (day.isBefore(weekLater)) {
- // Within a week, show day name
- return time.format('dddd');
- } else {
- // Beyond a week, show month and day
- return time.format('MMM D');
- }
- };
+ const formatDayLabel = useCallback(
+ (time) => formatTime(time, dateFormat),
+ [dateFormat]
+ );
// Hourly marks with day labels
- const hourTimeline = useMemo(() => {
- const hours = [];
- let current = start;
- let currentDay = null;
+ const hourTimeline = useMemo(
+ () => calculateHourTimeline(start, end, formatDayLabel),
+ [start, end, formatDayLabel]
+ );
- while (current.isBefore(end)) {
- // Check if we're entering a new day
- const day = current.startOf('day');
- const isNewDay = !currentDay || !day.isSame(currentDay, 'day');
+ useEffect(() => {
+ const node = guideRef.current;
+ if (!node) return undefined;
- if (isNewDay) {
- currentDay = day;
+ const handleScroll = () => {
+ if (isSyncingScroll.current) {
+ return;
}
- // Add day information to our hour object
- hours.push({
- time: current,
- isNewDay,
- dayLabel: formatDayLabel(current),
- });
+ const { scrollLeft } = node;
- current = current.add(1, 'hour');
- }
- return hours;
- }, [start, end]);
+ // Always sync if timeline is out of sync, even if ref matches
+ if (
+ timelineRef.current &&
+ timelineRef.current.scrollLeft !== scrollLeft
+ ) {
+ isSyncingScroll.current = true;
+ timelineRef.current.scrollLeft = scrollLeft;
+ guideScrollLeftRef.current = scrollLeft;
+ setGuideScrollLeft(scrollLeft);
+ requestAnimationFrame(() => {
+ isSyncingScroll.current = false;
+ });
+ } else if (scrollLeft !== guideScrollLeftRef.current) {
+ // Update ref even if timeline was already synced
+ guideScrollLeftRef.current = scrollLeft;
+ setGuideScrollLeft(scrollLeft);
+ }
+ };
- // Scroll to the nearest half-hour mark ONLY on initial load
- useEffect(() => {
- if (
- guideRef.current &&
- timelineRef.current &&
- programs.length > 0 &&
- !initialScrollComplete
- ) {
- // Round the current time to the nearest half-hour mark
- const roundedNow =
- now.minute() < 30
- ? now.startOf('hour')
- : now.startOf('hour').add(30, 'minute');
- const nowOffset = roundedNow.diff(start, 'minute');
- const scrollPosition =
- (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
- MINUTE_BLOCK_WIDTH;
+ node.addEventListener('scroll', handleScroll, { passive: true });
- const scrollPos = Math.max(scrollPosition, 0);
- guideRef.current.scrollLeft = scrollPos;
- timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll
+ return () => {
+ node.removeEventListener('scroll', handleScroll);
+ };
+ }, []);
- // Mark initial scroll as complete
- setInitialScrollComplete(true);
- }
- }, [programs, start, now, initialScrollComplete]);
-
- // Update “now” every second
+ // Update "now" every second
useEffect(() => {
const interval = setInterval(() => {
- setNow(dayjs());
+ setNow(getNow());
}, 1000);
return () => clearInterval(interval);
}, []);
- // Pixel offset for the “now” vertical line
- const nowPosition = useMemo(() => {
- if (now.isBefore(start) || now.isAfter(end)) return -1;
- const minutesSinceStart = now.diff(start, 'minute');
- return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
- }, [now, start, end]);
+ // Pixel offset for the "now" vertical line
+ const nowPosition = useMemo(
+ () => calculateNowPosition(now, start, end),
+ [now, start, end]
+ );
- // Helper: find channel by tvg_id
- function findChannelByTvgId(tvgId) {
- return guideChannels.find(
- (ch) =>
- tvgsById[ch.epg_data_id]?.tvg_id === tvgId ||
- (!ch.epg_data_id && ch.uuid === tvgId)
- );
- }
+ useEffect(() => {
+ const tvGuide = tvGuideRef.current;
- const record = async (program) => {
- const channel = findChannelByTvgId(program.tvg_id);
- await API.createRecording({
- channel: `${channel.id}`,
- start_time: program.start_time,
- end_time: program.end_time,
- custom_properties: JSON.stringify({
- program,
- }),
+ if (!tvGuide) return undefined;
+
+ const handleContainerWheel = (event) => {
+ const guide = guideRef.current;
+ const timeline = timelineRef.current;
+
+ if (!guide) {
+ return;
+ }
+
+ if (event.deltaX !== 0 || (event.shiftKey && event.deltaY !== 0)) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const delta = event.deltaX !== 0 ? event.deltaX : event.deltaY;
+ const newScrollLeft = guide.scrollLeft + delta;
+
+ // Set both guide and timeline scroll positions
+ if (typeof guide.scrollTo === 'function') {
+ guide.scrollTo({ left: newScrollLeft, behavior: 'auto' });
+ } else {
+ guide.scrollLeft = newScrollLeft;
+ }
+
+ // Also sync timeline immediately
+ if (timeline) {
+ if (typeof timeline.scrollTo === 'function') {
+ timeline.scrollTo({ left: newScrollLeft, behavior: 'auto' });
+ } else {
+ timeline.scrollLeft = newScrollLeft;
+ }
+ }
+
+ // Update the ref to keep state in sync
+ guideScrollLeftRef.current = newScrollLeft;
+ setGuideScrollLeft(newScrollLeft);
+ }
+ };
+
+ tvGuide.addEventListener('wheel', handleContainerWheel, {
+ passive: false,
+ capture: true,
});
- notifications.show({ title: 'Recording scheduled' });
- };
+
+ return () => {
+ tvGuide.removeEventListener('wheel', handleContainerWheel, {
+ capture: true,
+ });
+ };
+ }, []);
+
+ // Fallback: continuously monitor for any scroll changes
+ useEffect(() => {
+ let rafId = null;
+ let lastCheck = 0;
+
+ const checkSync = (timestamp) => {
+ // Throttle to check every 100ms instead of every frame
+ if (timestamp - lastCheck > 100) {
+ const guide = guideRef.current;
+ const timeline = timelineRef.current;
+
+ if (guide && timeline && guide.scrollLeft !== timeline.scrollLeft) {
+ timeline.scrollLeft = guide.scrollLeft;
+ guideScrollLeftRef.current = guide.scrollLeft;
+ setGuideScrollLeft(guide.scrollLeft);
+ }
+ lastCheck = timestamp;
+ }
+
+ rafId = requestAnimationFrame(checkSync);
+ };
+
+ rafId = requestAnimationFrame(checkSync);
+
+ return () => {
+ if (rafId) cancelAnimationFrame(rafId);
+ };
+ }, []);
+
+ useEffect(() => {
+ const tvGuide = tvGuideRef.current;
+ if (!tvGuide) return;
+
+ let lastTouchX = null;
+ let isTouching = false;
+ let rafId = null;
+ let lastScrollLeft = 0;
+ let stableFrames = 0;
+
+ const syncScrollPositions = () => {
+ const guide = guideRef.current;
+ const timeline = timelineRef.current;
+
+ if (!guide || !timeline) return false;
+
+ const currentScroll = guide.scrollLeft;
+
+ // Check if scroll position has changed
+ if (currentScroll !== lastScrollLeft) {
+ timeline.scrollLeft = currentScroll;
+ guideScrollLeftRef.current = currentScroll;
+ setGuideScrollLeft(currentScroll);
+ lastScrollLeft = currentScroll;
+ stableFrames = 0;
+ return true; // Still scrolling
+ } else {
+ stableFrames++;
+ return stableFrames < 10; // Continue for 10 stable frames to catch late updates
+ }
+ };
+
+ const startPolling = () => {
+ if (rafId) return; // Already polling
+
+ const poll = () => {
+ const shouldContinue = isTouching || syncScrollPositions();
+
+ if (shouldContinue) {
+ rafId = requestAnimationFrame(poll);
+ } else {
+ rafId = null;
+ }
+ };
+
+ rafId = requestAnimationFrame(poll);
+ };
+
+ const handleTouchStart = (e) => {
+ if (e.touches.length === 1) {
+ const guide = guideRef.current;
+ if (guide) {
+ lastTouchX = e.touches[0].clientX;
+ lastScrollLeft = guide.scrollLeft;
+ isTouching = true;
+ stableFrames = 0;
+ startPolling();
+ }
+ }
+ };
+
+ const handleTouchMove = (e) => {
+ if (!isTouching || e.touches.length !== 1) return;
+ const guide = guideRef.current;
+ if (!guide) return;
+
+ const touchX = e.touches[0].clientX;
+ const deltaX = lastTouchX - touchX;
+ lastTouchX = touchX;
+
+ if (Math.abs(deltaX) > 0) {
+ guide.scrollLeft += deltaX;
+ }
+ };
+
+ const handleTouchEnd = () => {
+ isTouching = false;
+ lastTouchX = null;
+ // Polling continues until scroll stabilizes
+ };
+
+ tvGuide.addEventListener('touchstart', handleTouchStart, { passive: true });
+ tvGuide.addEventListener('touchmove', handleTouchMove, { passive: false });
+ tvGuide.addEventListener('touchend', handleTouchEnd, { passive: true });
+ tvGuide.addEventListener('touchcancel', handleTouchEnd, { passive: true });
+
+ return () => {
+ if (rafId) cancelAnimationFrame(rafId);
+ tvGuide.removeEventListener('touchstart', handleTouchStart);
+ tvGuide.removeEventListener('touchmove', handleTouchMove);
+ tvGuide.removeEventListener('touchend', handleTouchEnd);
+ tvGuide.removeEventListener('touchcancel', handleTouchEnd);
+ };
+ }, []);
+
+ const syncScrollLeft = useCallback((nextLeft, behavior = 'auto') => {
+ const guideNode = guideRef.current;
+ const timelineNode = timelineRef.current;
+
+ isSyncingScroll.current = true;
+
+ if (guideNode) {
+ if (typeof guideNode.scrollTo === 'function') {
+ guideNode.scrollTo({ left: nextLeft, behavior });
+ } else {
+ guideNode.scrollLeft = nextLeft;
+ }
+ }
+
+ if (timelineNode) {
+ if (typeof timelineNode.scrollTo === 'function') {
+ timelineNode.scrollTo({ left: nextLeft, behavior });
+ } else {
+ timelineNode.scrollLeft = nextLeft;
+ }
+ }
+
+ guideScrollLeftRef.current = nextLeft;
+ setGuideScrollLeft(nextLeft);
+
+ requestAnimationFrame(() => {
+ isSyncingScroll.current = false;
+ });
+ }, []);
+
+ // Scroll to the nearest half-hour mark ONLY on initial load
+ useEffect(() => {
+ if (programs.length > 0 && !initialScrollComplete) {
+ syncScrollLeft(calculateScrollPosition(now, start));
+
+ setInitialScrollComplete(true);
+ }
+ }, [programs, start, now, initialScrollComplete, syncScrollLeft]);
+
+ const findChannelByTvgId = useCallback(
+ (tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId),
+ [channelById, channelIdByTvgId]
+ );
+
+ const openRecordChoice = useCallback(
+ async (program) => {
+ setRecordChoiceProgram(program);
+ setRecordChoiceOpen(true);
+ try {
+ const rules = await fetchRules();
+ const rule = getRuleByProgram(rules, program);
+ setExistingRuleMode(rule ? rule.mode : null);
+ } catch (error) {
+ console.warn('Failed to fetch series rules metadata', error);
+ }
+
+ setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
+ },
+ [recordingsByProgramId]
+ );
+
+ const recordOne = useCallback(
+ async (program) => {
+ const channel = findChannelByTvgId(program.tvg_id);
+ if (!channel) {
+ showNotification({
+ title: 'Unable to schedule recording',
+ message: 'No channel found for this program.',
+ color: 'red.6',
+ });
+ return;
+ }
+
+ await createRecording(channel, program);
+ showNotification({ title: 'Recording scheduled' });
+ },
+ [findChannelByTvgId]
+ );
+
+ const saveSeriesRule = useCallback(async (program, mode) => {
+ await createSeriesRule(program, mode);
+ await evaluateSeriesRule(program);
+ try {
+ await useChannelsStore.getState().fetchRecordings();
+ } catch (error) {
+ console.warn(
+ 'Failed to refresh recordings after saving series rule',
+ error
+ );
+ }
+ showNotification({
+ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
+ });
+ }, []);
+
+ const openRules = useCallback(async () => {
+ setRulesOpen(true);
+ try {
+ const r = await fetchRules();
+ setRules(r);
+ } catch (error) {
+ console.warn('Failed to load series rules', error);
+ }
+ }, []);
// The “Watch Now” click => show floating video
const showVideo = useVideoStore((s) => s.showVideo);
- function handleWatchStream(program) {
- const matched = findChannelByTvgId(program.tvg_id);
- if (!matched) {
- console.warn(`No channel found for tvg_id=${program.tvg_id}`);
- return;
- }
- // Build a playable stream URL for that channel
- let vidUrl = `/proxy/ts/stream/${matched.uuid}`;
- if (env_mode == 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
+ const handleWatchStream = useCallback(
+ (program) => {
+ const matched = findChannelByTvgId(program.tvg_id);
+ if (!matched) {
+ console.warn(`No channel found for tvg_id=${program.tvg_id}`);
+ return;
+ }
- showVideo(vidUrl);
- }
+ showVideo(getShowVideoUrl(matched, env_mode));
+ },
+ [env_mode, findChannelByTvgId, showVideo]
+ );
- // Function to handle logo click to play channel
- function handleLogoClick(channel, event) {
- // Prevent event from bubbling up
- event.stopPropagation();
+ const handleLogoClick = useCallback(
+ (channel, event) => {
+ event.stopPropagation();
- // Build a playable stream URL for the channel
- let vidUrl = `/proxy/ts/stream/${channel.uuid}`;
- if (env_mode === 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
+ showVideo(getShowVideoUrl(channel, env_mode));
+ },
+ [env_mode, showVideo]
+ );
- // Use the existing showVideo function
- showVideo(vidUrl);
- }
+ const handleProgramClick = useCallback(
+ (program, event) => {
+ event.stopPropagation();
- // On program click, toggle the expanded state
- function handleProgramClick(program, event) {
- // Prevent event from bubbling up to parent elements
- event.stopPropagation();
+ if (expandedProgramId === program.id) {
+ setExpandedProgramId(null);
+ setRecordingForProgram(null);
+ } else {
+ setExpandedProgramId(program.id);
+ setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
+ }
- // Get the program's start time and calculate its position
- const programStart = dayjs(program.start_time);
- const startOffsetMinutes = programStart.diff(start, 'minute');
- const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+ const leftPx = calculateLeftScrollPosition(program, start);
+ const desiredScrollPosition = calculateDesiredScrollPosition(leftPx);
- // Calculate desired scroll position (account for channel column width)
- const desiredScrollPosition = Math.max(0, leftPx - 20); // 20px buffer
-
- // If already expanded, collapse it
- if (expandedProgramId === program.id) {
- setExpandedProgramId(null);
- setRecordingForProgram(null);
- return;
- }
-
- // Otherwise expand this program
- setExpandedProgramId(program.id);
-
- // Check if this program has a recording
- const programRecording = recordings.find((recording) => {
- if (recording.custom_properties) {
- const customProps = JSON.parse(recording.custom_properties);
- if (customProps.program && customProps.program.id == program.id) {
- return true;
+ const guideNode = guideRef.current;
+ if (guideNode) {
+ const currentScrollPosition = guideNode.scrollLeft;
+ if (
+ desiredScrollPosition < currentScrollPosition ||
+ leftPx - currentScrollPosition < 100
+ ) {
+ syncScrollLeft(desiredScrollPosition, 'smooth');
}
}
- return false;
- });
-
- setRecordingForProgram(programRecording);
-
- // Scroll to show the start of the program if it's not already fully visible
- if (guideRef.current && timelineRef.current) {
- const currentScrollPosition = guideRef.current.scrollLeft;
-
- // Check if we need to scroll (if program start is before current view or too close to edge)
- if (
- desiredScrollPosition < currentScrollPosition ||
- leftPx - currentScrollPosition < 100
- ) {
- // 100px from left edge
-
- // Smooth scroll to the program's start
- guideRef.current.scrollTo({
- left: desiredScrollPosition,
- behavior: 'smooth',
- });
-
- // Also sync the timeline scroll
- timelineRef.current.scrollTo({
- left: desiredScrollPosition,
- behavior: 'smooth',
- });
- }
- }
- }
+ },
+ [expandedProgramId, recordingsByProgramId, start, syncScrollLeft]
+ );
// Close the expanded program when clicking elsewhere
const handleClickOutside = () => {
@@ -407,343 +639,340 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
};
- // Function to scroll to current time - matches initial loading position
- const scrollToNow = () => {
- if (guideRef.current && timelineRef.current && nowPosition >= 0) {
- // Round the current time to the nearest half-hour mark
- const roundedNow =
- now.minute() < 30
- ? now.startOf('hour')
- : now.startOf('hour').add(30, 'minute');
- const nowOffset = roundedNow.diff(start, 'minute');
- const scrollPosition =
- (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
- MINUTE_BLOCK_WIDTH;
-
- const scrollPos = Math.max(scrollPosition, 0);
- guideRef.current.scrollLeft = scrollPos;
- timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll
+ const scrollToNow = useCallback(() => {
+ if (nowPosition < 0) {
+ return;
}
- };
- // Sync scrolling between timeline and main content
- const handleTimelineScroll = () => {
- if (timelineRef.current && guideRef.current) {
- guideRef.current.scrollLeft = timelineRef.current.scrollLeft;
+ syncScrollLeft(calculateScrollPosition(now, start), 'smooth');
+ }, [now, nowPosition, start, syncScrollLeft]);
+
+ const handleTimelineScroll = useCallback(() => {
+ if (!timelineRef.current || isSyncingScroll.current) {
+ return;
}
- };
- // Sync scrolling between main content and timeline
- const handleGuideScroll = () => {
- if (guideRef.current && timelineRef.current) {
- timelineRef.current.scrollLeft = guideRef.current.scrollLeft;
+ const nextLeft = timelineRef.current.scrollLeft;
+ if (nextLeft === guideScrollLeftRef.current) {
+ return;
}
- };
- // Handle wheel events on the timeline for horizontal scrolling
- const handleTimelineWheel = (e) => {
- if (timelineRef.current) {
- // Prevent the default vertical scroll
- e.preventDefault();
+ guideScrollLeftRef.current = nextLeft;
+ setGuideScrollLeft(nextLeft);
- // Determine scroll amount (with shift key for faster scrolling)
- const scrollAmount = e.shiftKey ? 250 : 125;
-
- // Scroll horizontally based on wheel direction
- timelineRef.current.scrollLeft +=
- e.deltaY > 0 ? scrollAmount : -scrollAmount;
-
- // Sync the main content scroll position
- if (guideRef.current) {
- guideRef.current.scrollLeft = timelineRef.current.scrollLeft;
- }
- }
- };
-
- // Function to handle timeline time clicks with 15-minute snapping
- const handleTimeClick = (clickedTime, event) => {
- if (timelineRef.current && guideRef.current) {
- // Calculate where in the hour block the click happened
- const hourBlockElement = event.currentTarget;
- const rect = hourBlockElement.getBoundingClientRect();
- const clickPositionX = event.clientX - rect.left; // Position within the hour block
- const percentageAcross = clickPositionX / rect.width; // 0 to 1 value
-
- // Calculate the minute within the hour based on click position
- const minuteWithinHour = Math.floor(percentageAcross * 60);
-
- // Create a new time object with the calculated minute
- const exactTime = clickedTime.minute(minuteWithinHour);
-
- // Determine the nearest 15-minute interval (0, 15, 30, 45)
- let snappedMinute;
- if (minuteWithinHour < 7.5) {
- snappedMinute = 0;
- } else if (minuteWithinHour < 22.5) {
- snappedMinute = 15;
- } else if (minuteWithinHour < 37.5) {
- snappedMinute = 30;
- } else if (minuteWithinHour < 52.5) {
- snappedMinute = 45;
+ isSyncingScroll.current = true;
+ if (guideRef.current) {
+ if (typeof guideRef.current.scrollTo === 'function') {
+ guideRef.current.scrollTo({ left: nextLeft });
} else {
- // If we're past 52.5 minutes, snap to the next hour
- snappedMinute = 0;
- clickedTime = clickedTime.add(1, 'hour');
+ guideRef.current.scrollLeft = nextLeft;
}
-
- // Create the snapped time
- const snappedTime = clickedTime.minute(snappedMinute);
-
- // Calculate the offset from the start of the timeline to the snapped time
- const snappedOffset = snappedTime.diff(start, 'minute');
-
- // Convert to pixels
- const scrollPosition =
- (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
-
- // Scroll both containers to the snapped position
- timelineRef.current.scrollLeft = scrollPosition;
- guideRef.current.scrollLeft = scrollPosition;
}
- };
- // Renders each program block
- function renderProgram(program, channelStart) {
- const programKey = `${program.tvg_id}-${program.start_time}`;
- const programStart = dayjs(program.start_time);
- const programEnd = dayjs(program.end_time);
- const startOffsetMinutes = programStart.diff(channelStart, 'minute');
- const durationMinutes = programEnd.diff(programStart, 'minute');
- const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
-
- // Calculate width with a small gap (2px on each side)
- const gapSize = 2;
- const widthPx =
- (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2;
-
- // Check if we have a recording for this program
- const recording = recordings.find((recording) => {
- if (recording.custom_properties) {
- const customProps = JSON.parse(recording.custom_properties);
- if (customProps.program && customProps.program.id == program.id) {
- return recording;
- }
- }
- return null;
+ requestAnimationFrame(() => {
+ isSyncingScroll.current = false;
});
+ }, []);
- // Highlight if currently live
- const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
+ const handleTimelineWheel = useCallback((event) => {
+ if (!timelineRef.current) {
+ return;
+ }
- // Determine if the program has ended
- const isPast = now.isAfter(programEnd);
+ event.preventDefault();
+ const scrollAmount = event.shiftKey ? 250 : 125;
+ const delta = event.deltaY > 0 ? scrollAmount : -scrollAmount;
+ timelineRef.current.scrollBy({ left: delta, behavior: 'smooth' });
+ }, []);
- // Check if this program is expanded
- const isExpanded = expandedProgramId === program.id;
+ const handleTimeClick = useCallback(
+ (clickedTime, event) => {
+ syncScrollLeft(
+ calculateScrollPositionByTimeClick(event, clickedTime, start),
+ 'smooth'
+ );
+ },
+ [start, syncScrollLeft]
+ );
+ const renderProgram = useCallback(
+ (program, channelStart = start, channel = null) => {
+ const {
+ programStart,
+ programEnd,
+ startMs: programStartMs,
+ endMs: programEndMs,
+ isLive,
+ isPast,
+ } = program;
- // Calculate how much of the program is cut off
- const cutOffMinutes = Math.max(
- 0,
- channelStart.diff(programStart, 'minute')
- );
- const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+ const startOffsetMinutes =
+ (programStartMs - convertToMs(channelStart)) / 60000;
+ const durationMinutes = (programEndMs - programStartMs) / 60000;
+ const leftPx =
+ (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
- // Set the height based on expanded state
- const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
+ const gapSize = 2;
+ const widthPx =
+ (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2;
- // Determine expanded width - if program is short, ensure it has a minimum expanded width
- // This will allow it to overlap programs to the right
- const MIN_EXPANDED_WIDTH = 450; // Minimum width in pixels when expanded
- const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH);
+ const recording = recordingsByProgramId.get(program.id);
- return (
- handleProgramClick(program, e)}
- >
- viewportLeft;
+
+ let textOffsetLeft = 0;
+ if (startsBeforeView && extendsIntoView) {
+ const visibleStart = Math.max(viewportLeft - programStartInView, 0);
+ const maxOffset = widthPx - 200;
+ textOffsetLeft = Math.min(visibleStart, maxOffset);
+ }
+
+ const RecordButton = () => {
+ return (
+ }
+ variant="filled"
+ color="red"
+ size="xs"
+ onClick={(event) => {
+ event.stopPropagation();
+ openRecordChoice(program);
+ }}
+ >
+ Record
+
+ );
+ };
+ const WatchNow = () => {
+ return (
+ }
+ variant="filled"
+ color="blue"
+ size="xs"
+ onClick={(event) => {
+ event.stopPropagation();
+ handleWatchStream(program);
+ }}
+ >
+ Watch Now
+
+ );
+ };
+ return (
+ handleProgramClick(program, event)}
>
-
-
+
-
- {recording && (
-
- )}
- {program.title}
-
-
-
- {programStart.format('h:mma')} - {programEnd.format('h:mma')}
-
-
-
- {/* Description is always shown but expands when row is expanded */}
- {program.description && (
-
- {program.description}
-
- )}
-
- {/* Expanded content */}
- {isExpanded && (
-
-
- {/* Only show Record button if not already recording AND not in the past */}
- {!recording && !isPast && (
- }
- variant="filled"
- color="red"
- size="xs"
- onClick={(e) => {
- e.stopPropagation();
- record(program);
- }}
- >
- Record
-
- )}
-
- {isLive && (
- }
- variant="filled"
- color="blue"
- size="xs"
- onClick={(e) => {
- e.stopPropagation();
- handleWatchStream(program);
- }}
- >
- Watch Now
-
- )}
-
+
+
+ {recording && (
+
+ )}
+ {program.title}
+
+
+
+ {format(programStart, timeFormat)} -{' '}
+ {format(programEnd, timeFormat)}
+
- )}
-
-
- );
- }
+
+ {program.description && (
+
+
+ {program.description}
+
+
+ )}
+
+ {isExpanded && (
+
+
+ {!isPast && }
+
+ {isLive && }
+
+
+ )}
+
+
+ );
+ },
+ [
+ expandedProgramId,
+ guideScrollLeft,
+ handleProgramClick,
+ handleWatchStream,
+ now,
+ openRecordChoice,
+ recordingsByProgramId,
+ start,
+ timeFormat,
+ ]
+ );
+
+ const contentWidth = useMemo(
+ () => hourTimeline.length * HOUR_WIDTH + CHANNEL_WIDTH,
+ [hourTimeline]
+ );
+
+ const virtualizedHeight = useMemo(() => guideHeight || 600, [guideHeight]);
+
+ const virtualizedWidth = useMemo(() => {
+ if (guideWidth) {
+ return guideWidth;
+ }
+ if (typeof window !== 'undefined') {
+ return Math.min(window.innerWidth, contentWidth);
+ }
+ return contentWidth;
+ }, [guideWidth, contentWidth]);
+
+ const itemKey = useCallback(
+ (index) => filteredChannels[index]?.id ?? index,
+ [filteredChannels]
+ );
+
+ const listData = useMemo(
+ () => ({
+ filteredChannels,
+ programsByChannelId,
+ expandedProgramId,
+ rowHeights,
+ logos,
+ hoveredChannelId,
+ setHoveredChannelId,
+ renderProgram,
+ handleLogoClick,
+ contentWidth,
+ }),
+ [
+ filteredChannels,
+ programsByChannelId,
+ expandedProgramId,
+ rowHeights,
+ logos,
+ hoveredChannelId,
+ renderProgram,
+ handleLogoClick,
+ contentWidth,
+ setHoveredChannelId,
+ ]
+ );
+
+ useEffect(() => {
+ if (listRef.current) {
+ listRef.current.resetAfterIndex(0, true);
+ }
+ }, [rowHeights]);
+
+ useEffect(() => {
+ if (listRef.current) {
+ listRef.current.scrollToItem(0);
+ }
+ }, [searchQuery, selectedGroupId, selectedProfileId]);
// Create group options for dropdown - but only include groups used by guide channels
- const groupOptions = useMemo(() => {
- const options = [{ value: 'all', label: 'All Channel Groups' }];
-
- if (channelGroups && guideChannels.length > 0) {
- // Get unique channel group IDs from the channels that have program data
- const usedGroupIds = new Set();
- guideChannels.forEach((channel) => {
- if (channel.channel_group?.id) {
- usedGroupIds.add(channel.channel_group.id);
- }
- });
-
- // Only add groups that are actually used by channels in the guide
- Object.values(channelGroups)
- .filter((group) => usedGroupIds.has(group.id))
- .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
- .forEach((group) => {
- options.push({
- value: group.id.toString(),
- label: group.name,
- });
- });
- }
-
- return options;
- }, [channelGroups, guideChannels]);
+ const groupOptions = useMemo(
+ () => getGroupOptions(channelGroups, guideChannels),
+ [channelGroups, guideChannels]
+ );
// Create profile options for dropdown
- const profileOptions = useMemo(() => {
- const options = [{ value: 'all', label: 'All Profiles' }];
-
- if (profiles) {
- Object.values(profiles).forEach((profile) => {
- if (profile.id !== '0') {
- // Skip the 'All' default profile
- options.push({
- value: profile.id.toString(),
- label: profile.name,
- });
- }
- });
- }
-
- return options;
- }, [profiles]);
+ const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]);
// Clear all filters
const clearFilters = () => {
@@ -762,38 +991,46 @@ export default function TVChannelGuide({ startDate, endDate }) {
setSelectedProfileId(value || 'all');
};
+ const handleClearSearchQuery = () => {
+ setSearchQuery('');
+ };
+ const handleChangeSearchQuery = (e) => {
+ setSearchQuery(e.target.value);
+ };
+
return (
{/* Sticky top bar */}
{/* Title and current time */}
-
+
TV Guide
- {now.format('dddd, MMMM D, YYYY • h:mm A')}
+
+ {format(now, `dddd, ${dateFormat}, YYYY • ${timeFormat}`)}
+
setSearchQuery(e.target.value)}
- style={{ width: '250px' }} // Reduced width from flex: 1
+ onChange={handleChangeSearchQuery}
+ w={'250px'} // Reduced width from flex: 1
leftSection={ }
rightSection={
searchQuery ? (
setSearchQuery('')}
+ onClick={handleClearSearchQuery}
variant="subtle"
color="gray"
size="sm"
@@ -835,7 +1072,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
data={groupOptions}
value={selectedGroupId}
onChange={handleGroupChange} // Use the new handler
- style={{ width: '220px' }}
+ w={'220px'}
clearable={true} // Allow clearing the selection
/>
@@ -844,19 +1081,32 @@ export default function TVChannelGuide({ startDate, endDate }) {
data={profileOptions}
value={selectedProfileId}
onChange={handleProfileChange} // Use the new handler
- style={{ width: '180px' }}
+ w={'180px'}
clearable={true} // Allow clearing the selection
/>
{(searchQuery !== '' ||
selectedGroupId !== 'all' ||
selectedProfileId !== 'all') && (
-
+
Clear Filters
)}
-
+
+ Series Rules
+
+
+
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
@@ -866,34 +1116,34 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Guide container with headers and scrollable content */}
{/* Logo header - Sticky, non-scrollable */}
{/* Logo header cell - sticky in both directions */}
{/* Timeline header with its own scrollbar */}
@@ -901,121 +1151,33 @@ export default function TVChannelGuide({ startDate, endDate }) {
style={{
flex: 1,
overflow: 'hidden',
- position: 'relative',
}}
+ pos='relative'
>
- {hourTimeline.map((hourData, hourIndex) => {
- const { time, isNewDay, dayLabel } = hourData;
-
- return (
- handleTimeClick(time, e)}
- >
- {/* Remove the special day label for new days since we'll show day for all hours */}
-
- {/* Position time label at the left border of each hour block */}
-
- {/* Show day above time for every hour using the same format */}
-
- {formatDayLabel(time)}{' '}
- {/* Use same formatDayLabel function for all hours */}
-
- {time.format('h:mm')}
-
- {time.format('A')}
-
-
-
- {/* Hour boundary marker - more visible */}
-
-
- {/* Quarter hour tick marks */}
-
- {[15, 30, 45].map((minute) => (
-
- ))}
-
-
- );
- })}
+
@@ -1023,218 +1185,87 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Main scrollable container for program content */}
- {/* Content wrapper with min-width to ensure scroll range */}
-
- {/* Now line - positioned absolutely within content */}
- {nowPosition >= 0 && (
-
- )}
+
+ {nowPosition >= 0 && (
+
+ )}
- {/* Channel rows with logos and programs */}
- {filteredChannels.length > 0 ? (
- filteredChannels.map((channel) => {
- const channelPrograms = programs.filter(
- (p) =>
- (channel.epg_data_id &&
- p.tvg_id === tvgsById[channel.epg_data_id].tvg_id) ||
- (!channel.epg_data_id && p.tvg_id === channel.uuid)
- );
- // Check if any program in this channel is expanded
- const hasExpandedProgram = channelPrograms.some(
- (prog) => prog.id === expandedProgramId
- );
- const rowHeight = hasExpandedProgram
- ? EXPANDED_PROGRAM_HEIGHT
- : PROGRAM_HEIGHT;
-
- return (
-
- {/* Channel logo - sticky horizontally */}
- handleLogoClick(channel, e)}
- onMouseEnter={() => setHoveredChannelId(channel.id)}
- onMouseLeave={() => setHoveredChannelId(null)}
- >
- {/* Play icon overlay - visible on hover (moved outside to cover entire box) */}
- {hoveredChannelId === channel.id && (
-
- {' '}
- {/* Changed from Video to Play and increased size */}
-
- )}
-
- {/* Logo content - restructured for better positioning */}
-
- {/* Logo container with padding */}
-
-
-
-
- {/* Channel number - fixed position at bottom with consistent height */}
-
- {channel.channel_number || '-'}
-
-
-
-
- {/* Programs for this channel */}
-
- {channelPrograms.map((prog) =>
- renderProgram(prog, start)
- )}
-
-
- );
- })
- ) : (
-
- No channels match your filters
-
- Clear Filters
-
-
- )}
-
+ {filteredChannels.length > 0 ? (
+
+ {GuideRow}
+
+ ) : (
+
+ No channels match your filters
+
+ Clear Filters
+
+
+ )}
+ {/* Record choice modal */}
+ {recordChoiceOpen && recordChoiceProgram && (
+
+ }>
+ setRecordChoiceOpen(false)}
+ program={recordChoiceProgram}
+ recording={recordingForProgram}
+ existingRuleMode={existingRuleMode}
+ onRecordOne={() => recordOne(recordChoiceProgram)}
+ onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')}
+ onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')}
+ onExistingRuleModeChange={setExistingRuleMode}
+ />
+
+
+ )}
- {/* Modal removed since we're using expanded rows instead */}
+ {/* Series rules modal */}
+ {rulesOpen && (
+
+ }>
+ setRulesOpen(false)}
+ rules={rules}
+ onRulesUpdate={setRules}
+ />
+
+
+ )}
);
}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
deleted file mode 100644
index ac971332..00000000
--- a/frontend/src/pages/Home.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-// src/components/Home.js
-import React, { useState } from "react";
-
-const Home = () => {
- const [newChannel, setNewChannel] = useState("");
-
- return (
-
-
Home Page
-
- );
-};
-
-export default Home;
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index 262d4c35..3c2cf869 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -1,13 +1,21 @@
-import React from 'react';
+import React, { lazy, Suspense } from 'react';
import LoginForm from '../components/forms/LoginForm';
-import SuperuserForm from '../components/forms/SuperuserForm';
+const SuperuserForm = lazy(() => import('../components/forms/SuperuserForm'));
import useAuthStore from '../store/auth';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import { Text } from '@mantine/core';
const Login = ({}) => {
const superuserExists = useAuthStore((s) => s.superuserExists);
if (!superuserExists) {
- return
;
+ return (
+
+ Loading...}>
+
+
+
+ );
}
return
;
diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx
new file mode 100644
index 00000000..f95212d6
--- /dev/null
+++ b/frontend/src/pages/Logos.jsx
@@ -0,0 +1,88 @@
+import React, { useEffect, useCallback, useState } from 'react';
+import { Box, Tabs, Flex, Text, TabsList, TabsTab } from '@mantine/core';
+import useLogosStore from '../store/logos';
+import useVODLogosStore from '../store/vodLogos';
+import LogosTable from '../components/tables/LogosTable';
+import VODLogosTable from '../components/tables/VODLogosTable';
+import { showNotification } from '../utils/notificationUtils.js';
+
+const LogosPage = () => {
+ const logos = useLogosStore(s => s.logos);
+ const totalCount = useVODLogosStore(s => s.totalCount);
+ const [activeTab, setActiveTab] = useState('channel');
+ const logoCount = activeTab === 'channel'
+ ? Object.keys(logos).length
+ : totalCount;
+
+ const loadChannelLogos = useCallback(async () => {
+ try {
+ // Only fetch all logos if we haven't loaded them yet
+ if (useLogosStore.getState().needsAllLogos()) {
+ await useLogosStore.getState().fetchAllLogos();
+ }
+ } catch (err) {
+ showNotification({
+ title: 'Error',
+ message: 'Failed to load channel logos',
+ color: 'red',
+ });
+ console.error('Failed to load channel logos:', err);
+ }
+ }, []);
+
+ useEffect(() => {
+ // Always load channel logos on mount
+ loadChannelLogos();
+ }, [loadChannelLogos]);
+
+ return (
+
+ {/* Header with title and tabs */}
+
+
+
+
+ Logos
+
+
+ ({logoCount} {logoCount !== 1 ? 'logos' : 'logo'})
+
+
+
+
+
+ Channel Logos
+ VOD Logos
+
+
+
+
+
+ {/* Content based on active tab */}
+ {activeTab === 'channel' && }
+ {activeTab === 'vod' && }
+
+ );
+};
+
+export default LogosPage;
diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx
new file mode 100644
index 00000000..21df7faf
--- /dev/null
+++ b/frontend/src/pages/Plugins.jsx
@@ -0,0 +1,489 @@
+import React, {
+ Suspense,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {
+ ActionIcon,
+ Alert,
+ AppShellMain,
+ Box,
+ Button,
+ Divider,
+ FileInput,
+ Group,
+ Loader,
+ Modal,
+ SimpleGrid,
+ Stack,
+ Switch,
+ Text,
+} from '@mantine/core';
+import { Dropzone } from '@mantine/dropzone';
+import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
+import { usePluginStore } from '../store/plugins.jsx';
+import {
+ deletePluginByKey,
+ importPlugin,
+ runPluginAction,
+ setPluginEnabled,
+ updatePluginSettings,
+} from '../utils/pages/PluginsUtils.js';
+import { RefreshCcw } from 'lucide-react';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+const PluginCard = React.lazy(() =>
+ import('../components/cards/PluginCard.jsx'));
+
+const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
+ const plugins = usePluginStore((state) => state.plugins);
+ const loading = usePluginStore((state) => state.loading);
+ const hasFetchedRef = useRef(false);
+
+ useEffect(() => {
+ if (!hasFetchedRef.current) {
+ hasFetchedRef.current = true;
+ usePluginStore.getState().fetchPlugins();
+ }
+ }, []);
+
+ const handleTogglePluginEnabled = async (key, next) => {
+ const resp = await setPluginEnabled(key, next);
+
+ if (resp?.success) {
+ usePluginStore.getState().updatePlugin(key, {
+ enabled: next,
+ ever_enabled: resp?.ever_enabled,
+ });
+ }
+ };
+
+ if (loading && plugins.length === 0) {
+ return
;
+ }
+
+ return (
+ <>
+ {plugins.length > 0 &&
+
+
+ }>
+ {plugins.map((p) => (
+
+ ))}
+
+
+
+ }
+
+ {plugins.length === 0 && (
+
+
+ No plugins found. Drop a plugin into /data/plugins{' '}
+ and reload.
+
+
+ )}
+ >
+ );
+};
+
+export default function PluginsPage() {
+ const [importOpen, setImportOpen] = useState(false);
+ const [importFile, setImportFile] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const [imported, setImported] = useState(null);
+ const [enableAfterImport, setEnableAfterImport] = useState(false);
+ const [trustOpen, setTrustOpen] = useState(false);
+ const [trustResolve, setTrustResolve] = useState(null);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [confirmConfig, setConfirmConfig] = useState({
+ title: '',
+ message: '',
+ resolve: null,
+ });
+
+ const handleReload = () => {
+ usePluginStore.getState().invalidatePlugins();
+ };
+
+ const handleRequestDelete = useCallback((pl) => {
+ setDeleteTarget(pl);
+ setDeleteOpen(true);
+ }, []);
+
+ const requireTrust = useCallback((plugin) => {
+ return new Promise((resolve) => {
+ setTrustResolve(() => resolve);
+ setTrustOpen(true);
+ });
+ }, []);
+
+ const showImportForm = useCallback(() => {
+ setImportOpen(true);
+ setImported(null);
+ setImportFile(null);
+ setEnableAfterImport(false);
+ }, []);
+
+ const requestConfirm = useCallback((title, message) => {
+ return new Promise((resolve) => {
+ setConfirmConfig({ title, message, resolve });
+ setConfirmOpen(true);
+ });
+ }, []);
+
+ const handleImportPlugin = () => {
+ return async () => {
+ setImporting(true);
+ const id = showNotification({
+ title: 'Uploading plugin',
+ message: 'Backend may restart; please wait…',
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ });
+ try {
+ const resp = await importPlugin(importFile);
+ if (resp?.success && resp.plugin) {
+ setImported(resp.plugin);
+ usePluginStore.getState().invalidatePlugins();
+
+ updateNotification({
+ id,
+ loading: false,
+ color: 'green',
+ title: 'Imported',
+ message:
+ 'Plugin imported. If the app briefly disconnected, it should be back now.',
+ autoClose: 3000,
+ });
+ } else {
+ updateNotification({
+ id,
+ loading: false,
+ color: 'red',
+ title: 'Import failed',
+ message: resp?.error || 'Unknown error',
+ autoClose: 5000,
+ });
+ }
+ } catch (e) {
+ // API.importPlugin already showed a concise error; just update the loading notice
+ updateNotification({
+ id,
+ loading: false,
+ color: 'red',
+ title: 'Import failed',
+ message:
+ (e?.body && (e.body.error || e.body.detail)) ||
+ e?.message ||
+ 'Failed',
+ autoClose: 5000,
+ });
+ } finally {
+ setImporting(false);
+ }
+ };
+ };
+
+ const handleEnablePlugin = () => {
+ return async () => {
+ if (!imported) return;
+
+ const proceed = imported.ever_enabled || (await requireTrust(imported));
+ if (proceed) {
+ const resp = await setPluginEnabled(imported.key, true);
+ if (resp?.success) {
+ usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true });
+
+ showNotification({
+ title: imported.name,
+ message: 'Plugin enabled',
+ color: 'green',
+ });
+ }
+ setImportOpen(false);
+ setImported(null);
+ setEnableAfterImport(false);
+ }
+ };
+ };
+
+ const handleDeletePlugin = () => {
+ return async () => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ const resp = await deletePluginByKey(deleteTarget.key);
+ if (resp?.success) {
+ usePluginStore.getState().removePlugin(deleteTarget.key);
+
+ showNotification({
+ title: deleteTarget.name,
+ message: 'Plugin deleted',
+ color: 'green',
+ });
+ }
+ setDeleteOpen(false);
+ setDeleteTarget(null);
+ } finally {
+ setDeleting(false);
+ }
+ };
+ };
+
+ const handleConfirm = useCallback((confirmed) => {
+ const resolver = confirmConfig.resolve;
+ setConfirmOpen(false);
+ setConfirmConfig({ title: '', message: '', resolve: null });
+ if (resolver) resolver(confirmed);
+ }, [confirmConfig.resolve]);
+
+ return (
+
+
+
+ Plugins
+
+
+
+ Import Plugin
+
+
+
+
+
+
+
+
+
+ {/* Import Plugin Modal */}
+ setImportOpen(false)}
+ title="Import Plugin"
+ centered
+ >
+
+
+ Upload a ZIP containing your plugin folder or package.
+
+
+ Importing a plugin may briefly restart the backend (you might see a
+ temporary disconnect). Please wait a few seconds and the app will
+ reconnect automatically.
+
+ files[0] && setImportFile(files[0])}
+ onReject={() => {}}
+ maxFiles={1}
+ accept={[
+ 'application/zip',
+ 'application/x-zip-compressed',
+ 'application/octet-stream',
+ ]}
+ multiple={false}
+ >
+
+ Drag and drop plugin .zip here
+
+
+
+
+ setImportOpen(false)}
+ size="xs"
+ >
+ Close
+
+
+ Upload
+
+
+ {imported && (
+
+
+ {imported.name}
+
+ {imported.description}
+
+
+ Enable now
+
+ setEnableAfterImport(e.currentTarget.checked)
+ }
+ />
+
+
+ {
+ setImportOpen(false);
+ setImported(null);
+ setImportFile(null);
+ setEnableAfterImport(false);
+ }}
+ >
+ Done
+
+
+ Enable
+
+
+
+ )}
+
+
+
+ {/* Trust Warning Modal */}
+ {
+ setTrustOpen(false);
+ trustResolve && trustResolve(false);
+ }}
+ title="Enable third-party plugins?"
+ centered
+ >
+
+
+ Plugins run server-side code with full access to your Dispatcharr
+ instance and its data. Only enable plugins from developers you
+ trust.
+
+
+ Why: Malicious plugins could read or modify data, call internal
+ APIs, or perform unwanted actions. Review the source or trust the
+ author before enabling.
+
+
+ {
+ setTrustOpen(false);
+ trustResolve && trustResolve(false);
+ }}
+ >
+ Cancel
+
+ {
+ setTrustOpen(false);
+ trustResolve && trustResolve(true);
+ }}
+ >
+ I understand, enable
+
+
+
+
+
+ {/* Delete Plugin Modal */}
+ {
+ setDeleteOpen(false);
+ setDeleteTarget(null);
+ }}
+ title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'}
+ centered
+ >
+
+
+ This will remove the plugin files and its configuration. This action
+ cannot be undone.
+
+
+ {
+ setDeleteOpen(false);
+ setDeleteTarget(null);
+ }}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ {/* Confirmation modal */}
+ handleConfirm(false)}
+ title={confirmConfig.title}
+ centered
+ >
+
+ {confirmConfig.message}
+
+ handleConfirm(false)}
+ >
+ Cancel
+
+ handleConfirm(true)}>
+ Confirm
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index d9f716db..4ce519a3 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,430 +1,167 @@
-import React, { useEffect } from 'react';
-import API from '../api';
-import useSettingsStore from '../store/settings';
-import useUserAgentsStore from '../store/userAgents';
-import useStreamProfilesStore from '../store/streamProfiles';
+import React, { Suspense, useState } from 'react';
import {
+ Accordion,
+ AccordionControl,
+ AccordionItem,
+ AccordionPanel,
Box,
- Button,
Center,
- Flex,
- Group,
- Paper,
- Select,
- Stack,
- Switch,
Text,
- Title,
+ Loader
} from '@mantine/core';
-import { isNotEmpty, useForm } from '@mantine/form';
-import UserAgentsTable from '../components/tables/UserAgentsTable';
-import StreamProfilesTable from '../components/tables/StreamProfilesTable';
-import { useLocalStorage } from '@mantine/hooks';
+const UserAgentsTable = React.lazy(() =>
+ import('../components/tables/UserAgentsTable.jsx'));
+const StreamProfilesTable = React.lazy(() =>
+ import('../components/tables/StreamProfilesTable.jsx'));
+const BackupManager = React.lazy(() =>
+ import('../components/backups/BackupManager.jsx'));
+import useAuthStore from '../store/auth';
+import { USER_LEVELS } from '../constants';
+import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+const NetworkAccessForm = React.lazy(() =>
+ import('../components/forms/settings/NetworkAccessForm.jsx'));
+const ProxySettingsForm = React.lazy(() =>
+ import('../components/forms/settings/ProxySettingsForm.jsx'));
+const StreamSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/StreamSettingsForm.jsx'));
+const DvrSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/DvrSettingsForm.jsx'));
+const SystemSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/SystemSettingsForm.jsx'));
const SettingsPage = () => {
- const settings = useSettingsStore((s) => s.settings);
- const userAgents = useUserAgentsStore((s) => s.userAgents);
- const streamProfiles = useStreamProfilesStore((s) => s.profiles);
+ const authUser = useAuthStore((s) => s.user);
- const regionChoices = [
- { value: 'ad', label: 'AD' },
- { value: 'ae', label: 'AE' },
- { value: 'af', label: 'AF' },
- { value: 'ag', label: 'AG' },
- { value: 'ai', label: 'AI' },
- { value: 'al', label: 'AL' },
- { value: 'am', label: 'AM' },
- { value: 'ao', label: 'AO' },
- { value: 'aq', label: 'AQ' },
- { value: 'ar', label: 'AR' },
- { value: 'as', label: 'AS' },
- { value: 'at', label: 'AT' },
- { value: 'au', label: 'AU' },
- { value: 'aw', label: 'AW' },
- { value: 'ax', label: 'AX' },
- { value: 'az', label: 'AZ' },
- { value: 'ba', label: 'BA' },
- { value: 'bb', label: 'BB' },
- { value: 'bd', label: 'BD' },
- { value: 'be', label: 'BE' },
- { value: 'bf', label: 'BF' },
- { value: 'bg', label: 'BG' },
- { value: 'bh', label: 'BH' },
- { value: 'bi', label: 'BI' },
- { value: 'bj', label: 'BJ' },
- { value: 'bl', label: 'BL' },
- { value: 'bm', label: 'BM' },
- { value: 'bn', label: 'BN' },
- { value: 'bo', label: 'BO' },
- { value: 'bq', label: 'BQ' },
- { value: 'br', label: 'BR' },
- { value: 'bs', label: 'BS' },
- { value: 'bt', label: 'BT' },
- { value: 'bv', label: 'BV' },
- { value: 'bw', label: 'BW' },
- { value: 'by', label: 'BY' },
- { value: 'bz', label: 'BZ' },
- { value: 'ca', label: 'CA' },
- { value: 'cc', label: 'CC' },
- { value: 'cd', label: 'CD' },
- { value: 'cf', label: 'CF' },
- { value: 'cg', label: 'CG' },
- { value: 'ch', label: 'CH' },
- { value: 'ci', label: 'CI' },
- { value: 'ck', label: 'CK' },
- { value: 'cl', label: 'CL' },
- { value: 'cm', label: 'CM' },
- { value: 'cn', label: 'CN' },
- { value: 'co', label: 'CO' },
- { value: 'cr', label: 'CR' },
- { value: 'cu', label: 'CU' },
- { value: 'cv', label: 'CV' },
- { value: 'cw', label: 'CW' },
- { value: 'cx', label: 'CX' },
- { value: 'cy', label: 'CY' },
- { value: 'cz', label: 'CZ' },
- { value: 'de', label: 'DE' },
- { value: 'dj', label: 'DJ' },
- { value: 'dk', label: 'DK' },
- { value: 'dm', label: 'DM' },
- { value: 'do', label: 'DO' },
- { value: 'dz', label: 'DZ' },
- { value: 'ec', label: 'EC' },
- { value: 'ee', label: 'EE' },
- { value: 'eg', label: 'EG' },
- { value: 'eh', label: 'EH' },
- { value: 'er', label: 'ER' },
- { value: 'es', label: 'ES' },
- { value: 'et', label: 'ET' },
- { value: 'fi', label: 'FI' },
- { value: 'fj', label: 'FJ' },
- { value: 'fk', label: 'FK' },
- { value: 'fm', label: 'FM' },
- { value: 'fo', label: 'FO' },
- { value: 'fr', label: 'FR' },
- { value: 'ga', label: 'GA' },
- { value: 'gb', label: 'GB' },
- { value: 'gd', label: 'GD' },
- { value: 'ge', label: 'GE' },
- { value: 'gf', label: 'GF' },
- { value: 'gg', label: 'GG' },
- { value: 'gh', label: 'GH' },
- { value: 'gi', label: 'GI' },
- { value: 'gl', label: 'GL' },
- { value: 'gm', label: 'GM' },
- { value: 'gn', label: 'GN' },
- { value: 'gp', label: 'GP' },
- { value: 'gq', label: 'GQ' },
- { value: 'gr', label: 'GR' },
- { value: 'gs', label: 'GS' },
- { value: 'gt', label: 'GT' },
- { value: 'gu', label: 'GU' },
- { value: 'gw', label: 'GW' },
- { value: 'gy', label: 'GY' },
- { value: 'hk', label: 'HK' },
- { value: 'hm', label: 'HM' },
- { value: 'hn', label: 'HN' },
- { value: 'hr', label: 'HR' },
- { value: 'ht', label: 'HT' },
- { value: 'hu', label: 'HU' },
- { value: 'id', label: 'ID' },
- { value: 'ie', label: 'IE' },
- { value: 'il', label: 'IL' },
- { value: 'im', label: 'IM' },
- { value: 'in', label: 'IN' },
- { value: 'io', label: 'IO' },
- { value: 'iq', label: 'IQ' },
- { value: 'ir', label: 'IR' },
- { value: 'is', label: 'IS' },
- { value: 'it', label: 'IT' },
- { value: 'je', label: 'JE' },
- { value: 'jm', label: 'JM' },
- { value: 'jo', label: 'JO' },
- { value: 'jp', label: 'JP' },
- { value: 'ke', label: 'KE' },
- { value: 'kg', label: 'KG' },
- { value: 'kh', label: 'KH' },
- { value: 'ki', label: 'KI' },
- { value: 'km', label: 'KM' },
- { value: 'kn', label: 'KN' },
- { value: 'kp', label: 'KP' },
- { value: 'kr', label: 'KR' },
- { value: 'kw', label: 'KW' },
- { value: 'ky', label: 'KY' },
- { value: 'kz', label: 'KZ' },
- { value: 'la', label: 'LA' },
- { value: 'lb', label: 'LB' },
- { value: 'lc', label: 'LC' },
- { value: 'li', label: 'LI' },
- { value: 'lk', label: 'LK' },
- { value: 'lr', label: 'LR' },
- { value: 'ls', label: 'LS' },
- { value: 'lt', label: 'LT' },
- { value: 'lu', label: 'LU' },
- { value: 'lv', label: 'LV' },
- { value: 'ly', label: 'LY' },
- { value: 'ma', label: 'MA' },
- { value: 'mc', label: 'MC' },
- { value: 'md', label: 'MD' },
- { value: 'me', label: 'ME' },
- { value: 'mf', label: 'MF' },
- { value: 'mg', label: 'MG' },
- { value: 'mh', label: 'MH' },
- { value: 'ml', label: 'ML' },
- { value: 'mm', label: 'MM' },
- { value: 'mn', label: 'MN' },
- { value: 'mo', label: 'MO' },
- { value: 'mp', label: 'MP' },
- { value: 'mq', label: 'MQ' },
- { value: 'mr', label: 'MR' },
- { value: 'ms', label: 'MS' },
- { value: 'mt', label: 'MT' },
- { value: 'mu', label: 'MU' },
- { value: 'mv', label: 'MV' },
- { value: 'mw', label: 'MW' },
- { value: 'mx', label: 'MX' },
- { value: 'my', label: 'MY' },
- { value: 'mz', label: 'MZ' },
- { value: 'na', label: 'NA' },
- { value: 'nc', label: 'NC' },
- { value: 'ne', label: 'NE' },
- { value: 'nf', label: 'NF' },
- { value: 'ng', label: 'NG' },
- { value: 'ni', label: 'NI' },
- { value: 'nl', label: 'NL' },
- { value: 'no', label: 'NO' },
- { value: 'np', label: 'NP' },
- { value: 'nr', label: 'NR' },
- { value: 'nu', label: 'NU' },
- { value: 'nz', label: 'NZ' },
- { value: 'om', label: 'OM' },
- { value: 'pa', label: 'PA' },
- { value: 'pe', label: 'PE' },
- { value: 'pf', label: 'PF' },
- { value: 'pg', label: 'PG' },
- { value: 'ph', label: 'PH' },
- { value: 'pk', label: 'PK' },
- { value: 'pl', label: 'PL' },
- { value: 'pm', label: 'PM' },
- { value: 'pn', label: 'PN' },
- { value: 'pr', label: 'PR' },
- { value: 'ps', label: 'PS' },
- { value: 'pt', label: 'PT' },
- { value: 'pw', label: 'PW' },
- { value: 'py', label: 'PY' },
- { value: 'qa', label: 'QA' },
- { value: 're', label: 'RE' },
- { value: 'ro', label: 'RO' },
- { value: 'rs', label: 'RS' },
- { value: 'ru', label: 'RU' },
- { value: 'rw', label: 'RW' },
- { value: 'sa', label: 'SA' },
- { value: 'sb', label: 'SB' },
- { value: 'sc', label: 'SC' },
- { value: 'sd', label: 'SD' },
- { value: 'se', label: 'SE' },
- { value: 'sg', label: 'SG' },
- { value: 'sh', label: 'SH' },
- { value: 'si', label: 'SI' },
- { value: 'sj', label: 'SJ' },
- { value: 'sk', label: 'SK' },
- { value: 'sl', label: 'SL' },
- { value: 'sm', label: 'SM' },
- { value: 'sn', label: 'SN' },
- { value: 'so', label: 'SO' },
- { value: 'sr', label: 'SR' },
- { value: 'ss', label: 'SS' },
- { value: 'st', label: 'ST' },
- { value: 'sv', label: 'SV' },
- { value: 'sx', label: 'SX' },
- { value: 'sy', label: 'SY' },
- { value: 'sz', label: 'SZ' },
- { value: 'tc', label: 'TC' },
- { value: 'td', label: 'TD' },
- { value: 'tf', label: 'TF' },
- { value: 'tg', label: 'TG' },
- { value: 'th', label: 'TH' },
- { value: 'tj', label: 'TJ' },
- { value: 'tk', label: 'TK' },
- { value: 'tl', label: 'TL' },
- { value: 'tm', label: 'TM' },
- { value: 'tn', label: 'TN' },
- { value: 'to', label: 'TO' },
- { value: 'tr', label: 'TR' },
- { value: 'tt', label: 'TT' },
- { value: 'tv', label: 'TV' },
- { value: 'tw', label: 'TW' },
- { value: 'tz', label: 'TZ' },
- { value: 'ua', label: 'UA' },
- { value: 'ug', label: 'UG' },
- { value: 'um', label: 'UM' },
- { value: 'us', label: 'US' },
- { value: 'uy', label: 'UY' },
- { value: 'uz', label: 'UZ' },
- { value: 'va', label: 'VA' },
- { value: 'vc', label: 'VC' },
- { value: 've', label: 'VE' },
- { value: 'vg', label: 'VG' },
- { value: 'vi', label: 'VI' },
- { value: 'vn', label: 'VN' },
- { value: 'vu', label: 'VU' },
- { value: 'wf', label: 'WF' },
- { value: 'ws', label: 'WS' },
- { value: 'ye', label: 'YE' },
- { value: 'yt', label: 'YT' },
- { value: 'za', label: 'ZA' },
- { value: 'zm', label: 'ZM' },
- { value: 'zw', label: 'ZW' },
- ];
-
- const form = useForm({
- mode: 'uncontrolled',
- initialValues: {
- 'default-user-agent': '',
- 'default-stream-profile': '',
- 'preferred-region': '',
- 'auto-import-mapped-files': true,
- },
-
- validate: {
- 'default-user-agent': isNotEmpty('Select a channel'),
- 'default-stream-profile': isNotEmpty('Select a start time'),
- 'preferred-region': isNotEmpty('Select an end time'),
- },
- });
-
- useEffect(() => {
- if (settings) {
- form.setValues(
- Object.entries(settings).reduce((acc, [key, value]) => {
- // Modify each value based on its own properties
- switch (value.value) {
- case 'true':
- value.value = true;
- break;
- case 'false':
- value.value = false;
- break;
- }
-
- acc[key] = value.value;
- return acc;
- }, {})
- );
- }
- }, [settings]);
-
- const onSubmit = async () => {
- const values = form.getValues();
- const changedSettings = {};
- for (const settingKey in values) {
- // If the user changed the setting’s value from what’s in the DB:
- if (String(values[settingKey]) !== String(settings[settingKey].value)) {
- changedSettings[settingKey] = `${values[settingKey]}`;
- }
- }
-
- // Update each changed setting in the backend
- for (const updatedKey in changedSettings) {
- await API.updateSetting({
- ...settings[updatedKey],
- value: changedSettings[updatedKey],
- });
- }
- };
+ const [accordianValue, setAccordianValue] = useState(null);
return (
-
-
-
+
+
-
- Settings
-
-
-
-
+
+ System Settings
+
+
+ }>
+
+
+
+
+
-
-
-
-
-
+
+ User-Agents
+
+
+ }>
+
+
+
+
+
+
+
+ Stream Profiles
+
+
+ }>
+
+
+
+
+
+
+
+
+ Network Access
+ {accordianValue === 'network-access' && (
+
+ Comma-Delimited CIDR ranges
+
+ )}
+
+
+
+ }>
+
+
+
+
+
+
+
+
+ Proxy Settings
+
+
+
+ }>
+
+
+
+
+
+
+
+ Backup & Restore
+
+
+ }>
+
+
+
+
+
+ >
+ )}
+
+
+
);
};
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index fc6705b0..19702ae6 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -1,717 +1,302 @@
-import React, { useMemo, useState, useEffect } from 'react';
-import {
- ActionIcon,
- Box,
- Card,
- Center,
- Container,
- Flex,
- Group,
- SimpleGrid,
- Stack,
- Text,
- Title,
- Tooltip,
- useMantineTheme,
- Select,
-} from '@mantine/core';
-import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
-import { TableHelper } from '../helpers';
-import API from '../api';
+import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { Box, Button, Group, LoadingOverlay, NumberInput, Text, Title, } from '@mantine/core';
import useChannelsStore from '../store/channels';
-import logo from '../images/logo.png';
-import {
- Gauge,
- HardDriveDownload,
- HardDriveUpload,
- SquareX,
- Timer,
- Users,
- Video,
-} from 'lucide-react';
-import dayjs from 'dayjs';
-import duration from 'dayjs/plugin/duration';
-import relativeTime from 'dayjs/plugin/relativeTime';
-import { Sparkline } from '@mantine/charts';
+import useLogosStore from '../store/logos';
import useStreamProfilesStore from '../store/streamProfiles';
-import usePlaylistsStore from '../store/playlists'; // Add this import
-import { useLocation } from 'react-router-dom';
-import { notifications } from '@mantine/notifications';
+import useLocalStorage from '../hooks/useLocalStorage';
+import SystemEvents from '../components/SystemEvents';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ 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'));
-dayjs.extend(duration);
-dayjs.extend(relativeTime);
+const Connections = ({
+ combinedConnections,
+ clients,
+ channelsByUUID,
+ handleStopVODClient,
+}) => {
+ const logos = useLogosStore((s) => s.logos);
-function formatBytes(bytes) {
- if (bytes === 0) return '0 Bytes';
-
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
-
- return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
-}
-
-function formatSpeed(bytes) {
- if (bytes === 0) return '0 Bytes';
-
- const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
-
- return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
-}
-
-const getStartDate = (uptime) => {
- // Get the current date and time
- const currentDate = new Date();
- // Calculate the start date by subtracting uptime (in milliseconds)
- const startDate = new Date(currentDate.getTime() - uptime * 1000);
- // Format the date as a string (you can adjust the format as needed)
- return startDate.toLocaleString({
- weekday: 'short', // optional, adds day of the week
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: true, // 12-hour format with AM/PM
- });
-};
-
-// Create a separate component for each channel card to properly handle the hook
-const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channelsByUUID }) => {
- const location = useLocation();
- const [availableStreams, setAvailableStreams] = useState([]);
- const [isLoadingStreams, setIsLoadingStreams] = useState(false);
- const [activeStreamId, setActiveStreamId] = useState(null);
- const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
-
- // Get M3U account data from the playlists store
- const m3uAccounts = usePlaylistsStore((s) => s.playlists);
-
- // Create a map of M3U account IDs to names for quick lookup
- const m3uAccountsMap = useMemo(() => {
- const map = {};
- if (m3uAccounts && Array.isArray(m3uAccounts)) {
- m3uAccounts.forEach(account => {
- if (account.id) {
- map[account.id] = account.name;
- }
- });
- }
- return map;
- }, [m3uAccounts]);
-
- // Safety check - if channel doesn't have required data, don't render
- if (!channel || !channel.channel_id) {
- return null;
- }
-
- // Update M3U profile information when channel data changes
- useEffect(() => {
- // If the channel data includes M3U profile information, update our state
- if (channel.m3u_profile || channel.m3u_profile_name) {
- setCurrentM3UProfile({
- name: channel.m3u_profile?.name || channel.m3u_profile_name || 'Default M3U'
- });
- }
- }, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]);
-
- // Fetch available streams for this channel
- useEffect(() => {
- const fetchStreams = async () => {
- setIsLoadingStreams(true);
- try {
- // Get channel ID from UUID
- const channelId = channelsByUUID[channel.channel_id];
- if (channelId) {
- const streamData = await API.getChannelStreams(channelId);
-
- // Use streams in the order returned by the API without sorting
- setAvailableStreams(streamData);
-
- // If we have a channel URL, try to find the matching stream
- if (channel.url && streamData.length > 0) {
- // Try to find matching stream based on URL
- const matchingStream = streamData.find(stream =>
- channel.url.includes(stream.url) || stream.url.includes(channel.url)
- );
-
- if (matchingStream) {
- setActiveStreamId(matchingStream.id.toString());
-
- // If the stream has M3U profile info, save it
- if (matchingStream.m3u_profile) {
- setCurrentM3UProfile(matchingStream.m3u_profile);
- }
- }
- }
- }
- } catch (error) {
- console.error("Error fetching streams:", error);
- } finally {
- setIsLoadingStreams(false);
- }
- };
-
- fetchStreams();
- }, [channel.channel_id, channel.url, channelsByUUID]);
-
- // Handle stream switching
- const handleStreamChange = async (streamId) => {
- try {
- console.log("Switching to stream ID:", streamId);
- // Find the selected stream in availableStreams for debugging
- const selectedStream = availableStreams.find(s => s.id.toString() === streamId);
- console.log("Selected stream details:", selectedStream);
-
- // Make sure we're passing the correct ID to the API
- const response = await API.switchStream(channel.channel_id, streamId);
- console.log("Stream switch API response:", response);
-
- // Update the local active stream ID immediately
- setActiveStreamId(streamId);
-
- // Update M3U profile information if available in the response
- if (response && response.m3u_profile) {
- setCurrentM3UProfile(response.m3u_profile);
- } else if (selectedStream && selectedStream.m3u_profile) {
- // Fallback to the profile from the selected stream
- setCurrentM3UProfile(selectedStream.m3u_profile);
- }
-
- // Show detailed notification with stream name
- notifications.show({
- title: 'Stream switching',
- message: `Switching to "${selectedStream?.name}" for ${channel.name}`,
- color: 'blue.5',
- });
-
- // After a short delay, fetch streams again to confirm the switch
- setTimeout(async () => {
- try {
- const channelId = channelsByUUID[channel.channel_id];
- if (channelId) {
- const updatedStreamData = await API.getChannelStreams(channelId);
- console.log("Channel streams after switch:", updatedStreamData);
-
- // Update current stream information with fresh data
- const updatedStream = updatedStreamData.find(s => s.id.toString() === streamId);
- if (updatedStream && updatedStream.m3u_profile) {
- setCurrentM3UProfile(updatedStream.m3u_profile);
- }
- }
- } catch (error) {
- console.error("Error checking streams after switch:", error);
- }
- }, 2000);
-
- } catch (error) {
- console.error("Stream switch error:", error);
- notifications.show({
- title: 'Error switching stream',
- message: error.toString(),
- color: 'red.5',
- });
- }
- };
-
- const clientsColumns = useMemo(
- () => [
- {
- header: 'IP Address',
- accessorKey: 'ip_address',
- size: 50,
- },
- // Updated Connected column with tooltip
- {
- header: 'Connected',
- accessorFn: (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 currentTime = dayjs();
- const connectedTime = currentTime.subtract(row.connected_since, 'second');
- return connectedTime.format('MM/DD HH:mm:ss');
- }
-
- // Fallback to connected_at if it exists
- if (row.connected_at) {
- const connectedTime = dayjs(row.connected_at * 1000);
- return connectedTime.format('MM/DD HH:mm:ss');
- }
-
- return 'Unknown';
- },
- Cell: ({ cell }) => (
-
- {cell.getValue()}
-
- ),
- size: 50,
- },
- // Update Duration column with tooltip showing exact seconds
- {
- header: 'Duration',
- accessorFn: (row) => {
- if (row.connected_since) {
- return dayjs.duration(row.connected_since, 'seconds').humanize();
- }
-
- if (row.connection_duration) {
- return dayjs.duration(row.connection_duration, 'seconds').humanize();
- }
-
- return '-';
- },
- Cell: ({ cell, row }) => {
- const exactDuration = row.original.connected_since || row.original.connection_duration;
- return (
-
- {cell.getValue()}
-
- );
- },
- size: 50,
- }
- ],
- []
- );
-
- // This hook is now at the top level of this component
- const channelClientsTable = useMantineReactTable({
- ...TableHelper.defaultProperties,
- columns: clientsColumns,
- data: clients.filter(
- (client) => client.channel.channel_id === channel.channel_id
- ),
- enablePagination: false,
- enableTopToolbar: false,
- enableBottomToolbar: false,
- enableRowSelection: false,
- enableColumnFilters: false,
- mantineTableBodyCellProps: {
- style: {
- padding: 4,
- borderColor: '#444',
- color: '#E0E0E0',
- fontSize: '0.85rem',
- },
- },
- enableRowActions: true,
- renderRowActions: ({ row }) => (
-
-
-
-
- stopClient(row.original.channel.uuid, row.original.client_id)
- }
- >
-
-
-
-
-
- ),
- renderDetailPanel: ({ row }) => (
-
-
- User Agent:
- {row.original.user_agent || "Unknown"}
-
-
- ),
- mantineExpandButtonProps: ({ row, table }) => ({
- size: 'xs',
- style: {
- transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
- transition: 'transform 0.2s',
- },
- }),
- displayColumnDefOptions: {
- 'mrt-row-expand': {
- size: 15,
- header: '',
- },
- 'mrt-row-actions': {
- size: 74,
- },
- },
- });
-
- if (location.pathname != '/stats') {
- return <>>;
- }
-
- // Get logo URL from the logos object if available
- const logoUrl = channel.logo_id && logos && logos[channel.logo_id] ?
- logos[channel.logo_id].cache_url : null;
-
- // Ensure these values exist to prevent errors
- const channelName = channel.name || 'Unnamed Channel';
- const uptime = channel.uptime || 0;
- const bitrates = channel.bitrates || [];
- const totalBytes = channel.total_bytes || 0;
- const clientCount = channel.client_count || 0;
- const avgBitrate = channel.avg_bitrate || '0 Kbps';
- const streamProfileName = channel.stream_profile?.name || 'Unknown Profile';
-
- // Use currentM3UProfile if available, otherwise fall back to channel data
- const m3uProfileName = currentM3UProfile?.name ||
- channel.m3u_profile?.name ||
- channel.m3u_profile_name ||
- 'Unknown M3U Profile';
-
- // Create select options for available streams
- const streamOptions = availableStreams.map(stream => {
- // Get account name from our mapping if it exists
- const accountName = stream.m3u_account && m3uAccountsMap[stream.m3u_account]
- ? m3uAccountsMap[stream.m3u_account]
- : stream.m3u_account
- ? `M3U #${stream.m3u_account}`
- : 'Unknown M3U';
-
- return {
- value: stream.id.toString(),
- label: `${stream.name || `Stream #${stream.id}`} [${accountName}]`,
- };
- });
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {dayjs.duration(uptime, 'seconds').humanize()}
-
-
-
-
-
- stopChannel(channel.channel_id)}
- >
-
-
-
-
-
-
-
-
-
- {channelName}
-
-
-
-
-
- {streamProfileName}
-
-
-
-
- {/* Display M3U profile information */}
-
-
-
-
- {m3uProfileName}
-
-
-
-
- {/* Add stream selection dropdown */}
- {availableStreams.length > 0 && (
-
-
-
- )}
-
-
-
-
-
-
- {formatSpeed(bitrates.at(-1) || 0)}
-
-
-
-
-
- Avg: {avgBitrate}
-
-
-
-
-
-
- {formatBytes(totalBytes)}
-
-
-
-
-
-
-
-
- {clientCount}
-
-
-
-
-
-
-
-
+
+ No active connections
+
+
+ ) : (
+
+ }>
+ {combinedConnections.map((connection) => {
+ if (connection.type === 'stream') {
+ return (
+
+ );
+ } else if (connection.type === 'vod') {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
);
};
-const ChannelsPage = () => {
- const theme = useMantineTheme();
-
+const StatsPage = () => {
const channels = useChannelsStore((s) => s.channels);
const channelsByUUID = useChannelsStore((s) => s.channelsByUUID);
const channelStats = useChannelsStore((s) => s.stats);
- const logos = useChannelsStore((s) => s.logos); // Add logos from the store
+ const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
- const [activeChannels, setActiveChannels] = useState({});
const [clients, setClients] = useState([]);
+ const [vodConnections, setVodConnections] = useState([]);
+ const [channelHistory, setChannelHistory] = useState({});
+ const [isPollingActive, setIsPollingActive] = useState(false);
- const channelsColumns = useMemo(
- () => [
- {
- id: 'logo',
- header: 'Logo',
- accessorKey: 'logo_url',
- size: 50,
- Cell: ({ cell }) => (
-
-
-
- ),
- },
- {
- id: 'name',
- header: 'Name',
- accessorKey: 'name',
- Cell: ({ cell }) => (
-
- {cell.getValue()}
-
- ),
- },
- {
- id: 'started',
- header: 'Started',
- accessorFn: (row) => {
- // Get the current date and time
- const currentDate = new Date();
- // Calculate the start date by subtracting uptime (in milliseconds)
- const startDate = new Date(currentDate.getTime() - row.uptime * 1000);
- // Format the date as a string (you can adjust the format as needed)
- return startDate.toLocaleString({
- weekday: 'short', // optional, adds day of the week
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: true, // 12-hour format with AM/PM
- });
- },
- },
- {
- id: 'uptime',
- header: 'Uptime',
- size: 50,
- accessorFn: (row) => {
- const days = Math.floor(row.uptime / (3600 * 24)); // Calculate the number of days
- const hours = Math.floor((row.uptime % (3600 * 24)) / 3600); // Calculate remaining hours
- const minutes = Math.floor((row.uptime % 3600) / 60); // Calculate remaining minutes
- const seconds = parseInt(row.uptime % 60); // Remaining seconds
-
- // Format uptime as "d hh:mm:ss"
- return `${days ? days : ''} ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
- },
- mantineTableBodyCellProps: {
- align: 'right',
- },
- },
- {
- id: 'num_clients',
- header: 'Clients',
- accessorKey: 'client_count',
- size: 50,
- mantineTableBodyCellProps: {
- align: 'center',
- },
- },
- ],
- []
+ // Use localStorage for stats refresh interval (in seconds)
+ const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage(
+ 'stats-refresh-interval',
+ 5
+ );
+ const refreshInterval = refreshIntervalSeconds * 1000; // Convert to milliseconds
+ const channelHistoryLength = Object.keys(channelHistory).length;
+ const vodConnectionsCount = vodConnections.reduce(
+ (total, vodContent) => total + (vodContent.connections?.length || 0),
+ 0
);
- const stopChannel = async (id) => {
- await API.stopChannel(id);
+ const handleStopVODClient = async (clientId) => {
+ await stopVODClient(clientId);
+ // Refresh VOD stats after stopping to update the UI
+ fetchVODStats();
};
- const stopClient = async (channelId, clientId) => {
- await API.stopClient(channelId, clientId);
- };
+ // Function to fetch channel stats from API
+ const fetchChannelStats = useCallback(async () => {
+ try {
+ const response = await fetchActiveChannelStats();
+ if (response) {
+ setChannelStats(response);
+ } else {
+ console.log('API response was empty or null');
+ }
+ } catch (error) {
+ console.error('Error fetching channel stats:', error);
+ console.error('Error details:', {
+ message: error.message,
+ status: error.status,
+ body: error.body,
+ });
+ }
+ }, [setChannelStats]);
- // The main clientsTable is no longer needed since each channel card has its own table
+ const fetchVODStats = useCallback(async () => {
+ try {
+ const response = await getVODStats();
+ if (response) {
+ setVodConnections(response.vod_connections || []);
+ } else {
+ console.log('VOD API response was empty or null');
+ }
+ } catch (error) {
+ console.error('Error fetching VOD stats:', error);
+ console.error('Error details:', {
+ message: error.message,
+ status: error.status,
+ body: error.body,
+ });
+ }
+ }, []);
+
+ // Set up polling for stats when on stats page
+ useEffect(() => {
+ const location = window.location;
+ const isOnStatsPage = location.pathname === '/stats';
+
+ if (isOnStatsPage && refreshInterval > 0) {
+ setIsPollingActive(true);
+
+ // Initial fetch
+ fetchChannelStats();
+ fetchVODStats();
+
+ // Set up interval
+ const interval = setInterval(() => {
+ fetchChannelStats();
+ fetchVODStats();
+ }, refreshInterval);
+
+ return () => {
+ clearInterval(interval);
+ setIsPollingActive(false);
+ };
+ } else {
+ setIsPollingActive(false);
+ }
+ }, [refreshInterval, fetchChannelStats, fetchVODStats]);
+
+ // Fetch initial stats on component mount (for immediate data when navigating to page)
+ useEffect(() => {
+ fetchChannelStats();
+ fetchVODStats();
+ }, [fetchChannelStats, fetchVODStats]);
useEffect(() => {
- if (!channelStats || !channelStats.channels || !Array.isArray(channelStats.channels) || channelStats.channels.length === 0) {
- console.log("No channel stats available:", channelStats);
- // Clear active channels when there are no stats
- if (Object.keys(activeChannels).length > 0) {
- setActiveChannels({});
- setClients([]);
- }
+ console.log('Processing channel stats:', channelStats);
+ if (
+ !channelStats ||
+ !channelStats.channels ||
+ !Array.isArray(channelStats.channels) ||
+ channelStats.channels.length === 0
+ ) {
+ console.log('No channel stats available:', channelStats);
+ // Clear clients and channel history when there are no stats
+ setClients([]);
+ setChannelHistory({});
return;
}
- // Create a completely new object based only on current channel stats
- const stats = {};
+ // 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);
- // Track which channels are currently active according to channelStats
- const currentActiveChannelIds = new Set(
- channelStats.channels.map(ch => ch.channel_id).filter(Boolean)
- );
+ console.log('Processed active channels:', stats);
- channelStats.channels.forEach(ch => {
- // Make sure we have a valid channel_id
- if (!ch.channel_id) {
- console.warn("Found channel without channel_id:", ch);
- return;
- }
+ // Update clients based on new stats
+ setClients(getClientStats(stats));
- let bitrates = [];
- if (activeChannels[ch.channel_id]) {
- bitrates = [...(activeChannels[ch.channel_id].bitrates || [])];
- const bitrate =
- ch.total_bytes - activeChannels[ch.channel_id].total_bytes;
- if (bitrate > 0) {
- bitrates.push(bitrate);
- }
-
- if (bitrates.length > 15) {
- bitrates = bitrates.slice(1);
- }
- }
-
- // Find corresponding channel data
- const channelData = channelsByUUID && ch.channel_id ?
- channels[channelsByUUID[ch.channel_id]] : null;
-
- // Find stream profile
- const streamProfile = streamProfiles.find(
- profile => profile.id == parseInt(ch.stream_profile)
- );
-
- stats[ch.channel_id] = {
- ...ch,
- ...(channelData || {}), // Safely merge channel data if available
- bitrates,
- stream_profile: streamProfile || { name: 'Unknown' },
- // Make sure stream_id is set from the active stream info
- stream_id: ch.stream_id || null,
- };
+ return stats; // Return only currently active channels
});
-
- console.log("Processed active channels:", stats);
- setActiveChannels(stats);
-
- const clientStats = Object.values(stats).reduce((acc, ch) => {
- if (ch.clients && Array.isArray(ch.clients)) {
- return acc.concat(
- ch.clients.map((client) => ({
- ...client,
- channel: ch,
- }))
- );
- }
- return acc;
- }, []);
- setClients(clientStats);
}, [channelStats, channels, channelsByUUID, streamProfiles]);
+ // Combine active streams and VOD connections into a single mixed list
+ const combinedConnections = useMemo(() => {
+ return getCombinedConnections(channelHistory, vodConnections);
+ }, [channelHistory, vodConnections]);
+
return (
-
- {Object.keys(activeChannels).length === 0 ? (
-
- No active channels currently streaming
+ <>
+
+
+
+
+ Active Connections
+
+
+ {channelHistoryLength} {
+ channelHistoryLength !== 1 ? 'streams' : 'stream'
+ } • {vodConnectionsCount} {
+ vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection'
+ }
+
+
+ Refresh Interval (seconds):
+ setRefreshIntervalSeconds(value || 0)}
+ min={0}
+ max={300}
+ step={1}
+ size="xs"
+ w={120}
+ />
+ {refreshIntervalSeconds === 0 && (
+
+ Refreshing disabled
+
+ )}
+
+ {isPollingActive && refreshInterval > 0 && (
+
+ Refreshing every {refreshIntervalSeconds}s
+
+ )}
+ {
+ fetchChannelStats();
+ fetchVODStats();
+ }}
+ loading={false}
+ >
+ Refresh Now
+
+
+
+
+
+
+
- ) : (
- Object.values(activeChannels).map((channel) => (
-
- ))
- )}
-
+
+
+ {/* System Events Section - Fixed at bottom */}
+
+
+
+
+
+ >
);
};
-export default ChannelsPage;
+export default StatsPage;
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx
new file mode 100644
index 00000000..e69f07f8
--- /dev/null
+++ b/frontend/src/pages/Users.jsx
@@ -0,0 +1,25 @@
+import UsersTable from '../components/tables/UsersTable';
+import { Box } from '@mantine/core';
+import useAuthStore from '../store/auth';
+import ErrorBoundary from '../components/ErrorBoundary';
+
+const PageContent = () => {
+ const authUser = useAuthStore((s) => s.user);
+ if (!authUser.id) throw new Error();
+
+ return (
+
+
+
+ );
+}
+
+const UsersPage = () => {
+ return (
+
+
+
+ );
+};
+
+export default UsersPage;
diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx
new file mode 100644
index 00000000..460b7211
--- /dev/null
+++ b/frontend/src/pages/VODs.jsx
@@ -0,0 +1,254 @@
+import React, { Suspense, useEffect, useState } from 'react';
+import {
+ Box,
+ Flex,
+ Grid,
+ GridCol,
+ Group,
+ Loader,
+ LoadingOverlay,
+ Pagination,
+ SegmentedControl,
+ Select,
+ Stack,
+ TextInput,
+ Title,
+} from '@mantine/core';
+import { Search } from 'lucide-react';
+import { useDisclosure } from '@mantine/hooks';
+import useVODStore from '../store/useVODStore';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import {
+ filterCategoriesToEnabled,
+ getCategoryOptions,
+} from '../utils/pages/VODsUtils.js';
+const SeriesModal = React.lazy(() => import('../components/SeriesModal'));
+const VODModal = React.lazy(() => import('../components/VODModal'));
+const VODCard = React.lazy(() => import('../components/cards/VODCard'));
+const SeriesCard = React.lazy(() => import('../components/cards/SeriesCard'));
+
+const MIN_CARD_WIDTH = 260;
+const MAX_CARD_WIDTH = 320;
+
+const useCardColumns = () => {
+ const [columns, setColumns] = useState(4);
+
+ useEffect(() => {
+ const calcColumns = () => {
+ const container = document.getElementById('vods-container');
+ const width = container ? container.offsetWidth : window.innerWidth;
+ let colCount = Math.floor(width / MIN_CARD_WIDTH);
+ if (colCount < 1) colCount = 1;
+ if (colCount > 6) colCount = 6;
+ setColumns(colCount);
+ };
+ calcColumns();
+ window.addEventListener('resize', calcColumns);
+ return () => window.removeEventListener('resize', calcColumns);
+ }, []);
+
+ return columns;
+};
+
+const VODsPage = () => {
+ const currentPageContent = useVODStore((s) => s.currentPageContent); // Direct subscription
+ const allCategories = useVODStore((s) => s.categories);
+ const filters = useVODStore((s) => s.filters);
+ const currentPage = useVODStore((s) => s.currentPage);
+ const totalCount = useVODStore((s) => s.totalCount);
+ const pageSize = useVODStore((s) => s.pageSize);
+ const setFilters = useVODStore((s) => s.setFilters);
+ const setPage = useVODStore((s) => s.setPage);
+ const setPageSize = useVODStore((s) => s.setPageSize);
+
+ // Persist page size in localStorage
+ useEffect(() => {
+ const stored = localStorage.getItem('vodsPageSize');
+ if (stored && !isNaN(Number(stored)) && Number(stored) !== pageSize) {
+ setPageSize(Number(stored));
+ }
+ // eslint-disable-next-line
+ }, []);
+
+ const handlePageSizeChange = (value) => {
+ setPageSize(Number(value));
+ localStorage.setItem('vodsPageSize', value);
+ };
+ const fetchContent = useVODStore((s) => s.fetchContent);
+ const fetchCategories = useVODStore((s) => s.fetchCategories);
+
+ // const showVideo = useVideoStore((s) => s.showVideo); - removed as unused
+ const [selectedSeries, setSelectedSeries] = useState(null);
+ const [selectedVOD, setSelectedVOD] = useState(null);
+ const [
+ seriesModalOpened,
+ { open: openSeriesModal, close: closeSeriesModal },
+ ] = useDisclosure(false);
+ const [vodModalOpened, { open: openVODModal, close: closeVODModal }] =
+ useDisclosure(false);
+ const [initialLoad, setInitialLoad] = useState(true);
+ const columns = useCardColumns();
+ const [categories, setCategories] = useState({});
+
+ // Helper function to get display data based on current filters
+ const getDisplayData = () => {
+ return (currentPageContent || []).map((item) => ({
+ ...item,
+ _vodType: item.contentType === 'movie' ? 'movie' : 'series',
+ }));
+ };
+
+ useEffect(() => {
+ setCategories(filterCategoriesToEnabled(allCategories));
+ }, [allCategories]);
+
+ useEffect(() => {
+ fetchCategories();
+ }, [fetchCategories]);
+
+ useEffect(() => {
+ fetchContent().finally(() => setInitialLoad(false));
+ }, [filters, currentPage, pageSize, fetchContent]);
+
+ const handleVODCardClick = (vod) => {
+ setSelectedVOD(vod);
+ openVODModal();
+ };
+
+ const handleSeriesClick = (series) => {
+ setSelectedSeries(series);
+ openSeriesModal();
+ };
+
+ const onCategoryChange = (value) => {
+ setFilters({ category: value });
+ setPage(1);
+ };
+
+ // When type changes, reset category to all
+ const handleTypeChange = (value) => {
+ setFilters({ type: value, category: '' });
+ setPage(1);
+ };
+
+ const categoryOptions = getCategoryOptions(categories, filters);
+
+ const totalPages = Math.ceil(totalCount / pageSize);
+
+ return (
+
+
+
+ Video on Demand
+
+
+ {/* Filters */}
+
+
+
+ }
+ value={filters.search}
+ onChange={(e) => setFilters({ search: e.target.value })}
+ miw={200}
+ />
+
+
+
+ ({
+ value: v,
+ label: v,
+ }))}
+ w={110}
+ />
+
+
+ {/* Content */}
+ {initialLoad ? (
+
+
+
+ ) : (
+ <>
+
+
+ }>
+ {getDisplayData().map((item) => (
+
+ {item.contentType === 'series' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ )}
+ >
+ )}
+
+
+ {/* Series Episodes Modal */}
+
+ }>
+
+
+
+
+ {/* VOD Details Modal */}
+
+ }>
+
+
+
+
+ );
+};
+
+export default VODsPage;
diff --git a/frontend/src/pages/__tests__/Channels.test.jsx b/frontend/src/pages/__tests__/Channels.test.jsx
new file mode 100644
index 00000000..e029952f
--- /dev/null
+++ b/frontend/src/pages/__tests__/Channels.test.jsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import useAuthStore from '../../store/auth';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import ChannelsPage from '../Channels';
+
+vi.mock('../../store/auth');
+vi.mock('../../hooks/useLocalStorage');
+vi.mock('../../components/tables/ChannelsTable', () => ({
+ default: () =>
ChannelsTable
+}));
+vi.mock('../../components/tables/StreamsTable', () => ({
+ default: () =>
StreamsTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) =>
{children}
,
+}));
+vi.mock('allotment', () => ({
+ Allotment: ({ children }) =>
{children}
,
+}));
+
+describe('ChannelsPage', () => {
+ beforeEach(() => {
+ useLocalStorage.mockReturnValue([[50, 50], vi.fn()]);
+ });
+
+ it('renders nothing when user is not authenticated', () => {
+ useAuthStore.mockReturnValue({ id: null, user_level: 0 });
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders only ChannelsTable for standard users', () => {
+ useAuthStore.mockReturnValue({ id: 1, user_level: 1 });
+ render(
);
+ expect(screen.getByTestId('channels-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('streams-table')).not.toBeInTheDocument();
+ });
+
+ it('renders split view for higher-level users', async () => {
+ useAuthStore.mockReturnValue({ id: 1, user_level: 2 });
+ render(
);
+ expect(screen.getByTestId('channels-table')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByTestId('streams-table')).toBeInTheDocument()
+ );
+ });
+});
diff --git a/frontend/src/pages/__tests__/ContentSources.test.jsx b/frontend/src/pages/__tests__/ContentSources.test.jsx
new file mode 100644
index 00000000..3f2ce1c5
--- /dev/null
+++ b/frontend/src/pages/__tests__/ContentSources.test.jsx
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import ContentSourcesPage from '../ContentSources';
+import useUserAgentsStore from '../../store/userAgents';
+
+vi.mock('../../store/userAgents');
+vi.mock('../../components/tables/M3UsTable', () => ({
+ default: () =>
M3UsTable
+}));
+vi.mock('../../components/tables/EPGsTable', () => ({
+ default: () =>
EPGsTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) =>
{children}
,
+ Stack: ({ children, ...props }) =>
{children}
,
+}));
+
+describe('ContentSourcesPage', () => {
+ it('renders error on userAgents error', () => {
+ const errorMessage = 'Failed to load userAgents.';
+ useUserAgentsStore.mockReturnValue(errorMessage);
+ render(
);
+ const element = screen.getByText(/Something went wrong/i);
+ expect(element).toBeInTheDocument();
+ });
+
+ it('no error renders tables', () => {
+ useUserAgentsStore.mockReturnValue(null);
+ render(
);
+ expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
+ expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/DVR.test.jsx b/frontend/src/pages/__tests__/DVR.test.jsx
new file mode 100644
index 00000000..be68bd7f
--- /dev/null
+++ b/frontend/src/pages/__tests__/DVR.test.jsx
@@ -0,0 +1,556 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import DVRPage from '../DVR';
+import dayjs from 'dayjs';
+import useChannelsStore from '../../store/channels';
+import useSettingsStore from '../../store/settings';
+import useVideoStore from '../../store/useVideoStore';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import {
+ isAfter,
+ isBefore,
+ useTimeHelpers,
+} from '../../utils/dateTimeUtils.js';
+import { categorizeRecordings } from '../../utils/pages/DVRUtils.js';
+import {
+ getPosterUrl,
+ getRecordingUrl,
+ getShowVideoUrl,
+} from '../../utils/cards/RecordingCardUtils.js';
+
+vi.mock('../../store/channels');
+vi.mock('../../store/settings');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../hooks/useLocalStorage');
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) =>
{children}
,
+ Container: ({ children }) =>
{children}
,
+ Title: ({ children, order }) =>
{children} ,
+ Text: ({ children }) =>
{children}
,
+ Button: ({ children, onClick, leftSection, loading, ...props }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Badge: ({ children }) =>
{children} ,
+ SimpleGrid: ({ children }) =>
{children}
,
+ Group: ({ children }) =>
{children}
,
+ Stack: ({ children }) =>
{children}
,
+ Divider: () =>
,
+ useMantineTheme: () => ({
+ tailwind: {
+ green: { 5: '#22c55e' },
+ red: { 6: '#dc2626' },
+ yellow: { 6: '#ca8a04' },
+ gray: { 6: '#52525b' },
+ },
+ }),
+}));
+
+// Mock components
+vi.mock('../../components/cards/RecordingCard', () => ({
+ default: ({ recording, onOpenDetails, onOpenRecurring }) => (
+
+ {recording.custom_properties?.Title || 'Recording'}
+ onOpenDetails(recording)}>Open Details
+ {recording.custom_properties?.rule && (
+ onOpenRecurring(recording)}>
+ Open Recurring
+
+ )}
+
+ ),
+}));
+
+vi.mock('../../components/forms/RecordingDetailsModal', () => ({
+ default: ({
+ opened,
+ onClose,
+ recording,
+ onEdit,
+ onWatchLive,
+ onWatchRecording,
+ }) =>
+ opened ? (
+
+
+ {recording?.custom_properties?.Title}
+
+
Close Modal
+
Edit
+
Watch Live
+
Watch Recording
+
+ ) : null,
+}));
+
+vi.mock('../../components/forms/RecurringRuleModal', () => ({
+ default: ({ opened, onClose, ruleId }) =>
+ opened ? (
+
+
Rule ID: {ruleId}
+
Close Recurring
+
+ ) : null,
+}));
+
+vi.mock('../../components/forms/Recording', () => ({
+ default: ({ isOpen, onClose, recording }) =>
+ isOpen ? (
+
+
Recording ID: {recording?.id || 'new'}
+
Close Form
+
+ ) : null,
+}));
+
+vi.mock('../../components/ErrorBoundary', () => ({
+ default: ({ children }) =>
{children}
,
+}));
+
+vi.mock('../../utils/dateTimeUtils.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ isBefore: vi.fn(),
+ isAfter: vi.fn(),
+ useTimeHelpers: vi.fn(),
+ };
+});
+vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({
+ getPosterUrl: vi.fn(),
+ getRecordingUrl: vi.fn(),
+ getShowVideoUrl: vi.fn(),
+}));
+vi.mock('../../utils/pages/DVRUtils.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ categorizeRecordings: vi.fn(),
+ };
+});
+
+describe('DVRPage', () => {
+ const mockShowVideo = vi.fn();
+ const mockFetchRecordings = vi.fn();
+ const mockFetchChannels = vi.fn();
+ const mockFetchRecurringRules = vi.fn();
+ const mockRemoveRecording = vi.fn();
+
+ const defaultChannelsState = {
+ recordings: [],
+ channels: {},
+ recurringRules: [],
+ fetchRecordings: mockFetchRecordings,
+ fetchChannels: mockFetchChannels,
+ fetchRecurringRules: mockFetchRecurringRules,
+ removeRecording: mockRemoveRecording,
+ };
+
+ const defaultSettingsState = {
+ settings: {
+ system_settings: { value: { time_zone: 'America/New_York' } },
+ },
+ environment: {
+ env_mode: 'production',
+ },
+ };
+
+ const defaultVideoState = {
+ showVideo: mockShowVideo,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ const now = new Date('2024-01-15T12:00:00Z');
+ vi.setSystemTime(now);
+
+ isAfter.mockImplementation((a, b) => new Date(a) > new Date(b));
+ isBefore.mockImplementation((a, b) => new Date(a) < new Date(b));
+ useTimeHelpers.mockReturnValue({
+ toUserTime: (dt) => dayjs(dt).tz('America/New_York').toDate(),
+ userNow: () => dayjs().tz('America/New_York').toDate(),
+ });
+
+ categorizeRecordings.mockImplementation((recordings, toUserTime, now) => {
+ const inProgress = [];
+ const upcoming = [];
+ const completed = [];
+ recordings.forEach((rec) => {
+ const start = toUserTime(rec.start_time);
+ const end = toUserTime(rec.end_time);
+ if (now >= start && now <= end) inProgress.push(rec);
+ else if (now < start) upcoming.push(rec);
+ else completed.push(rec);
+ });
+ return { inProgress, upcoming, completed };
+ });
+
+ getPosterUrl.mockImplementation((recording) =>
+ recording?.id ? `http://poster.url/${recording.id}` : null
+ );
+ getRecordingUrl.mockImplementation(
+ (custom_properties) => custom_properties?.recording_url
+ );
+ getShowVideoUrl.mockImplementation((channel) => channel?.stream_url);
+
+ useChannelsStore.mockImplementation((selector) => {
+ return selector ? selector(defaultChannelsState) : defaultChannelsState;
+ });
+ useChannelsStore.getState = () => defaultChannelsState;
+
+ useSettingsStore.mockImplementation((selector) => {
+ return selector ? selector(defaultSettingsState) : defaultSettingsState;
+ });
+ useSettingsStore.getState = () => defaultSettingsState;
+
+ useVideoStore.mockImplementation((selector) => {
+ return selector ? selector(defaultVideoState) : defaultVideoState;
+ });
+ useVideoStore.getState = () => defaultVideoState;
+
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.clearAllTimers(); // Clear pending timers
+ vi.useRealTimers();
+ });
+
+ describe('Initial Render', () => {
+ it('renders new recording buttons', () => {
+ render(
);
+
+ expect(screen.getByText('New Recording')).toBeInTheDocument();
+ });
+
+ it('renders empty state when no recordings', () => {
+ render(
);
+
+ expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument();
+ });
+ });
+
+ describe('Recording Display', () => {
+ it('displays recordings grouped by date', () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recordings = [
+ {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Show 1' },
+ },
+ {
+ id: 2,
+ channel: 1,
+ start_time: now.add(1, 'day').toISOString(),
+ end_time: now.add(1, 'day').add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Show 2' },
+ },
+ ];
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = { ...defaultChannelsState, recordings };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ expect(screen.getByTestId('recording-card-1')).toBeInTheDocument();
+ expect(screen.getByTestId('recording-card-2')).toBeInTheDocument();
+ });
+ });
+
+ describe('New Recording', () => {
+ it('opens recording form when new recording button is clicked', async () => {
+ render(
);
+
+ const newButton = screen.getByText('New Recording');
+ fireEvent.click(newButton);
+
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+ });
+
+ it('closes recording form when close is clicked', async () => {
+ render(
);
+
+ const newButton = screen.getByText('New Recording');
+ fireEvent.click(newButton);
+
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close Form');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Recording Details Modal', () => {
+ const setupRecording = () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Test Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: {
+ 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
+ },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ return recording;
+ };
+
+ it('opens details modal when recording card is clicked', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render(
);
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Show');
+ });
+
+ it('closes details modal when close is clicked', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render(
);
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const closeButton = screen.getByText('Close Modal');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument();
+ });
+
+ it('opens edit form from details modal', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render(
);
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const editButton = screen.getByText('Edit');
+ fireEvent.click(editButton);
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument();
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+ });
+ });
+
+ describe('Recurring Rule Modal', () => {
+ it('opens recurring rule modal when recording has rule', async () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recurring Show',
+ rule: { id: 100 },
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ const recurringButton = screen.getByText('Open Recurring');
+ fireEvent.click(recurringButton);
+
+ expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
+ expect(screen.getByText('Rule ID: 100')).toBeInTheDocument();
+ });
+
+ it('closes recurring modal when close is clicked', async () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recurring Show',
+ rule: { id: 100 },
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ const recurringButton = screen.getByText('Open Recurring');
+ fireEvent.click(recurringButton);
+
+ expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close Recurring');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Watch Functionality', () => {
+ it('calls showVideo for watch live on in-progress recording', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs();
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(30, 'minutes').toISOString(),
+ end_time: now.add(30, 'minutes').toISOString(),
+ custom_properties: { Title: 'Live Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: {
+ 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
+ },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const watchLiveButton = screen.getByText('Watch Live');
+ fireEvent.click(watchLiveButton);
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('stream.url'),
+ 'live'
+ );
+ });
+
+ it('calls showVideo for watch recording on completed recording', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recorded Show',
+ recording_url: 'http://recording.url/video.mp4',
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const watchButton = screen.getByText('Watch Recording');
+ fireEvent.click(watchButton);
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('http://recording.url/video.mp4'),
+ 'vod',
+ expect.objectContaining({
+ name: 'Recording',
+ })
+ );
+ });
+
+ it('does not call showVideo when recording URL is missing', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ custom_properties: { Title: 'No URL Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ const detailsButton = await screen.findByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ const modal = await screen.findByTestId('details-modal');
+ expect(modal).toBeInTheDocument();
+
+ const watchButton = screen.getByText('Watch Recording');
+ fireEvent.click(watchButton);
+
+ expect(mockShowVideo).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx
new file mode 100644
index 00000000..feb5325c
--- /dev/null
+++ b/frontend/src/pages/__tests__/Guide.test.jsx
@@ -0,0 +1,619 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import {
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+} from '@testing-library/react';
+import dayjs from 'dayjs';
+import Guide from '../Guide';
+import useChannelsStore from '../../store/channels';
+import useLogosStore from '../../store/logos';
+import useEPGsStore from '../../store/epgs';
+import useSettingsStore from '../../store/settings';
+import useVideoStore from '../../store/useVideoStore';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import { showNotification } from '../../utils/notificationUtils.js';
+import * as guideUtils from '../guideUtils';
+import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
+import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
+import userEvent from '@testing-library/user-event';
+
+// Mock dependencies
+vi.mock('../../store/channels');
+vi.mock('../../store/logos');
+vi.mock('../../store/epgs');
+vi.mock('../../store/settings');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../hooks/useLocalStorage');
+
+vi.mock('@mantine/hooks', () => ({
+ useElementSize: () => ({
+ ref: vi.fn(),
+ width: 1200,
+ height: 800,
+ }),
+}));
+vi.mock('@mantine/core', async () => {
+ const actual = await vi.importActual('@mantine/core');
+ return {
+ ...actual,
+ Box: ({ children, style, onClick, className, ref }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children, direction, justify, align, gap, mb, style }) => (
+
+ {children}
+
+ ),
+ Group: ({ children, gap, justify }) => (
+
+ {children}
+
+ ),
+ Title: ({ children, order, size }) => (
+
+ {children}
+
+ ),
+ Text: ({ children, size, c, fw, lineClamp, style, onClick }) => (
+
+ {children}
+
+ ),
+ Paper: ({ children, style, onClick }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, leftSection, variant, size, color, disabled }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
+
+ {icon}
+
+ {rightSection}
+
+ ),
+ Select: ({ value, onChange, data, placeholder, clearable }) => (
+
onChange?.(e.target.value)}
+ aria-label={placeholder}
+ data-clearable={clearable}
+ >
+ Select...
+ {data?.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ ),
+ ActionIcon: ({ children, onClick, variant, size, color }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) =>
{children}
,
+ LoadingOverlay: ({ visible }) => (visible ?
Loading...
: null),
+ };
+});
+vi.mock('react-window', () => ({
+ VariableSizeList: ({ children, itemData, itemCount }) => (
+
+ {Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
+
+ {children({
+ index: i,
+ style: {},
+ data: itemData.filteredChannels[i]
+ })}
+
+ )}
+
+ ),
+}));
+
+vi.mock('../../components/GuideRow', () => ({
+ default: ({ data }) =>
GuideRow for {data?.name}
,
+}));
+vi.mock('../../components/HourTimeline', () => ({
+ default: ({ hourTimeline }) => (
+
+ {hourTimeline.map((hour, i) => (
+
{hour.label}
+ ))}
+
+ ),
+}));
+vi.mock('../../components/forms/ProgramRecordingModal', () => ({
+ __esModule: true,
+ default: ({ opened, onClose, program, onRecordOne }) =>
+ opened ? (
+
+
{program?.title}
+
Close
+
Record One
+
+ ) : null,
+}));
+vi.mock('../../components/forms/SeriesRecordingModal', () => ({
+ __esModule: true,
+ default: ({ opened, onClose, rules }) =>
+ opened ? (
+
+
Series Rules: {rules.length}
+
Close
+
+ ) : null,
+}));
+
+vi.mock('../guideUtils', async () => {
+ const actual = await vi.importActual('../guideUtils');
+ return {
+ ...actual,
+ fetchPrograms: vi.fn(),
+ createRecording: vi.fn(),
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRule: vi.fn(),
+ fetchRules: vi.fn(),
+ filterGuideChannels: vi.fn(),
+ getGroupOptions: vi.fn(),
+ getProfileOptions: vi.fn(),
+ };
+});
+vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/cards/RecordingCardUtils.js');
+ return {
+ ...actual,
+ getShowVideoUrl: vi.fn(),
+ };
+});
+vi.mock('../../utils/dateTimeUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/dateTimeUtils.js');
+ return {
+ ...actual,
+ getNow: vi.fn(),
+ add: vi.fn(),
+ format: vi.fn(),
+ initializeTime: vi.fn(),
+ startOfDay: vi.fn(),
+ convertToMs: vi.fn(),
+ useDateTimeFormat: vi.fn(),
+ };
+});
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+describe('Guide', () => {
+ let mockChannelsState;
+ let mockShowVideo;
+ let mockFetchRecordings;
+ const now = dayjs('2024-01-15T12:00:00Z');
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+
+ mockChannelsState = {
+ channels: {
+ 'channel-1': {
+ id: 'channel-1',
+ uuid: 'uuid-1',
+ name: 'Test Channel 1',
+ channel_number: 1,
+ logo_id: 'logo-1',
+ stream_url: 'http://stream1.test',
+ },
+ 'channel-2': {
+ id: 'channel-2',
+ uuid: 'uuid-2',
+ name: 'Test Channel 2',
+ channel_number: 2,
+ logo_id: 'logo-2',
+ stream_url: 'http://stream2.test',
+ },
+ },
+ recordings: [],
+ channelGroups: {
+ 'group-1': { id: 'group-1', name: 'News', channels: ['channel-1'] },
+ },
+ profiles: {
+ 'profile-1': { id: 'profile-1', name: 'HD Profile' },
+ },
+ };
+
+ mockShowVideo = vi.fn();
+ mockFetchRecordings = vi.fn().mockResolvedValue([]);
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...mockChannelsState,
+ fetchRecordings: mockFetchRecordings,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLogosStore.mockReturnValue({
+ 'logo-1': { url: 'http://logo1.png' },
+ 'logo-2': { url: 'http://logo2.png' },
+ });
+
+ useEPGsStore.mockImplementation((selector) =>
+ selector ? selector({ tvgsById: {}, epgs: {} }) : { tvgsById: {}, epgs: {} }
+ );
+
+ useSettingsStore.mockReturnValue('production');
+ useVideoStore.mockReturnValue(mockShowVideo);
+ useLocalStorage.mockReturnValue(['12h', vi.fn()]);
+
+ dateTimeUtils.getNow.mockReturnValue(now);
+ dateTimeUtils.format.mockImplementation((date, format) => {
+ if (format?.includes('dddd')) return 'Monday, 01/15/2024 • 12:00 PM';
+ return '12:00 PM';
+ });
+ 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']);
+
+ guideUtils.fetchPrograms.mockResolvedValue([
+ {
+ id: 'prog-1',
+ tvg_id: 'tvg-1',
+ title: 'Test Program 1',
+ description: 'Description 1',
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ programStart: now,
+ programEnd: now.add(1, 'hour'),
+ startMs: now.valueOf(),
+ endMs: now.add(1, 'hour').valueOf(),
+ isLive: true,
+ isPast: false,
+ },
+ ]);
+
+ guideUtils.fetchRules.mockResolvedValue([]);
+ guideUtils.filterGuideChannels.mockImplementation(
+ (channels) => Object.values(channels)
+ );
+ guideUtils.createRecording.mockResolvedValue(undefined);
+ guideUtils.createSeriesRule.mockResolvedValue(undefined);
+ guideUtils.evaluateSeriesRule.mockResolvedValue(undefined);
+ guideUtils.getGroupOptions.mockReturnValue([
+ { value: 'all', label: 'All Groups' },
+ { value: 'group-1', label: 'News' },
+ ]);
+ guideUtils.getProfileOptions.mockReturnValue([
+ { value: 'all', label: 'All Profiles' },
+ { value: 'profile-1', label: 'HD Profile' },
+ ]);
+
+ recordingCardUtils.getShowVideoUrl.mockReturnValue('http://video.test');
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ describe('Rendering', () => {
+ it('renders the TV Guide title', async () => {
+ render(
);
+
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ it('displays current time in header', async () => {
+ render(
);
+
+ expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
+ });
+
+ it('renders channel rows when channels are available', async () => {
+ render(
);
+
+ expect(screen.getAllByTestId('guide-row')).toHaveLength(2);
+ });
+
+ it('shows no channels message when filters exclude all channels', async () => {
+ guideUtils.filterGuideChannels.mockReturnValue([]);
+
+ render(
);
+
+ // await waitFor(() => {
+ expect(screen.getByText('No channels match your filters')).toBeInTheDocument();
+ // });
+ });
+
+ it('displays channel count', async () => {
+ render(
);
+
+ // await waitFor(() => {
+ expect(screen.getByText(/2 channels/)).toBeInTheDocument();
+ // });
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('updates search query when user types', async () => {
+ vi.useRealTimers();
+
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText('Search channels...');
+ fireEvent.change(searchInput, { target: { value: 'Test' } });
+
+ expect(searchInput).toHaveValue('Test');
+ });
+
+ it('clears search query when clear button is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText('Search channels...');
+
+ await user.type(searchInput, 'Test');
+ expect(searchInput).toHaveValue('Test');
+
+ await user.click(screen.getByText('Clear Filters'));
+ expect(searchInput).toHaveValue('');
+ });
+
+ it('calls filterGuideChannels with search query', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render(
);
+
+ const searchInput = await screen.findByPlaceholderText('Search channels...');
+ await user.type(searchInput, 'News');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ 'News',
+ 'all',
+ 'all',
+ expect.anything()
+ );
+ });
+ });
+ });
+
+ describe('Filter Functionality', () => {
+ it('filters by channel group', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render(
);
+
+ const groupSelect = await screen.findByLabelText('Filter by group');
+ await user.selectOptions(groupSelect, 'group-1');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ '',
+ 'group-1',
+ 'all',
+ expect.anything()
+ );
+ });
+ });
+
+ it('filters by profile', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render(
);
+
+ const profileSelect = await screen.findByLabelText('Filter by profile');
+ await user.selectOptions(profileSelect, 'profile-1');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ '',
+ 'all',
+ 'profile-1',
+ expect.anything()
+ );
+ });
+ });
+
+ it('clears all filters when Clear Filters is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render(
);
+
+ // Set some filters
+ const searchInput = await screen.findByPlaceholderText('Search channels...');
+ await user.type(searchInput, 'Test');
+
+ // Clear them
+ const clearButton = await screen.findByText('Clear Filters');
+ await user.click(clearButton);
+
+ expect(searchInput).toHaveValue('');
+ });
+ });
+
+ describe('Recording Functionality', () => {
+ it('opens Series Rules modal when button is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup();
+ render(
);
+
+ const rulesButton = await screen.findByText('Series Rules');
+ await user.click(rulesButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('series-recording-modal')).toBeInTheDocument();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+
+ it('fetches rules when opening Series Rules modal', async () => {
+ vi.useRealTimers();
+
+ const mockRules = [{ id: 1, title: 'Test Rule' }];
+ guideUtils.fetchRules.mockResolvedValue(mockRules);
+
+ const user = userEvent.setup();
+ render(
);
+
+ const rulesButton = await screen.findByText('Series Rules');
+ await user.click(rulesButton);
+
+ await waitFor(() => {
+ expect(guideUtils.fetchRules).toHaveBeenCalled();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+ });
+
+ describe('Navigation', () => {
+ it('scrolls to current time when Jump to current time is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render(
);
+
+ const jumpButton = await screen.findByTitle('Jump to current time');
+ await user.click(jumpButton);
+
+ // Verify button was clicked (scroll behavior is tested in integration tests)
+ expect(jumpButton).toBeInTheDocument();
+ });
+ });
+
+ describe('Time Updates', () => {
+ it('updates current time every second', async () => {
+ render(
);
+
+ expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
+
+ // Advance time by 1 second
+ vi.advanceTimersByTime(1000);
+
+ expect(dateTimeUtils.getNow).toHaveBeenCalled();
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('shows notification when no channels are available', async () => {
+ useChannelsStore.mockImplementation((selector) => {
+ const state = { channels: {}, recordings: [], channelGroups: {}, profiles: {} };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'No channels available',
+ color: 'red.5',
+ });
+ });
+ });
+
+ describe('Watch Functionality', () => {
+ it('calls showVideo when watch button is clicked on live program', async () => {
+ vi.useRealTimers();
+
+ // Mock a live program
+ const liveProgram = {
+ id: 'prog-live',
+ tvg_id: 'tvg-1',
+ title: 'Live Show',
+ description: 'Live Description',
+ start_time: now.subtract(30, 'minutes').toISOString(),
+ end_time: now.add(30, 'minutes').toISOString(),
+ programStart: now.subtract(30, 'minutes'),
+ programEnd: now.add(30, 'minutes'),
+ startMs: now.subtract(30, 'minutes').valueOf(),
+ endMs: now.add(30, 'minutes').valueOf(),
+ isLive: true,
+ isPast: false,
+ };
+
+ guideUtils.fetchPrograms.mockResolvedValue([liveProgram]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ // Implementation depends on how programs are rendered - this is a placeholder
+ // You would need to find and click the actual watch button in the rendered program
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+
+ it('does not show watch button for past programs', async () => {
+ vi.useRealTimers();
+
+ const pastProgram = {
+ id: 'prog-past',
+ tvg_id: 'tvg-1',
+ title: 'Past Show',
+ description: 'Past Description',
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ programStart: now.subtract(2, 'hours'),
+ programEnd: now.subtract(1, 'hour'),
+ startMs: now.subtract(2, 'hours').valueOf(),
+ endMs: now.subtract(1, 'hour').valueOf(),
+ isLive: false,
+ isPast: true,
+ };
+
+ guideUtils.fetchPrograms.mockResolvedValue([pastProgram]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Login.test.jsx b/frontend/src/pages/__tests__/Login.test.jsx
new file mode 100644
index 00000000..3db66883
--- /dev/null
+++ b/frontend/src/pages/__tests__/Login.test.jsx
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import Login from '../Login';
+import useAuthStore from '../../store/auth';
+
+vi.mock('../../store/auth');
+vi.mock('../../components/forms/LoginForm', () => ({
+ default: () =>
LoginForm
+}));
+vi.mock('../../components/forms/SuperuserForm', () => ({
+ default: () =>
SuperuserForm
+}));
+vi.mock('@mantine/core', () => ({
+ Text: ({ children }) =>
{children}
,
+}));
+
+describe('Login', () => {
+ it('renders SuperuserForm when superuser does not exist', async () => {
+ useAuthStore.mockReturnValue(false);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('login-form')).not.toBeInTheDocument();
+ });
+
+ it('renders LoginForm when superuser exists', () => {
+ useAuthStore.mockReturnValue(true);
+
+ render(
);
+
+ expect(screen.getByTestId('login-form')).toBeInTheDocument();
+ expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Logos.test.jsx b/frontend/src/pages/__tests__/Logos.test.jsx
new file mode 100644
index 00000000..b710b2ef
--- /dev/null
+++ b/frontend/src/pages/__tests__/Logos.test.jsx
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import LogosPage from '../Logos';
+import useLogosStore from '../../store/logos';
+import useVODLogosStore from '../../store/vodLogos';
+import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+
+vi.mock('../../store/logos');
+vi.mock('../../store/vodLogos');
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+ updateNotification: vi.fn(),
+}));
+vi.mock('../../components/tables/LogosTable', () => ({
+ default: () =>
LogosTable
+}));
+vi.mock('../../components/tables/VODLogosTable', () => ({
+ default: () =>
VODLogosTable
+}));
+vi.mock('@mantine/core', () => {
+ const tabsComponent = ({ children, value, onChange }) =>
+
onChange('vod')}>{children}
;
+ tabsComponent.List = ({ children }) =>
{children}
;
+ tabsComponent.Tab = ({ children, value }) =>
{children} ;
+
+ return {
+ Box: ({ children, ...props }) =>
{children}
,
+ Flex: ({ children, ...props }) =>
{children}
,
+ Text: ({ children, ...props }) =>
{children} ,
+ Tabs: tabsComponent,
+ TabsList: tabsComponent.List,
+ TabsTab: tabsComponent.Tab,
+ };
+});
+
+describe('LogosPage', () => {
+ const mockFetchAllLogos = vi.fn();
+ const mockNeedsAllLogos = vi.fn();
+
+ const defaultLogosState = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: { 1: {}, 2: {}, 3: {} },
+ };
+
+ const defaultVODLogosState = {
+ totalCount: 5,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ useLogosStore.mockImplementation((selector) => {
+ return selector ? selector(defaultLogosState) : defaultLogosState;
+ });
+ useLogosStore.getState = () => defaultLogosState;
+
+ useVODLogosStore.mockImplementation((selector) => {
+ return selector ? selector(defaultVODLogosState) : defaultVODLogosState;
+ });
+
+ mockNeedsAllLogos.mockReturnValue(true);
+ mockFetchAllLogos.mockResolvedValue();
+ });
+
+ it('renders with channel logos tab by default', () => {
+ render(
);
+
+ expect(screen.getByText('Logos')).toBeInTheDocument();
+ expect(screen.getByTestId('logos-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('vod-logos-table')).not.toBeInTheDocument();
+ });
+
+ it('displays correct channel logos count', () => {
+ render(
);
+
+ expect(screen.getByText(/\(3 logos\)/i)).toBeInTheDocument();
+ });
+
+ it('displays singular "logo" when count is 1', () => {
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: { 1: {} },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ expect(screen.getByText(/\(1 logo\)/i)).toBeInTheDocument();
+ });
+
+ it('fetches all logos on mount when needed', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(mockNeedsAllLogos).toHaveBeenCalled();
+ expect(mockFetchAllLogos).toHaveBeenCalled();
+ });
+ });
+
+ it('does not fetch logos when not needed', async () => {
+ mockNeedsAllLogos.mockReturnValue(false);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(mockNeedsAllLogos).toHaveBeenCalled();
+ expect(mockFetchAllLogos).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows error notification when fetching logos fails', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const error = new Error('Failed to fetch');
+ mockFetchAllLogos.mockRejectedValue(error);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'Error',
+ message: 'Failed to load channel logos',
+ color: 'red',
+ });
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load channel logos:',
+ error
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('switches to VOD logos tab when clicked', () => {
+ const { rerender } = render(
);
+
+ expect(screen.getByTestId('logos-table')).toBeInTheDocument();
+
+ const tabs = screen.getByTestId('tabs');
+ fireEvent.click(tabs);
+
+ rerender(
);
+
+ expect(screen.getByTestId('vod-logos-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('logos-table')).not.toBeInTheDocument();
+ });
+
+ it('renders both tab options', () => {
+ render(
);
+
+ expect(screen.getByText('Channel Logos')).toBeInTheDocument();
+ expect(screen.getByText('VOD Logos')).toBeInTheDocument();
+ });
+
+ it('displays zero logos correctly', () => {
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: {},
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render(
);
+
+ expect(screen.getByText(/\(0 logos\)/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Plugins.test.jsx b/frontend/src/pages/__tests__/Plugins.test.jsx
new file mode 100644
index 00000000..cbf052ed
--- /dev/null
+++ b/frontend/src/pages/__tests__/Plugins.test.jsx
@@ -0,0 +1,561 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import PluginsPage from '../Plugins';
+import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+import {
+ deletePluginByKey,
+ importPlugin,
+ setPluginEnabled,
+ updatePluginSettings,
+} from '../../utils/pages/PluginsUtils';
+import { usePluginStore } from '../../store/plugins';
+
+vi.mock('../../store/plugins');
+
+vi.mock('../../utils/pages/PluginsUtils', () => ({
+ deletePluginByKey: vi.fn(),
+ importPlugin: vi.fn(),
+ setPluginEnabled: vi.fn(),
+ updatePluginSettings: vi.fn(),
+ runPluginAction: vi.fn(),
+}));
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+ updateNotification: vi.fn(),
+}));
+
+vi.mock('@mantine/core', async () => {
+ return {
+ AppShellMain: ({ children }) =>
{children}
,
+ Box: ({ children, style }) =>
{children}
,
+ Stack: ({ children, gap }) =>
{children}
,
+ Group: ({ children, justify, mb }) => (
+
+ {children}
+
+ ),
+ Alert: ({ children, color, title }) => (
+
+ {title &&
{title}
}
+ {children}
+
+ ),
+ Text: ({ children, size, fw, c }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Loader: () =>
Loading...
,
+ Switch: ({ checked, onChange, label, description }) => (
+
+ onChange(e)}
+ />
+ {label}
+ {description && {description} }
+
+ ),
+ Divider: ({ my }) =>
,
+ ActionIcon: ({ children, onClick, color, variant, title }) => (
+
+ {children}
+
+ ),
+ SimpleGrid: ({ children, cols }) => (
+
{children}
+ ),
+ Modal: ({ opened, onClose, title, children, size, centered }) =>
+ opened ? (
+
+
{title}
+
Close Modal
+ {children}
+
+ ) : null,
+ FileInput: ({ value, onChange, label, placeholder, accept }) => (
+
+ {label && {label} }
+ onChange?.(e.target.files[0])}
+ placeholder={placeholder}
+ accept={accept}
+ aria-label={label}
+ />
+
+ ),
+ };
+});
+vi.mock('@mantine/dropzone', () => ({
+ Dropzone: ({ children, onDrop, accept, maxSize }) => (
+
{
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ onDrop([file]);
+ }}
+ >
+
Drop files
+ {children}
+
+ ),
+}));
+
+vi.mock('../../components/cards/PluginCard.jsx', () => ({
+ default: ({ plugin }) => (
+
+
{plugin.name}
+
{plugin.description}
+
+ ),
+}));
+
+describe('PluginsPage', () => {
+ const mockPlugins = [
+ {
+ key: 'plugin1',
+ name: 'Test Plugin 1',
+ description: 'Description 1',
+ enabled: true,
+ ever_enabled: true,
+ },
+ {
+ key: 'plugin2',
+ name: 'Test Plugin 2',
+ description: 'Description 2',
+ enabled: false,
+ ever_enabled: false,
+ },
+ ];
+
+ const mockPluginStoreState = {
+ plugins: mockPlugins,
+ loading: false,
+ fetchPlugins: vi.fn(),
+ updatePlugin: vi.fn(),
+ removePlugin: vi.fn(),
+ invalidatePlugins: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(mockPluginStoreState) : mockPluginStoreState;
+ });
+ usePluginStore.getState = vi.fn(() => mockPluginStoreState);
+ });
+
+ describe('Rendering', () => {
+ it('renders the page with plugins list', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Plugins')).toBeInTheDocument();
+ expect(screen.getByText('Test Plugin 1')).toBeInTheDocument();
+ expect(screen.getByText('Test Plugin 2')).toBeInTheDocument();
+ });
+ });
+
+ it('renders import button', () => {
+ render(
);
+
+ expect(screen.getByText('Import Plugin')).toBeInTheDocument();
+ });
+
+ it('renders reload button', () => {
+ render(
);
+
+ const reloadButton = screen.getByTitle('Reload');
+ expect(reloadButton).toBeInTheDocument();
+ });
+
+ it('shows loader when loading and no plugins', () => {
+ const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() };
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(loadingState) : loadingState;
+ });
+ usePluginStore.getState = vi.fn(() => loadingState);
+
+ render(
);
+
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no plugins', () => {
+ const emptyState = { plugins: [], loading: false, fetchPlugins: vi.fn() };
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(emptyState) : emptyState;
+ });
+ usePluginStore.getState = vi.fn(() => emptyState);
+
+ render(
);
+
+ expect(screen.getByText(/No plugins found/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Import Plugin', () => {
+ it('opens import modal when import button is clicked', () => {
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin');
+ });
+
+ it('shows dropzone and file input in import modal', () => {
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ expect(screen.getByTestId('dropzone')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument();
+ });
+
+ it('closes import modal when close button is clicked', () => {
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Close Modal'));
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('handles file upload via dropzone', async () => {
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' },
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+ const dropzone = screen.getByTestId('dropzone');
+ fireEvent.click(dropzone);
+
+ await waitFor(() => {
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ expect(uploadButton).not.toBeDisabled();
+ });
+ });
+
+ it('uploads plugin successfully', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(importPlugin).toHaveBeenCalledWith(file);
+ expect(showNotification).toHaveBeenCalled();
+ expect(updateNotification).toHaveBeenCalled();
+ });
+ });
+
+ it('handles upload failure', async () => {
+ importPlugin.mockResolvedValue({
+ success: false,
+ error: 'Upload failed',
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(updateNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ color: 'red',
+ title: 'Import failed',
+ })
+ );
+ });
+ });
+
+ it('shows enable switch after successful import', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('New Plugin')).toBeInTheDocument();
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+ });
+
+ it('enables plugin after import when switch is toggled', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: true,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(setPluginEnabled).toHaveBeenCalledWith('new-plugin', true);
+ });
+ });
+ });
+
+ describe('Trust Warning', () => {
+ it('shows trust warning for untrusted plugins', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true, ever_enabled: true });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument();
+ });
+ });
+
+ it('enables plugin when trust is confirmed', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true, ever_enabled: true });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('I understand, enable')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('I understand, enable'));
+
+ await waitFor(() => {
+ expect(setPluginEnabled).toHaveBeenCalledWith('new-plugin', true);
+ });
+ });
+
+ it('cancels enable when trust is denied', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ const cancelButtons = screen.getAllByText('Cancel');
+ expect(cancelButtons.length).toBeGreaterThan(0);
+ });
+
+ const cancelButtons = screen.getAllByText('Cancel');
+ fireEvent.click(cancelButtons[cancelButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(setPluginEnabled).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Reload', () => {
+ it('reloads plugins when reload button is clicked', async () => {
+ const invalidatePlugins = vi.fn();
+ usePluginStore.getState = vi.fn(() => ({
+ ...mockPluginStoreState,
+ invalidatePlugins,
+ }));
+
+ render(
);
+
+ const reloadButton = screen.getByTitle('Reload');
+ fireEvent.click(reloadButton);
+
+ await waitFor(() => {
+ expect(invalidatePlugins).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx
new file mode 100644
index 00000000..6a254326
--- /dev/null
+++ b/frontend/src/pages/__tests__/Settings.test.jsx
@@ -0,0 +1,208 @@
+import {
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import SettingsPage from '../Settings';
+import useAuthStore from '../../store/auth';
+import { USER_LEVELS } from '../../constants';
+import userEvent from '@testing-library/user-event';
+
+// Mock all dependencies
+vi.mock('../../store/auth');
+vi.mock('../../components/tables/UserAgentsTable', () => ({
+ default: ({ active }) =>
UserAgentsTable {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/tables/StreamProfilesTable', () => ({
+ default: ({ active }) =>
StreamProfilesTable {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/backups/BackupManager', () => ({
+ default: ({ active }) =>
BackupManager {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
+ default: ({ active }) =>
UiSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
+ default: ({ active }) =>
NetworkAccessForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
+ default: ({ active }) =>
ProxySettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
+ default: ({ active }) =>
StreamSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
+ default: ({ active }) =>
DvrSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
+ default: ({ active }) =>
SystemSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/ErrorBoundary', () => ({
+ default: ({ children }) =>
{children}
,
+}));
+
+vi.mock('@mantine/core', async () => {
+ const accordionComponent = ({ children, onChange, defaultValue }) =>
{children}
;
+ accordionComponent.Item = ({ children, value }) => (
+
{children}
+ );
+ accordionComponent.Control = ({ children }) => (
+
{children}
+ );
+ accordionComponent.Panel = ({ children }) => (
+
{children}
+ );
+
+ return {
+ Accordion: accordionComponent,
+ AccordionItem: accordionComponent.Item,
+ AccordionControl: accordionComponent.Control,
+ AccordionPanel: accordionComponent.Panel,
+ Box: ({ children }) =>
{children}
,
+ Center: ({ children }) =>
{children}
,
+ Loader: () =>
Loading...
,
+ Text: ({ children }) =>
{children} ,
+ };
+});
+
+
+describe('SettingsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering for Regular User', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.USER,
+ username: 'testuser',
+ });
+ });
+
+ it('renders the settings page', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion')).toBeInTheDocument();
+ });
+
+ it('renders UI Settings accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-ui-settings')).toBeInTheDocument();
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+ });
+
+ it('opens UI Settings panel by default', () => {
+ render(
);
+
+ expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
+ });
+
+ it('does not render admin-only sections for regular users', () => {
+ render(
);
+
+ expect(screen.queryByText('DVR')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('System Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('User-Agents')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stream Profiles')).not.toBeInTheDocument();
+ expect(screen.queryByText('Network Access')).not.toBeInTheDocument();
+ expect(screen.queryByText('Proxy Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('Backup & Restore')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Rendering for Admin User', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.ADMIN,
+ username: 'admin',
+ });
+ });
+
+ it('renders all accordion items for admin', async () => {
+ render(
);
+
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText('DVR')).toBeInTheDocument();
+ expect(screen.getByText('Stream Settings')).toBeInTheDocument();
+ expect(screen.getByText('System Settings')).toBeInTheDocument();
+ expect(screen.getByText('User-Agents')).toBeInTheDocument();
+ expect(screen.getByText('Stream Profiles')).toBeInTheDocument();
+ expect(screen.getByText('Network Access')).toBeInTheDocument();
+ expect(screen.getByText('Proxy Settings')).toBeInTheDocument();
+ expect(screen.getByText('Backup & Restore')).toBeInTheDocument();
+ });
+ });
+
+ it('renders DVR settings accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-dvr-settings')).toBeInTheDocument();
+ });
+
+ it('renders Stream Settings accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-stream-settings')).toBeInTheDocument();
+ });
+
+ it('renders System Settings accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-system-settings')).toBeInTheDocument();
+ });
+
+ it('renders User-Agents accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-user-agents')).toBeInTheDocument();
+ });
+
+ it('renders Stream Profiles accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-stream-profiles')).toBeInTheDocument();
+ });
+
+ it('renders Network Access accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-network-access')).toBeInTheDocument();
+ });
+
+ it('renders Proxy Settings accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-proxy-settings')).toBeInTheDocument();
+ });
+
+ it('renders Backup & Restore accordion item', () => {
+ render(
);
+
+ expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
+ });
+ });
+
+ describe('Accordion Interactions', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.ADMIN,
+ username: 'admin',
+ });
+ });
+
+ it('opens DVR settings when clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const streamSettingsButton = screen.getByText('DVR');
+ await user.click(streamSettingsButton);
+
+ await screen.findByTestId('dvr-settings-form');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx
new file mode 100644
index 00000000..bf5cdb42
--- /dev/null
+++ b/frontend/src/pages/__tests__/Stats.test.jsx
@@ -0,0 +1,494 @@
+// src/pages/__tests__/Stats.test.jsx
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+ act,
+} from '@testing-library/react';
+import StatsPage from '../Stats';
+import useStreamProfilesStore from '../../store/streamProfiles';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import useChannelsStore from '../../store/channels';
+import useLogosStore from '../../store/logos';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ getStatsByChannelId,
+ getVODStats,
+ stopChannel,
+ stopClient,
+ stopVODClient,
+} from '../../utils/pages/StatsUtils.js';
+
+// Mock dependencies
+vi.mock('../../store/channels');
+vi.mock('../../store/logos');
+vi.mock('../../store/streamProfiles');
+vi.mock('../../hooks/useLocalStorage');
+
+vi.mock('../../components/SystemEvents', () => ({
+ default: () =>
SystemEvents
+}));
+
+vi.mock('../../components/ErrorBoundary.jsx', () => ({
+ default: ({ children }) =>
{children}
+}));
+
+vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({
+ default: ({ vodContent, stopVODClient }) => (
+
+ VODConnectionCard - {vodContent.content_uuid}
+ {vodContent.connections?.map((conn) => (
+ stopVODClient(conn.client_id)}
+ >
+ Stop VOD Client
+
+ ))}
+
+ ),
+}));
+
+vi.mock('../../components/cards/StreamConnectionCard.jsx', () => ({
+ default: ({ channel }) => (
+
+ StreamConnectionCard - {channel.uuid}
+
+ ),
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) =>
{children}
,
+ Button: ({ children, onClick, loading, ...props }) => (
+
+ {children}
+
+ ),
+ Group: ({ children }) =>
{children}
,
+ LoadingOverlay: () =>
Loading...
,
+ Text: ({ children }) =>
{children} ,
+ Title: ({ children }) =>
{children} ,
+ NumberInput: ({ value, onChange, min, max, ...props }) => (
+
onChange(Number(e.target.value))}
+ min={min}
+ max={max}
+ {...props}
+ />
+ ),
+}));
+
+//mock stats utils
+vi.mock('../../utils/pages/StatsUtils', () => {
+ return {
+ fetchActiveChannelStats: vi.fn(),
+ getVODStats: vi.fn(),
+ getClientStats: vi.fn(),
+ getCombinedConnections: vi.fn(),
+ getStatsByChannelId: vi.fn(),
+ stopChannel: vi.fn(),
+ stopClient: vi.fn(),
+ stopVODClient: vi.fn(),
+ };
+});
+
+describe('StatsPage', () => {
+ const mockChannels = [
+ { id: 1, uuid: 'channel-1', name: 'Channel 1' },
+ { id: 2, uuid: 'channel-2', name: 'Channel 2' },
+ ];
+
+ const mockChannelsByUUID = {
+ 'channel-1': mockChannels[0],
+ 'channel-2': mockChannels[1],
+ };
+
+ const mockStreamProfiles = [
+ { id: 1, name: 'Profile 1' },
+ ];
+
+ const mockLogos = {
+ 'logo-1': 'logo-url-1',
+ };
+
+ const mockChannelStats = {
+ channels: [
+ { channel_id: 1, uuid: 'channel-1', connections: 2 },
+ { channel_id: 2, uuid: 'channel-2', connections: 1 },
+ ],
+ };
+
+ const mockVODStats = {
+ vod_connections: [
+ {
+ content_uuid: 'vod-1',
+ connections: [
+ { client_id: 'client-1', ip: '192.168.1.1' },
+ ],
+ },
+ ],
+ };
+
+ const mockProcessedChannelHistory = {
+ 1: { id: 1, uuid: 'channel-1', connections: 2 },
+ 2: { id: 2, uuid: 'channel-2', connections: 1 },
+ };
+
+ const mockClients = [
+ { id: 'client-1', channel_id: 1 },
+ { id: 'client-2', channel_id: 1 },
+ { id: 'client-3', channel_id: 2 },
+ ];
+
+ 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' }] } },
+ ];
+
+ let mockSetChannelStats;
+ let mockSetRefreshInterval;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockSetChannelStats = vi.fn();
+ mockSetRefreshInterval = vi.fn();
+
+ // Setup store mocks
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ channels: mockChannels,
+ channelsByUUID: mockChannelsByUUID,
+ stats: { channels: mockChannelStats.channels },
+ setChannelStats: mockSetChannelStats,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useStreamProfilesStore.mockImplementation((selector) => {
+ const state = {
+ profiles: mockStreamProfiles,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ logos: mockLogos,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLocalStorage.mockReturnValue([5, mockSetRefreshInterval]);
+
+ // Setup API mocks
+ fetchActiveChannelStats.mockResolvedValue(mockChannelStats);
+ getVODStats.mockResolvedValue(mockVODStats);
+ getStatsByChannelId.mockReturnValue(mockProcessedChannelHistory);
+ getClientStats.mockReturnValue(mockClients);
+ getCombinedConnections.mockReturnValue(mockCombinedConnections);
+ stopVODClient.mockResolvedValue({});
+
+ delete window.location;
+ window.location = { pathname: '/stats' };
+ });
+
+ describe('Initial Rendering', () => {
+ it('renders the page title', async () => {
+ render(
);
+ await screen.findByText('Active Connections')
+ });
+
+ it('fetches initial stats on mount', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('displays connection counts', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 streams/)).toBeInTheDocument();
+ expect(screen.getByText(/1 VOD connection/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders SystemEvents component', async () => {
+ render(
);
+ await screen.findByTestId('system-events')
+ });
+ });
+
+ describe('Refresh Interval Controls', () => {
+ it('displays default refresh interval', () => {
+ render(
);
+
+ waitFor(() => {
+ const input = screen.getByTestId('refresh-interval-input');
+ expect(input).toHaveValue(5);
+ });
+ });
+
+ it('updates refresh interval when input changes', async () => {
+ render(
);
+
+ const input = screen.getByTestId('refresh-interval-input');
+ fireEvent.change(input, { target: { value: '10' } });
+
+ await waitFor(() => {
+ expect(mockSetRefreshInterval).toHaveBeenCalledWith(10);
+ });
+ });
+
+ it('displays polling active message when interval > 0', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Refreshing every 5s/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays disabled message when interval is 0', async () => {
+ useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
+ render(
);
+
+ await screen.findByText('Refreshing disabled')
+ });
+ });
+
+ describe('Auto-refresh Polling', () => {
+ it('sets up polling interval for stats', async () => {
+ vi.useFakeTimers();
+
+ render(
);
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+
+ // Advance timers by 5 seconds
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+
+ vi.useRealTimers();
+ });
+
+ it('does not poll when interval is 0', async () => {
+ vi.useFakeTimers();
+
+ useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
+ render(
);
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(10000);
+ });
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+
+ it('clears interval on unmount', async () => {
+ vi.useFakeTimers();
+
+ const { unmount } = render(
);
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ unmount();
+
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ // Should not fetch again after unmount
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('Manual Refresh', () => {
+ it('refreshes stats when Refresh Now button is clicked', async () => {
+ render(
);
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ const refreshButton = screen.getByText('Refresh Now');
+ fireEvent.click(refreshButton);
+
+ await waitFor(() => {
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+
+ describe('Connection Display', () => {
+ it('renders stream connection cards', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('stream-connection-card-channel-1')).toBeInTheDocument();
+ expect(screen.getByTestId('stream-connection-card-channel-2')).toBeInTheDocument();
+ });
+ });
+
+ it('renders VOD connection cards', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument();
+ });
+ });
+
+ it('displays empty state when no connections', async () => {
+ getCombinedConnections.mockReturnValue([]);
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('No active connections')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('VOD Client Management', () => {
+ it('stops VOD client when stop button is clicked', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('stop-vod-client-client-1')).toBeInTheDocument();
+ });
+
+ const stopButton = screen.getByTestId('stop-vod-client-client-1');
+ fireEvent.click(stopButton);
+
+ await waitFor(() => {
+ expect(stopVODClient).toHaveBeenCalledWith('client-1');
+ });
+ });
+
+ it('refreshes VOD stats after stopping client', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+ });
+
+ const stopButton = await screen.findByTestId('stop-vod-client-client-1');
+ fireEvent.click(stopButton);
+
+ await waitFor(() => {
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+
+ describe('Stats Processing', () => {
+ it('processes channel stats correctly', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(getStatsByChannelId).toHaveBeenCalledWith(
+ mockChannelStats,
+ expect.any(Object),
+ mockChannelsByUUID,
+ mockChannels,
+ mockStreamProfiles
+ );
+ });
+ });
+
+ it('updates clients based on processed stats', async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(getClientStats).toHaveBeenCalledWith(mockProcessedChannelHistory);
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('handles fetchActiveChannelStats error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ fetchActiveChannelStats.mockRejectedValue(new Error('API Error'));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Error fetching channel stats:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+
+ it('handles getVODStats error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ getVODStats.mockRejectedValue(new Error('VOD API Error'));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Error fetching VOD stats:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Connection Count Display', () => {
+ it('displays singular form for 1 stream', async () => {
+ getCombinedConnections.mockReturnValue([
+ { id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
+ ]);
+ getStatsByChannelId.mockReturnValue({ 1: { id: 1, uuid: 'channel-1' } });
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText(/1 stream/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays plural form for multiple VOD connections', async () => {
+ const multiVODStats = {
+ vod_connections: [
+ { content_uuid: 'vod-1', connections: [{ client_id: 'c1' }] },
+ { content_uuid: 'vod-2', connections: [{ client_id: 'c2' }] },
+ ],
+ };
+ getVODStats.mockResolvedValue(multiVODStats);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 VOD connections/)).toBeInTheDocument();
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Users.test.jsx b/frontend/src/pages/__tests__/Users.test.jsx
new file mode 100644
index 00000000..3ee63627
--- /dev/null
+++ b/frontend/src/pages/__tests__/Users.test.jsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import UsersPage from '../Users';
+import useAuthStore from '../../store/auth';
+
+vi.mock('../../store/auth');
+vi.mock('../../components/tables/UsersTable', () => ({
+ default: () =>
UsersTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) =>
{children}
,
+}));
+
+describe('UsersPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing when user is not authenticated', () => {
+ useAuthStore.mockReturnValue({ id: null });
+
+ const { container } = render(
);
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.queryByTestId('users-table')).not.toBeInTheDocument();
+ });
+
+ it('renders UsersTable when user is authenticated', () => {
+ useAuthStore.mockReturnValue({ id: 1, email: 'test@example.com' });
+
+ render(
);
+
+ expect(screen.getByTestId('users-table')).toBeInTheDocument();
+ });
+
+ it('handles user with id 0 as authenticated', () => {
+ useAuthStore.mockReturnValue({ id: 0 });
+
+ const { container } = render(
);
+
+ // id: 0 is falsy, so should render empty
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('switches from unauthenticated to authenticated state', () => {
+ useAuthStore.mockReturnValue({ id: null });
+
+ render(
);
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+
+ useAuthStore.mockReturnValue({ id: 1 });
+
+ render(
);
+
+ expect(screen.getByTestId('users-table')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/VODs.test.jsx b/frontend/src/pages/__tests__/VODs.test.jsx
new file mode 100644
index 00000000..6e7c00ec
--- /dev/null
+++ b/frontend/src/pages/__tests__/VODs.test.jsx
@@ -0,0 +1,468 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import VODsPage from '../VODs';
+import useVODStore from '../../store/useVODStore';
+import {
+ filterCategoriesToEnabled,
+ getCategoryOptions,
+} from '../../utils/pages/VODsUtils.js';
+
+vi.mock('../../store/useVODStore');
+
+vi.mock('../../components/SeriesModal', () => ({
+ default: ({ opened, series, onClose }) =>
+ opened ? (
+
+
{series?.name}
+
Close
+
+ ) : null
+}));
+vi.mock('../../components/VODModal', () => ({
+ default: ({ opened, vod, onClose }) =>
+ opened ? (
+
+ ) : null
+}));
+vi.mock('../../components/cards/VODCard', () => ({
+ default: ({ vod, onClick }) => (
+
onClick(vod)}>
+
{vod.name}
+
+ )
+}));
+vi.mock('../../components/cards/SeriesCard', () => ({
+ default: ({ series, onClick }) => (
+
onClick(series)}>
+
{series.name}
+
+ )
+}));
+
+vi.mock('@mantine/core', () => {
+ const gridComponent = ({ children, ...props }) =>
{children}
;
+ gridComponent.Col = ({ children, ...props }) =>
{children}
;
+
+ return {
+ Box: ({ children, ...props }) =>
{children}
,
+ Stack: ({ children, ...props }) =>
{children}
,
+ Group: ({ children, ...props }) =>
{children}
,
+ Flex: ({ children, ...props }) =>
{children}
,
+ Title: ({ children, ...props }) =>
{children} ,
+ TextInput: ({ value, onChange, placeholder, icon }) => (
+
+ {icon}
+
+
+ ),
+ Select: ({ value, onChange, data, label, placeholder }) => (
+
+ {label && {label} }
+ onChange?.(e.target.value)}
+ aria-label={placeholder || label}
+ >
+ {data?.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ),
+ SegmentedControl: ({ value, onChange, data }) => (
+
+ {data.map((item) => (
+ onChange(item.value)}
+ data-active={value === item.value}
+ >
+ {item.label}
+
+ ))}
+
+ ),
+ Pagination: ({ page, onChange, total }) => (
+
+ onChange(page - 1)} disabled={page === 1}>
+ Prev
+
+ {page} of {total}
+ onChange(page + 1)} disabled={page === total}>
+ Next
+
+
+ ),
+ Grid: gridComponent,
+ GridCol: gridComponent.Col,
+ Loader: () =>
Loading...
,
+ LoadingOverlay: ({ visible }) =>
+ visible ?
Loading...
: null,
+ };
+});
+
+vi.mock('../../utils/pages/VODsUtils.js', () => {
+ return {
+ filterCategoriesToEnabled: vi.fn(),
+ getCategoryOptions: vi.fn(),
+ };
+});
+
+describe('VODsPage', () => {
+ const mockFetchContent = vi.fn();
+ const mockFetchCategories = vi.fn();
+ const mockSetFilters = vi.fn();
+ const mockSetPage = vi.fn();
+ const mockSetPageSize = vi.fn();
+
+ const defaultStoreState = {
+ currentPageContent: [],
+ categories: {},
+ filters: { type: 'all', search: '', category: '' },
+ currentPage: 1,
+ totalCount: 0,
+ pageSize: 12,
+ setFilters: mockSetFilters,
+ setPage: mockSetPage,
+ setPageSize: mockSetPageSize,
+ fetchContent: mockFetchContent,
+ fetchCategories: mockFetchCategories,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetchContent.mockResolvedValue();
+ mockFetchCategories.mockResolvedValue();
+ filterCategoriesToEnabled.mockReturnValue({});
+ getCategoryOptions.mockReturnValue([]);
+ useVODStore.mockImplementation((selector) => selector(defaultStoreState));
+ localStorage.clear();
+ });
+
+ it('renders the page title', async () => {
+ render(
);
+ await screen.findByText('Video on Demand');
+ });
+
+ it('fetches categories on mount', async () => {
+ render(
);
+ await waitFor(() => {
+ expect(mockFetchCategories).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('fetches content on mount', async () => {
+ render(
);
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('displays loader during initial load', async () => {
+ render(
);
+ await screen.findByTestId('loader');
+ });
+
+ it('displays content after loading', async () => {
+ const stateWithContent = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Movie 1', contentType: 'movie' },
+ { id: 2, name: 'Series 1', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithContent));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Movie 1')).toBeInTheDocument();
+ expect(screen.getByText('Series 1')).toBeInTheDocument();
+ });
+ });
+
+ it('renders VOD cards for movies', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('vod-card')).toBeInTheDocument();
+ });
+ });
+
+ it('renders series cards for series', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Series 1', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('series-card')).toBeInTheDocument();
+ });
+ });
+
+ it('opens VOD modal when VOD card is clicked', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Movie', contentType: 'movie' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render(
);
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('vod-card'));
+ });
+
+ expect(screen.getByTestId('vod-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('vod-name')).toHaveTextContent('Test Movie');
+ });
+
+ it('opens series modal when series card is clicked', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Series', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render(
);
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('series-card'));
+ });
+
+ expect(screen.getByTestId('series-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('series-name')).toHaveTextContent('Test Series');
+ });
+
+ it('closes VOD modal when close button is clicked', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Movie', contentType: 'movie' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render(
);
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('vod-card'));
+ });
+
+ fireEvent.click(screen.getByText('Close'));
+
+ expect(screen.queryByTestId('vod-modal')).not.toBeInTheDocument();
+ });
+
+ it('closes series modal when close button is clicked', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Series', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render(
);
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('series-card'));
+ });
+
+ fireEvent.click(screen.getByText('Close'));
+
+ expect(screen.queryByTestId('series-modal')).not.toBeInTheDocument();
+ });
+
+ it('updates filters when search input changes', async () => {
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText('Search VODs...');
+ fireEvent.change(searchInput, { target: { value: 'test search' } });
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({ search: 'test search' });
+ });
+ });
+
+ it('updates filters and resets page when type changes', async () => {
+ render(
);
+
+ const moviesButton = screen.getByText('Movies');
+ fireEvent.click(moviesButton);
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({
+ type: 'movies',
+ category: '',
+ });
+ expect(mockSetPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('updates filters and resets page when category changes', async () => {
+ getCategoryOptions.mockReturnValue([
+ { value: 'action', label: 'Action' },
+ ]);
+
+ render(
);
+
+ const categorySelect = screen.getByLabelText('Category');
+ fireEvent.change(categorySelect, { target: { value: 'action' } });
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({ category: 'action' });
+ expect(mockSetPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('updates page size and saves to localStorage', async () => {
+ render(
);
+
+ const pageSizeSelect = screen.getByLabelText('Page Size');
+ fireEvent.change(pageSizeSelect, { target: { value: '24' } });
+
+ await waitFor(() => {
+ expect(mockSetPageSize).toHaveBeenCalledWith(24);
+ expect(localStorage.getItem('vodsPageSize')).toBe('24');
+ });
+ });
+
+ it('loads page size from localStorage on mount', async () => {
+ localStorage.setItem('vodsPageSize', '48');
+
+ render(
);
+
+ await waitFor(() => {
+ expect(mockSetPageSize).toHaveBeenCalledWith(48);
+ });
+ });
+
+ it('displays pagination when total pages > 1', async () => {
+ const stateWithPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 25,
+ pageSize: 12,
+ };
+ useVODStore.mockImplementation((selector) =>
+ selector(stateWithPagination)
+ );
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ });
+ });
+
+ it('does not display pagination when total pages <= 1', async () => {
+ const stateNoPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 5,
+ pageSize: 12,
+ };
+ useVODStore.mockImplementation((selector) => selector(stateNoPagination));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
+ });
+ });
+
+ it('changes page when pagination is clicked', async () => {
+ const stateWithPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 25,
+ pageSize: 12,
+ currentPage: 1,
+ };
+ useVODStore.mockImplementation((selector) =>
+ selector(stateWithPagination)
+ );
+
+ render(
);
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Next'));
+ });
+
+ expect(mockSetPage).toHaveBeenCalledWith(2);
+ });
+
+ it('refetches content when filters change', async () => {
+ const { rerender } = render(
);
+
+ const updatedState = {
+ ...defaultStoreState,
+ filters: { type: 'movies', search: '', category: '' },
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender(
);
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('refetches content when page changes', async () => {
+ const { rerender } = render(
);
+
+ const updatedState = {
+ ...defaultStoreState,
+ currentPage: 2,
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender(
);
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('refetches content when page size changes', async () => {
+ const { rerender } = render(
);
+
+ const updatedState = {
+ ...defaultStoreState,
+ pageSize: 24,
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender(
);
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js
new file mode 100644
index 00000000..01bbe846
--- /dev/null
+++ b/frontend/src/pages/__tests__/guideUtils.test.js
@@ -0,0 +1,1108 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import * as guideUtils from '../guideUtils';
+import * as dateTimeUtils from '../../utils/dateTimeUtils';
+import API from '../../api';
+
+dayjs.extend(utc);
+
+vi.mock('../../utils/dateTimeUtils', () => ({
+ convertToMs: vi.fn((time) => {
+ if (typeof time === 'number') return time;
+ return dayjs(time).valueOf();
+ }),
+ initializeTime: vi.fn((time) => {
+ if (typeof time === 'number') return dayjs(time);
+ return dayjs(time);
+ }),
+ startOfDay: vi.fn((time) => dayjs(time).startOf('day')),
+ isBefore: vi.fn((a, b) => dayjs(a).isBefore(dayjs(b))),
+ isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))),
+ isSame: vi.fn((a, b, unit) => dayjs(a).isSame(dayjs(b), unit)),
+ add: vi.fn((time, amount, unit) => dayjs(time).add(amount, unit)),
+ diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)),
+ format: vi.fn((time, formatStr) => dayjs(time).format(formatStr)),
+ getNow: vi.fn(() => dayjs()),
+ getNowMs: vi.fn(() => dayjs().valueOf()),
+ roundToNearest: vi.fn((time, minutes) => {
+ const m = dayjs(time).minute();
+ const rounded = Math.round(m / minutes) * minutes;
+ return dayjs(time).minute(rounded).second(0).millisecond(0);
+ }),
+}));
+
+vi.mock('../../api', () => ({
+ default: {
+ getGrid: vi.fn(),
+ createRecording: vi.fn(),
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRules: vi.fn(),
+ deleteSeriesRule: vi.fn(),
+ listSeriesRules: vi.fn(),
+ },
+}));
+
+describe('guideUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('buildChannelIdMap', () => {
+ it('should create map with channel UUIDs when no EPG data', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: null },
+ { id: 2, uuid: 'uuid-2', epg_data_id: null },
+ ];
+ const tvgsById = {};
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ expect(result.get('uuid-2')).toEqual([2]);
+ });
+
+ it('should use tvg_id from EPG data for regular sources', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('tvg-123')).toEqual([1]);
+ });
+
+ it('should use channel UUID for dummy EPG sources', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'dummy' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ });
+
+ it('should group multiple channels with same tvg_id', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ { id: 2, uuid: 'uuid-2', epg_data_id: 'epg-2' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'shared-tvg', epg_source: 'source-1' },
+ 'epg-2': { tvg_id: 'shared-tvg', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('shared-tvg')).toEqual([1, 2]);
+ });
+
+ it('should fall back to UUID when tvg_id is null', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: null, epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ });
+ });
+
+ describe('mapProgramsByChannel', () => {
+ it('should return empty map when no programs', () => {
+ const channelIdByTvgId = new Map();
+
+ const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId);
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should return empty map when no channel mapping', () => {
+ const programs = [{ tvg_id: 'tvg-1' }];
+
+ const result = guideUtils.mapProgramsByChannel(programs, new Map());
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should map programs to channels', () => {
+ const nowMs = 1000000;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(1)[0]).toMatchObject({
+ id: 1,
+ tvg_id: 'tvg-1',
+ });
+ });
+
+ it('should precompute startMs and endMs', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.convertToMs.mockImplementation((time) =>
+ typeof time === 'number' ? time : dayjs(time).valueOf()
+ );
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0]).toHaveProperty('startMs');
+ expect(result.get(1)[0]).toHaveProperty('endMs');
+ });
+
+ it('should mark program as live when now is between start and end', () => {
+ const startMs = 1000;
+ const endMs = 2000;
+ const nowMs = 1500;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs,
+ endMs,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].isLive).toBe(true);
+ expect(result.get(1)[0].isPast).toBe(false);
+ });
+
+ it('should mark program as past when now is after end', () => {
+ const startMs = 1000;
+ const endMs = 2000;
+ const nowMs = 3000;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs,
+ endMs,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].isLive).toBe(false);
+ expect(result.get(1)[0].isPast).toBe(true);
+ });
+
+ it('should add program to multiple channels with same tvg_id', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(2)).toHaveLength(1);
+ expect(result.get(3)).toHaveLength(1);
+ });
+
+ it('should sort programs by start time', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+
+ const programs = [
+ {
+ id: 2,
+ tvg_id: 'tvg-1',
+ startMs: 2000,
+ endMs: 3000,
+ start_time: '2024-01-15T11:00:00Z',
+ end_time: '2024-01-15T12:00:00Z',
+ },
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs: 1000,
+ endMs: 2000,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].id).toBe(1);
+ expect(result.get(1)[1].id).toBe(2);
+ });
+ });
+
+ describe('computeRowHeights', () => {
+ it('should return empty array when no channels', () => {
+ const result = guideUtils.computeRowHeights([], new Map(), null);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return default height for all channels when none expanded', () => {
+ const channels = [{ id: 1 }, { id: 2 }];
+ const programsByChannelId = new Map();
+
+ const result = guideUtils.computeRowHeights(channels, programsByChannelId, null);
+
+ expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ });
+
+ it('should return expanded height for channel with expanded program', () => {
+ const channels = [{ id: 1 }, { id: 2 }];
+ const programsByChannelId = new Map([
+ [1, [{ id: 'program-1' }]],
+ [2, [{ id: 'program-2' }]],
+ ]);
+
+ const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1');
+
+ expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ });
+
+ it('should use custom heights when provided', () => {
+ const channels = [{ id: 1 }];
+ const programsByChannelId = new Map([[1, [{ id: 'program-1' }]]]);
+ const customDefault = 100;
+ const customExpanded = 200;
+
+ const result = guideUtils.computeRowHeights(
+ channels,
+ programsByChannelId,
+ 'program-1',
+ customDefault,
+ customExpanded
+ );
+
+ expect(result).toEqual([customExpanded]);
+ });
+ });
+
+ describe('fetchPrograms', () => {
+ it('should fetch and transform programs', async () => {
+ const mockPrograms = [
+ {
+ id: 1,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ API.getGrid.mockResolvedValue(mockPrograms);
+ dateTimeUtils.convertToMs.mockReturnValue(1000);
+
+ const result = await guideUtils.fetchPrograms();
+
+ expect(API.getGrid).toHaveBeenCalledTimes(1);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveProperty('startMs');
+ expect(result[0]).toHaveProperty('endMs');
+ });
+ });
+
+ describe('sortChannels', () => {
+ it('should sort channels by channel number', () => {
+ const channels = {
+ 1: { id: 1, channel_number: 3 },
+ 2: { id: 2, channel_number: 1 },
+ 3: { id: 3, channel_number: 2 },
+ };
+
+ const result = guideUtils.sortChannels(channels);
+
+ expect(result[0].channel_number).toBe(1);
+ expect(result[1].channel_number).toBe(2);
+ expect(result[2].channel_number).toBe(3);
+ });
+
+ it('should put channels without number at end', () => {
+ const channels = {
+ 1: { id: 1, channel_number: 2 },
+ 2: { id: 2, channel_number: null },
+ 3: { id: 3, channel_number: 1 },
+ };
+
+ const result = guideUtils.sortChannels(channels);
+
+ expect(result[0].channel_number).toBe(1);
+ expect(result[1].channel_number).toBe(2);
+ expect(result[2].channel_number).toBeNull();
+ });
+ });
+
+ describe('filterGuideChannels', () => {
+ it('should return all channels when no filters', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
+
+ expect(result).toHaveLength(2);
+ });
+
+ it('should filter by search query', () => {
+ const channels = [
+ { id: 1, name: 'ESPN' },
+ { id: 2, name: 'CNN' },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {});
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('ESPN');
+ });
+
+ it('should filter by channel group', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1', channel_group_id: 1 },
+ { id: 2, name: 'Channel 2', channel_group_id: 2 },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {});
+
+ expect(result).toHaveLength(1);
+ expect(result[0].channel_group_id).toBe(1);
+ });
+
+ it('should filter by profile with array of channels', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const profiles = {
+ profile1: {
+ channels: [
+ { id: 1, enabled: true },
+ { id: 2, enabled: false },
+ ],
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('should filter by profile with Set of channels', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const profiles = {
+ profile1: {
+ channels: new Set([1]),
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('should apply multiple filters together', () => {
+ const channels = [
+ { id: 1, name: 'ESPN', channel_group_id: 1 },
+ { id: 2, name: 'ESPN2', channel_group_id: 2 },
+ { id: 3, name: 'CNN', channel_group_id: 1 },
+ ];
+ const profiles = {
+ profile1: {
+ channels: [
+ { id: 1, enabled: true },
+ { id: 3, enabled: true },
+ ],
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+ });
+
+ describe('calculateEarliestProgramStart', () => {
+ it('should return default when no programs', () => {
+ const defaultStart = dayjs('2024-01-15T00:00:00Z');
+
+ const result = guideUtils.calculateEarliestProgramStart([], defaultStart);
+
+ expect(result).toBe(defaultStart);
+ });
+
+ it('should return earliest program start', () => {
+ dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+
+ const programs = [
+ { start_time: '2024-01-15T12:00:00Z' },
+ { start_time: '2024-01-15T10:00:00Z' },
+ { start_time: '2024-01-15T14:00:00Z' },
+ ];
+ const defaultStart = dayjs.utc('2024-01-16T00:00:00Z');
+
+ const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart);
+
+ expect(result.hour()).toBe(10);
+ });
+ });
+
+ describe('calculateLatestProgramEnd', () => {
+ it('should return default when no programs', () => {
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+
+ const result = guideUtils.calculateLatestProgramEnd([], defaultEnd);
+
+ expect(result).toBe(defaultEnd);
+ });
+
+ it('should return latest program end', () => {
+ dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
+ dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b)));
+
+ const programs = [
+ { end_time: '2024-01-15T12:00:00Z' },
+ { end_time: '2024-01-15T18:00:00Z' },
+ { end_time: '2024-01-15T14:00:00Z' },
+ ];
+ const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z');
+
+ const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd);
+
+ expect(result.hour()).toBe(18);
+ });
+ });
+
+ describe('calculateStart', () => {
+ it('should return earliest when before default', () => {
+ const earliest = dayjs('2024-01-15T08:00:00Z');
+ const defaultStart = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(true);
+
+ const result = guideUtils.calculateStart(earliest, defaultStart);
+
+ expect(result).toBe(earliest);
+ });
+
+ it('should return default when earliest is after', () => {
+ const earliest = dayjs('2024-01-15T12:00:00Z');
+ const defaultStart = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+
+ const result = guideUtils.calculateStart(earliest, defaultStart);
+
+ expect(result).toBe(defaultStart);
+ });
+ });
+
+ describe('calculateEnd', () => {
+ it('should return latest when after default', () => {
+ const latest = dayjs('2024-01-16T02:00:00Z');
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+ dateTimeUtils.isAfter.mockReturnValue(true);
+
+ const result = guideUtils.calculateEnd(latest, defaultEnd);
+
+ expect(result).toBe(latest);
+ });
+
+ it('should return default when latest is before', () => {
+ const latest = dayjs('2024-01-15T22:00:00Z');
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+ dateTimeUtils.isAfter.mockReturnValue(false);
+
+ const result = guideUtils.calculateEnd(latest, defaultEnd);
+
+ expect(result).toBe(defaultEnd);
+ });
+ });
+
+ describe('mapChannelsById', () => {
+ it('should create map of channels by id', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+
+ const result = guideUtils.mapChannelsById(channels);
+
+ expect(result.get(1).name).toBe('Channel 1');
+ expect(result.get(2).name).toBe('Channel 2');
+ });
+ });
+
+ describe('mapRecordingsByProgramId', () => {
+ it('should return empty map for null recordings', () => {
+ const result = guideUtils.mapRecordingsByProgramId(null);
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should map recordings by program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ custom_properties: {
+ program: { id: 'program-1' },
+ },
+ },
+ {
+ id: 2,
+ custom_properties: {
+ program: { id: 'program-2' },
+ },
+ },
+ ];
+
+ const result = guideUtils.mapRecordingsByProgramId(recordings);
+
+ expect(result.get('program-1').id).toBe(1);
+ expect(result.get('program-2').id).toBe(2);
+ });
+
+ it('should skip recordings without program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ custom_properties: {},
+ },
+ ];
+
+ const result = guideUtils.mapRecordingsByProgramId(recordings);
+
+ expect(result.size).toBe(0);
+ });
+ });
+
+ describe('formatTime', () => {
+ it('should return "Today" for today', () => {
+ const today = dayjs();
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValueOnce(true);
+
+ const result = guideUtils.formatTime(today, 'MM/DD');
+
+ expect(result).toBe('Today');
+ });
+
+ it('should return "Tomorrow" for tomorrow', () => {
+ const today = dayjs();
+ const tomorrow = today.add(1, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true);
+
+ const result = guideUtils.formatTime(tomorrow, 'MM/DD');
+
+ expect(result).toBe('Tomorrow');
+ });
+
+ it('should return day name within a week', () => {
+ const today = dayjs();
+ const future = today.add(3, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValue(false);
+ dateTimeUtils.isBefore.mockReturnValue(true);
+ dateTimeUtils.format.mockReturnValue('Wednesday');
+
+ const result = guideUtils.formatTime(future, 'MM/DD');
+
+ expect(result).toBe('Wednesday');
+ });
+
+ it('should return formatted date beyond a week', () => {
+ const today = dayjs();
+ const future = today.add(10, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValue(false);
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.format.mockReturnValue('01/25');
+
+ const result = guideUtils.formatTime(future, 'MM/DD');
+
+ expect(result).toBe('01/25');
+ });
+ });
+
+ describe('calculateHourTimeline', () => {
+ it('should generate hours between start and end', () => {
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T13:00:00Z');
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isSame.mockReturnValue(true);
+
+ const formatDayLabel = vi.fn((time) => 'Today');
+ const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+
+ expect(result).toHaveLength(3);
+ expect(formatDayLabel).toHaveBeenCalledTimes(3);
+ });
+
+ it('should mark new day transitions', () => {
+ const start = dayjs('2024-01-15T23:00:00Z');
+ const end = dayjs('2024-01-16T02:00:00Z');
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isSame.mockImplementation((a, b, unit) => dayjs(a).isSame(dayjs(b), unit));
+
+ const formatDayLabel = vi.fn((time) => 'Day');
+ const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+
+ expect(result[0].isNewDay).toBe(true);
+ });
+ });
+
+ describe('calculateNowPosition', () => {
+ it('should return -1 when now is before start', () => {
+ const now = dayjs('2024-01-15T09:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(true);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBe(-1);
+ });
+
+ it('should return -1 when now is after end', () => {
+ const now = dayjs('2024-01-15T19:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.isAfter.mockReturnValue(true);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBe(-1);
+ });
+
+ it('should calculate position when now is between start and end', () => {
+ const now = dayjs('2024-01-15T11:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.isAfter.mockReturnValue(false);
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBeGreaterThan(0);
+ });
+ });
+
+ describe('calculateScrollPosition', () => {
+ it('should calculate scroll position for current time', () => {
+ const now = dayjs('2024-01-15T11:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const rounded = dayjs('2024-01-15T11:00:00Z');
+ dateTimeUtils.roundToNearest.mockReturnValue(rounded);
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateScrollPosition(now, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should return 0 when calculated position is negative', () => {
+ const now = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const rounded = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.roundToNearest.mockReturnValue(rounded);
+ dateTimeUtils.diff.mockReturnValue(0);
+
+ const result = guideUtils.calculateScrollPosition(now, start);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('matchChannelByTvgId', () => {
+ it('should return null when no matching channel ids', () => {
+ const channelIdByTvgId = new Map();
+ const channelById = new Map();
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return first matching channel', () => {
+ const channel = { id: 1, name: 'Channel 1' };
+ const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
+ const channelById = new Map([[1, channel]]);
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBe(channel);
+ });
+
+ it('should return null when channel not in channelById map', () => {
+ const channelIdByTvgId = new Map([['tvg-1', [999]]]);
+ const channelById = new Map();
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('fetchRules', () => {
+ it('should fetch series rules from API', async () => {
+ const mockRules = [{ id: 1, tvg_id: 'tvg-1' }];
+ API.listSeriesRules.mockResolvedValue(mockRules);
+
+ const result = await guideUtils.fetchRules();
+
+ expect(API.listSeriesRules).toHaveBeenCalledTimes(1);
+ expect(result).toBe(mockRules);
+ });
+ });
+
+ describe('getRuleByProgram', () => {
+ it('should return null when no rules', () => {
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(null, program);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should find rule by tvg_id without title', () => {
+ const rules = [{ tvg_id: 'tvg-1', title: null }];
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[0]);
+ });
+
+ it('should find rule by tvg_id and title', () => {
+ const rules = [
+ { tvg_id: 'tvg-1', title: 'Show A' },
+ { tvg_id: 'tvg-1', title: 'Show B' },
+ ];
+ const program = { tvg_id: 'tvg-1', title: 'Show B' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[1]);
+ });
+
+ it('should handle string comparison for tvg_id', () => {
+ const rules = [{ tvg_id: 123, title: null }];
+ const program = { tvg_id: '123', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[0]);
+ });
+ });
+
+ describe('createRecording', () => {
+ it('should create recording via API', async () => {
+ const channel = { id: 1 };
+ const program = {
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ };
+
+ await guideUtils.createRecording(channel, program);
+
+ expect(API.createRecording).toHaveBeenCalledWith({
+ channel: '1',
+ start_time: program.start_time,
+ end_time: program.end_time,
+ custom_properties: { program },
+ });
+ });
+ });
+
+ describe('createSeriesRule', () => {
+ it('should create series rule via API', async () => {
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+ const mode = 'all';
+
+ await guideUtils.createSeriesRule(program, mode);
+
+ expect(API.createSeriesRule).toHaveBeenCalledWith({
+ tvg_id: program.tvg_id,
+ mode,
+ title: program.title,
+ });
+ });
+ });
+
+ describe('evaluateSeriesRule', () => {
+ it('should evaluate series rule via API', async () => {
+ const program = { tvg_id: 'tvg-1' };
+
+ await guideUtils.evaluateSeriesRule(program);
+
+ expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id);
+ });
+ });
+
+ describe('calculateLeftScrollPosition', () => {
+ it('should calculate left position using startMs', () => {
+ const program = {
+ startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(),
+ };
+ const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf();
+ dateTimeUtils.convertToMs.mockImplementation((time) => {
+ if (typeof time === 'number') return time;
+ return dayjs.utc(time).valueOf();
+ });
+
+ const result = guideUtils.calculateLeftScrollPosition(program, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should calculate left position from start_time when no startMs', () => {
+ const program = {
+ start_time: '2024-01-15T10:30:00Z',
+ };
+ const start = '2024-01-15T10:00:00Z';
+ dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf());
+
+ const result = guideUtils.calculateLeftScrollPosition(program, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('calculateDesiredScrollPosition', () => {
+ it('should subtract 20 from left position', () => {
+ const result = guideUtils.calculateDesiredScrollPosition(100);
+
+ expect(result).toBe(80);
+ });
+
+ it('should return 0 when result would be negative', () => {
+ const result = guideUtils.calculateDesiredScrollPosition(10);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('calculateScrollPositionByTimeClick', () => {
+ it('should calculate scroll position from time click', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 100, width: 450 }),
+ },
+ clientX: 325,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should snap to 15-minute increments', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 0, width: 450 }),
+ },
+ clientX: 112.5,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(75);
+
+ guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(dateTimeUtils.diff).toHaveBeenCalled();
+ });
+
+ it('should handle click at end of hour', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 0, width: 450 }),
+ },
+ clientX: 450,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(120);
+
+ const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour');
+ });
+ });
+
+ describe('getGroupOptions', () => {
+ it('should return only "All" when no channel groups', () => {
+ const result = guideUtils.getGroupOptions(null, []);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('all');
+ });
+
+ it('should include groups used by channels', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Sports' },
+ 2: { id: 2, name: 'News' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ { id: 2, channel_group_id: 1 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].label).toBe('Sports');
+ });
+
+ it('should exclude groups not used by any channel', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Sports' },
+ 2: { id: 2, name: 'News' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].label).toBe('Sports');
+ });
+
+ it('should sort groups alphabetically', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Z Group' },
+ 2: { id: 2, name: 'A Group' },
+ 3: { id: 3, name: 'M Group' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ { id: 2, channel_group_id: 2 },
+ { id: 3, channel_group_id: 3 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result[1].label).toBe('A Group');
+ expect(result[2].label).toBe('M Group');
+ expect(result[3].label).toBe('Z Group');
+ });
+ });
+
+ describe('getProfileOptions', () => {
+ it('should return only "All" when no profiles', () => {
+ const result = guideUtils.getProfileOptions(null);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('all');
+ });
+
+ it('should include all profiles except id 0', () => {
+ const profiles = {
+ 0: { id: '0', name: 'All' },
+ 1: { id: '1', name: 'Profile 1' },
+ 2: { id: '2', name: 'Profile 2' },
+ };
+
+ const result = guideUtils.getProfileOptions(profiles);
+
+ expect(result).toHaveLength(3);
+ expect(result[1].label).toBe('Profile 1');
+ expect(result[2].label).toBe('Profile 2');
+ });
+ });
+
+ describe('deleteSeriesRuleByTvgId', () => {
+ it('should delete series rule via API', async () => {
+ await guideUtils.deleteSeriesRuleByTvgId('tvg-1');
+
+ expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1');
+ });
+ });
+
+ describe('evaluateSeriesRulesByTvgId', () => {
+ it('should evaluate series rules via API', async () => {
+ await guideUtils.evaluateSeriesRulesByTvgId('tvg-1');
+
+ expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1');
+ });
+ });
+});
diff --git a/frontend/src/pages/guide.css b/frontend/src/pages/guide.css
index 600bb449..15ff6e0e 100644
--- a/frontend/src/pages/guide.css
+++ b/frontend/src/pages/guide.css
@@ -67,3 +67,16 @@
.tv-guide {
position: relative;
}
+
+/* Hide bottom horizontal scrollbar for the guide's virtualized list only */
+.tv-guide .guide-list-outer {
+ /* Allow horizontal scrolling but hide the scrollbar visually */
+ overflow-x: auto !important;
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* IE and Edge */
+}
+
+/* Also hide scrollbars visually across browsers for the outer container */
+.tv-guide .guide-list-outer::-webkit-scrollbar {
+ display: none; /* Chrome, Safari, Opera */
+}
diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js
new file mode 100644
index 00000000..68bb74b2
--- /dev/null
+++ b/frontend/src/pages/guideUtils.js
@@ -0,0 +1,401 @@
+import {
+ convertToMs,
+ initializeTime,
+ startOfDay,
+ isBefore,
+ isAfter,
+ isSame,
+ add,
+ diff,
+ format,
+ getNow,
+ getNowMs,
+ roundToNearest
+} from '../utils/dateTimeUtils.js';
+import API from '../api.js';
+
+export const PROGRAM_HEIGHT = 90;
+export const EXPANDED_PROGRAM_HEIGHT = 180;
+/** Layout constants */
+export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
+export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
+export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
+export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
+
+export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
+ const map = new Map();
+ channels.forEach((channel) => {
+ const tvgRecord = channel.epg_data_id
+ ? tvgsById[channel.epg_data_id]
+ : null;
+
+ // For dummy EPG sources, ALWAYS use channel UUID to ensure unique programs per channel
+ // This prevents multiple channels with the same dummy EPG from showing identical data
+ let tvgId;
+ if (tvgRecord?.epg_source) {
+ const epgSource = epgs[tvgRecord.epg_source];
+ if (epgSource?.source_type === 'dummy') {
+ // Dummy EPG: use channel UUID for uniqueness
+ tvgId = channel.uuid;
+ } else {
+ // Regular EPG: use tvg_id from EPG data, or fall back to channel UUID
+ tvgId = tvgRecord.tvg_id ?? channel.uuid;
+ }
+ } else {
+ // No EPG data: use channel UUID
+ tvgId = channel.uuid;
+ }
+
+ if (tvgId) {
+ const tvgKey = String(tvgId);
+ if (!map.has(tvgKey)) {
+ map.set(tvgKey, []);
+ }
+ map.get(tvgKey).push(channel.id);
+ }
+ });
+ return map;
+}
+
+export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
+ if (!programs?.length || !channelIdByTvgId?.size) {
+ return new Map();
+ }
+
+ const map = new Map();
+ const nowMs = getNowMs();
+
+ programs.forEach((program) => {
+ const channelIds = channelIdByTvgId.get(String(program.tvg_id));
+ if (!channelIds || channelIds.length === 0) {
+ return;
+ }
+
+ const startMs = program.startMs ?? convertToMs(program.start_time);
+ const endMs = program.endMs ?? convertToMs(program.end_time);
+
+ const programData = {
+ ...program,
+ startMs,
+ endMs,
+ programStart: initializeTime(program.startMs),
+ programEnd: initializeTime(program.endMs),
+ // Precompute live/past status
+ isLive: nowMs >= program.startMs && nowMs < program.endMs,
+ isPast: nowMs >= program.endMs,
+ };
+
+ // Add this program to all channels that share the same TVG ID
+ channelIds.forEach((channelId) => {
+ if (!map.has(channelId)) {
+ map.set(channelId, []);
+ }
+ map.get(channelId).push(programData);
+ });
+ });
+
+ map.forEach((list) => {
+ list.sort((a, b) => a.startMs - b.startMs);
+ });
+
+ return map;
+};
+
+export function computeRowHeights(
+ filteredChannels,
+ programsByChannelId,
+ expandedProgramId,
+ defaultHeight = PROGRAM_HEIGHT,
+ expandedHeight = EXPANDED_PROGRAM_HEIGHT
+) {
+ if (!filteredChannels?.length) {
+ return [];
+ }
+
+ return filteredChannels.map((channel) => {
+ const channelPrograms = programsByChannelId.get(channel.id) || [];
+ const expanded = channelPrograms.some(
+ (program) => program.id === expandedProgramId
+ );
+ return expanded ? expandedHeight : defaultHeight;
+ });
+}
+
+export const fetchPrograms = async () => {
+ console.log('Fetching program grid...');
+ const fetched = await API.getGrid(); // GETs your EPG grid
+ console.log(`Received ${fetched.length} programs`);
+
+ return fetched.map((program) => {
+ return {
+ ...program,
+ startMs: convertToMs(program.start_time),
+ endMs: convertToMs(program.end_time),
+ };
+ });
+};
+
+export const sortChannels = (channels) => {
+ // Include ALL channels, sorted by channel number - don't filter by EPG data
+ const sortedChannels = Object.values(channels).sort(
+ (a, b) =>
+ (a.channel_number || Infinity) - (b.channel_number || Infinity)
+ );
+
+ console.log(`Using all ${sortedChannels.length} available channels`);
+ return sortedChannels;
+}
+
+export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
+ return guideChannels.filter((channel) => {
+ // Search filter
+ if (searchQuery) {
+ if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
+ }
+
+ // Channel group filter
+ if (selectedGroupId !== 'all') {
+ if (channel.channel_group_id !== parseInt(selectedGroupId)) return false;
+ }
+
+ // Profile filter
+ if (selectedProfileId !== 'all') {
+ const profileChannels = profiles[selectedProfileId]?.channels || [];
+ const enabledChannelIds = Array.isArray(profileChannels)
+ ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
+ : profiles[selectedProfileId]?.channels instanceof Set
+ ? Array.from(profiles[selectedProfileId].channels)
+ : [];
+
+ if (!enabledChannelIds.includes(channel.id)) return false;
+ }
+
+ return true;
+ });
+}
+
+export const calculateEarliestProgramStart = (programs, defaultStart) => {
+ if (!programs.length) return defaultStart;
+ return programs.reduce((acc, p) => {
+ const s = initializeTime(p.start_time);
+ return isBefore(s, acc) ? s : acc;
+ }, defaultStart);
+}
+
+export const calculateLatestProgramEnd = (programs, defaultEnd) => {
+ if (!programs.length) return defaultEnd;
+ return programs.reduce((acc, p) => {
+ const e = initializeTime(p.end_time);
+ return isAfter(e, acc) ? e : acc;
+ }, defaultEnd);
+}
+
+export const calculateStart = (earliestProgramStart, defaultStart) => {
+ return isBefore(earliestProgramStart, defaultStart)
+ ? earliestProgramStart
+ : defaultStart;
+}
+
+export const calculateEnd = (latestProgramEnd, defaultEnd) => {
+ return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
+}
+
+export const mapChannelsById = (guideChannels) => {
+ const map = new Map();
+ guideChannels.forEach((channel) => {
+ map.set(channel.id, channel);
+ });
+ return map;
+}
+
+export const mapRecordingsByProgramId = (recordings) => {
+ const map = new Map();
+ (recordings || []).forEach((recording) => {
+ const programId = recording?.custom_properties?.program?.id;
+ if (programId != null) {
+ map.set(programId, recording);
+ }
+ });
+ return map;
+}
+
+export const formatTime = (time, dateFormat) => {
+ const today = startOfDay(getNow());
+ const tomorrow = add(today, 1, 'day');
+ const weekLater = add(today, 7, 'day');
+ const day = startOfDay(time);
+
+ if (isSame(day, today, 'day')) {
+ return 'Today';
+ } else if (isSame(day, tomorrow, 'day')) {
+ return 'Tomorrow';
+ } else if (isBefore(day, weekLater)) {
+ // Within a week, show day name
+ return format(time, 'dddd');
+ } else {
+ // Beyond a week, show month and day
+ return format(time, dateFormat);
+ }
+}
+
+export const calculateHourTimeline = (start, end, formatDayLabel) => {
+ const hours = [];
+ let current = start;
+ let currentDay = null;
+
+ while (isBefore(current, end)) {
+ // Check if we're entering a new day
+ const day = startOfDay(current);
+ const isNewDay = !currentDay || !isSame(day, currentDay, 'day');
+
+ if (isNewDay) {
+ currentDay = day;
+ }
+
+ // Add day information to our hour object
+ hours.push({
+ time: current,
+ isNewDay,
+ dayLabel: formatDayLabel(current),
+ });
+
+ current = add(current, 1, 'hour');
+ }
+ return hours;
+}
+
+export const calculateNowPosition = (now, start, end) => {
+ if (isBefore(now, start) || isAfter(now, end)) return -1;
+ const minutesSinceStart = diff(now, start, 'minute');
+ return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const calculateScrollPosition = (now, start) => {
+ const roundedNow = roundToNearest(now, 30);
+ const nowOffset = diff(roundedNow, start, 'minute');
+ const scrollPosition =
+ (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
+
+ return Math.max(scrollPosition, 0);
+};
+
+export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
+ const channelIds = channelIdByTvgId.get(String(tvgId));
+ if (!channelIds || channelIds.length === 0) {
+ return null;
+ }
+ // Return the first channel that matches this TVG ID
+ return channelById.get(channelIds[0]) || null;
+}
+
+export const fetchRules = async () => {
+ return await API.listSeriesRules();
+}
+
+export const getRuleByProgram = (rules, program) => {
+ return (rules || []).find(
+ (r) =>
+ String(r.tvg_id) === String(program.tvg_id) &&
+ (!r.title || r.title === program.title)
+ );
+}
+
+export const createRecording = async (channel, program) => {
+ await API.createRecording({
+ channel: `${channel.id}`,
+ start_time: program.start_time,
+ end_time: program.end_time,
+ custom_properties: { program },
+ });
+}
+
+export const createSeriesRule = async (program, mode) => {
+ await API.createSeriesRule({
+ tvg_id: program.tvg_id,
+ mode,
+ title: program.title,
+ });
+}
+
+export const evaluateSeriesRule = async (program) => {
+ await API.evaluateSeriesRules(program.tvg_id);
+}
+
+export const calculateLeftScrollPosition = (program, start) => {
+ const programStartMs =
+ program.startMs ?? convertToMs(program.start_time);
+ const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
+
+ return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const calculateDesiredScrollPosition = (leftPx) => {
+ return Math.max(0, leftPx - 20);
+}
+
+export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const clickPositionX = event.clientX - rect.left;
+ const percentageAcross = clickPositionX / rect.width;
+ const minuteWithinHour = percentageAcross * 60;
+
+ const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
+
+ const adjustedTime = (snappedMinute === 60)
+ ? add(clickedTime, 1, 'hour').minute(0)
+ : clickedTime.minute(snappedMinute);
+
+ const snappedOffset = diff(adjustedTime, start, 'minute');
+ return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const getGroupOptions = (channelGroups, guideChannels) => {
+ const options = [{ value: 'all', label: 'All Channel Groups' }];
+
+ if (channelGroups && guideChannels.length > 0) {
+ // Get unique channel group IDs from the channels that have program data
+ const usedGroupIds = new Set();
+ guideChannels.forEach((channel) => {
+ if (channel.channel_group_id) {
+ usedGroupIds.add(channel.channel_group_id);
+ }
+ });
+ // Only add groups that are actually used by channels in the guide
+ Object.values(channelGroups)
+ .filter((group) => usedGroupIds.has(group.id))
+ .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
+ .forEach((group) => {
+ options.push({
+ value: group.id.toString(),
+ label: group.name,
+ });
+ });
+ }
+ return options;
+}
+
+export const getProfileOptions = (profiles) => {
+ const options = [{ value: 'all', label: 'All Profiles' }];
+
+ if (profiles) {
+ Object.values(profiles).forEach((profile) => {
+ if (profile.id !== '0') {
+ // Skip the 'All' default profile
+ options.push({
+ value: profile.id.toString(),
+ label: profile.name,
+ });
+ }
+ });
+ }
+
+ return options;
+}
+
+export const deleteSeriesRuleByTvgId = async (tvg_id) => {
+ await API.deleteSeriesRule(tvg_id);
+}
+
+export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
+ await API.evaluateSeriesRules(tvg_id);
+}
\ No newline at end of file
diff --git a/frontend/src/routes.js b/frontend/src/routes.js
deleted file mode 100644
index 93fadefe..00000000
--- a/frontend/src/routes.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import ProxyManager from './components/ProxyManager';
-
-// ...existing code...
-
-const routes = [
- ...existingRoutes,
- {
- path: '/proxy',
- element:
,
- name: 'Proxy Manager',
- },
-];
-
-export default routes;
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index d6eb8053..8fe943b7 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -1,11 +1,14 @@
import { create } from 'zustand';
-import API from '../api';
+import api from '../api';
+import useSettingsStore from './settings';
import useChannelsStore from './channels';
-import useUserAgentsStore from './userAgents';
import usePlaylistsStore from './playlists';
import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
-import useSettingsStore from './settings';
+import useUserAgentsStore from './userAgents';
+import useUsersStore from './users';
+import API from '../api';
+import { USER_LEVELS } from '../constants';
const decodeToken = (token) => {
if (!token) return null;
@@ -20,32 +23,63 @@ const isTokenExpired = (expirationTime) => {
};
const useAuthStore = create((set, get) => ({
+ isAuthenticated: false,
+ isInitialized: false,
+ needsSuperuser: false,
+ user: {
+ username: '',
+ email: '',
+ user_level: '',
+ },
+ isLoading: false,
+ error: null,
+
+ setUser: (user) => set({ user }),
+
+ initData: async () => {
+ const user = await API.me();
+ if (user.user_level <= USER_LEVELS.STREAMER) {
+ throw new Error('Unauthorized');
+ }
+
+ set({ user, isAuthenticated: true });
+
+ // Ensure settings are loaded first
+ await useSettingsStore.getState().fetchSettings();
+
+ try {
+ // Only after settings are loaded, fetch the essential data
+ await Promise.all([
+ useChannelsStore.getState().fetchChannels(),
+ useChannelsStore.getState().fetchChannelGroups(),
+ useChannelsStore.getState().fetchChannelProfiles(),
+ usePlaylistsStore.getState().fetchPlaylists(),
+ useEPGsStore.getState().fetchEPGs(),
+ useEPGsStore.getState().fetchEPGData(),
+ useStreamProfilesStore.getState().fetchProfiles(),
+ useUserAgentsStore.getState().fetchUserAgents(),
+ ]);
+
+ if (user.user_level >= USER_LEVELS.ADMIN) {
+ await Promise.all([useUsersStore.getState().fetchUsers()]);
+ }
+
+ // Note: Logos are loaded after the Channels page tables finish loading
+ // This is handled by the tables themselves signaling completion
+ } catch (error) {
+ console.error('Error initializing data:', error);
+ }
+ },
+
accessToken: localStorage.getItem('accessToken') || null,
refreshToken: localStorage.getItem('refreshToken') || null,
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
superuserExists: true,
- isAuthenticated: false,
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
setSuperuserExists: (superuserExists) => set({ superuserExists }),
- initData: async () => {
- await Promise.all([
- useChannelsStore.getState().fetchChannels(),
- useChannelsStore.getState().fetchChannelGroups(),
- useChannelsStore.getState().fetchLogos(),
- useChannelsStore.getState().fetchChannelProfiles(),
- useChannelsStore.getState().fetchRecordings(),
- useUserAgentsStore.getState().fetchUserAgents(),
- usePlaylistsStore.getState().fetchPlaylists(),
- useEPGsStore.getState().fetchEPGs(),
- useEPGsStore.getState().fetchEPGData(),
- useStreamProfilesStore.getState().fetchProfiles(),
- useSettingsStore.getState().fetchSettings(),
- ]);
- },
-
getToken: async () => {
const tokenExpiration = localStorage.getItem('tokenExpiration');
let accessToken = null;
@@ -61,19 +95,20 @@ const useAuthStore = create((set, get) => ({
// 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({
accessToken: response.access,
refreshToken: response.refresh,
tokenExpiration: expiration, // 1 hour from now
- isAuthenticated: true,
});
// Store in localStorage
localStorage.setItem('accessToken', response.access);
localStorage.setItem('refreshToken', response.refresh);
localStorage.setItem('tokenExpiration', expiration);
+
+ // Don't start background loading here - let it happen after app initialization
}
} catch (error) {
console.error('Login failed:', error);
@@ -83,11 +118,11 @@ const useAuthStore = create((set, get) => ({
// Action to refresh the token
getRefreshToken: async () => {
const refreshToken = localStorage.getItem('refreshToken');
- if (!refreshToken) return;
+ if (!refreshToken) return false; // Add explicit return here
try {
- const data = await API.refreshToken(refreshToken);
- if (data.access) {
+ const data = await api.refreshToken(refreshToken);
+ if (data && data.access) {
set({
accessToken: data.access,
tokenExpiration: decodeToken(data.access),
@@ -98,21 +133,30 @@ const useAuthStore = create((set, get) => ({
return data.access;
}
+ return false; // Add explicit return for when data.access is not available
} catch (error) {
console.error('Token refresh failed:', error);
- get().logout();
+ await get().logout();
+ return false; // Add explicit return after error
}
-
- return false;
},
// Action to logout
- logout: () => {
+ logout: async () => {
+ // Call backend logout endpoint to log the event
+ try {
+ await API.logout();
+ } catch (error) {
+ // Continue with logout even if API call fails
+ console.error('Logout API call failed:', error);
+ }
+
set({
accessToken: null,
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false,
+ user: null,
});
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 503cb1cc..9fb958b2 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -14,14 +14,14 @@ const useChannelsStore = create((set, get) => ({
stats: {},
activeChannels: {},
activeClients: {},
- logos: {},
recordings: [],
+ recurringRules: [],
isLoading: false,
error: null,
forceUpdate: 0,
triggerUpdate: () => {
- set({ forecUpdate: new Date() });
+ set({ forceUpdate: new Date() });
},
fetchChannels: async () => {
@@ -40,22 +40,29 @@ const useChannelsStore = create((set, get) => ({
isLoading: false,
});
} catch (error) {
- console.error('Failed to fetch channels:', error);
- set({ error: 'Failed to load channels.', isLoading: false });
+ set({ error: error.message, isLoading: false });
}
},
fetchChannelGroups: async () => {
- set({ isLoading: true, error: null });
try {
const channelGroups = await api.getChannelGroups();
- set({
- channelGroups: channelGroups.reduce((acc, group) => {
- acc[group.id] = group;
- return acc;
- }, {}),
- isLoading: false,
- });
+
+ // Process groups to add association flags
+ const processedGroups = channelGroups.reduce((acc, group) => {
+ acc[group.id] = {
+ ...group,
+ hasChannels: group.channel_count > 0,
+ hasM3UAccounts: group.m3u_account_count > 0,
+ canEdit: group.m3u_account_count === 0,
+ canDelete: group.channel_count === 0 && group.m3u_account_count === 0,
+ };
+ return acc;
+ }, {});
+
+ set((state) => ({
+ channelGroups: processedGroups,
+ }));
} catch (error) {
console.error('Failed to fetch channel groups:', error);
set({ error: 'Failed to load channel groups.', isLoading: false });
@@ -107,7 +114,6 @@ const useChannelsStore = create((set, get) => ({
addChannels: (newChannels) =>
set((state) => {
const channelsByUUID = {};
- const logos = {};
const profileChannels = new Set();
const channelsByID = newChannels.reduce((acc, channel) => {
@@ -118,14 +124,8 @@ const useChannelsStore = create((set, get) => ({
return acc;
}, {});
- const newProfiles = { ...defaultProfiles };
- Object.entries(state.profiles).forEach(([id, profile]) => {
- newProfiles[id] = {
- ...profile,
- channels: new Set([...profile.channels, ...profileChannels]),
- };
- });
-
+ // Don't automatically add to all profiles anymore - let the backend handle profile assignments
+ // Just maintain the existing profile structure
return {
channels: {
...state.channels,
@@ -135,7 +135,6 @@ const useChannelsStore = create((set, get) => ({
...state.channelsByUUID,
...channelsByUUID,
},
- profiles: newProfiles,
};
}),
@@ -151,6 +150,35 @@ const useChannelsStore = create((set, get) => ({
},
})),
+ updateChannels: (channels) => {
+ // Ensure channels is an array
+ if (!Array.isArray(channels)) {
+ console.error(
+ 'updateChannels expects an array, received:',
+ typeof channels,
+ channels
+ );
+ return;
+ }
+ const channelsByUUID = {};
+ const updatedChannels = channels.reduce((acc, chan) => {
+ channelsByUUID[chan.uuid] = chan.id;
+ acc[chan.id] = chan;
+ return acc;
+ }, {});
+
+ set((state) => ({
+ channels: {
+ ...state.channels,
+ ...updatedChannels,
+ },
+ channelsByUUID: {
+ ...state.channelsByUUID,
+ ...channelsByUUID,
+ },
+ }));
+ },
+
removeChannels: (channelIds) => {
set((state) => {
const updatedChannels = { ...state.channels };
@@ -180,41 +208,18 @@ const useChannelsStore = create((set, get) => ({
updateChannelGroup: (channelGroup) =>
set((state) => ({
- ...state.channelGroups,
- [channelGroup.id]: channelGroup,
- })),
-
- fetchLogos: async () => {
- set({ isLoading: true, error: null });
- try {
- const logos = await api.getLogos();
- set({
- logos: logos.reduce((acc, logo) => {
- acc[logo.id] = {
- ...logo,
- url: logo.url.replace(/^\/data/, ''),
- };
- return acc;
- }, {}),
- isLoading: false,
- });
- } catch (error) {
- console.error('Failed to fetch logos:', error);
- set({ error: 'Failed to load logos.', isLoading: false });
- }
- },
-
- addLogo: (newLogo) =>
- set((state) => ({
- logos: {
- ...state.logos,
- [newLogo.id]: {
- ...newLogo,
- url: newLogo.url.replace(/^\/data/, ''),
- },
+ channelGroups: {
+ ...state.channelGroups,
+ [channelGroup.id]: channelGroup,
},
})),
+ removeChannelGroup: (groupId) =>
+ set((state) => {
+ const { [groupId]: removed, ...remainingGroups } = state.channelGroups;
+ return { channelGroups: remainingGroups };
+ }),
+
addProfile: (profile) =>
set((state) => ({
profiles: {
@@ -302,10 +307,10 @@ const useChannelsStore = create((set, get) => ({
}),
setChannelsPageSelection: (channelsPageSelection) =>
- set((state) => ({ channelsPageSelection })),
+ set(() => ({ channelsPageSelection })),
setSelectedProfileId: (id) =>
- set((state) => ({
+ set(() => ({
selectedProfileId: id,
})),
@@ -323,11 +328,17 @@ const useChannelsStore = create((set, get) => ({
acc[ch.channel_id] = ch;
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
- notifications.show({
- title: 'New channel streaming',
- message: channels[channelsByUUID[ch.channel_id]].name,
- color: 'blue.5',
- });
+ // 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) => {
@@ -349,11 +360,23 @@ const useChannelsStore = create((set, get) => ({
if (currentStats.channels) {
for (const uuid in oldChannels) {
if (newChannels[uuid] === undefined) {
- notifications.show({
- title: 'Channel streaming stopped',
- message: channels[channelsByUUID[uuid]].name,
- color: 'blue.5',
- });
+ // 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) {
@@ -379,12 +402,65 @@ const useChannelsStore = create((set, get) => ({
try {
set({
recordings: await api.getRecordings(),
+ isLoading: false,
});
} catch (error) {
console.error('Failed to fetch recordings:', error);
set({ error: 'Failed to load recordings.', isLoading: false });
}
},
+
+ fetchRecurringRules: async () => {
+ try {
+ const rules = await api.listRecurringRules();
+ set({ recurringRules: Array.isArray(rules) ? rules : [] });
+ } catch (error) {
+ console.error('Failed to fetch recurring DVR rules:', error);
+ set({ error: 'Failed to load recurring DVR rules.' });
+ }
+ },
+
+ removeRecurringRule: (id) =>
+ set((state) => ({
+ recurringRules: Array.isArray(state.recurringRules)
+ ? state.recurringRules.filter((rule) => String(rule?.id) !== String(id))
+ : [],
+ })),
+
+ // Optimistically remove a single recording from the local store
+ removeRecording: (id) =>
+ set((state) => {
+ const target = String(id);
+ const current = state.recordings;
+ if (Array.isArray(current)) {
+ return {
+ recordings: current.filter((r) => String(r?.id) !== target),
+ };
+ }
+ if (current && typeof current === 'object') {
+ const next = { ...current };
+ for (const k of Object.keys(next)) {
+ try {
+ if (String(next[k]?.id) === target) delete next[k];
+ } catch {}
+ }
+ return { recordings: next };
+ }
+ return {};
+ }),
+
+ // Add helper methods for validation
+ canEditChannelGroup: (groupIdOrGroup) => {
+ const groupId =
+ typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
+ return get().channelGroups[groupId]?.canEdit ?? true;
+ },
+
+ canDeleteChannelGroup: (groupIdOrGroup) => {
+ const groupId =
+ typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
+ return get().channelGroups[groupId]?.canDelete ?? true;
+ },
}));
export default useChannelsStore;
diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx
index 592e5c86..75e14c63 100644
--- a/frontend/src/store/channelsTable.jsx
+++ b/frontend/src/store/channelsTable.jsx
@@ -10,7 +10,8 @@ const useChannelsTableStore = create((set, get) => ({
sorting: [{ id: 'channel_number', desc: false }],
pagination: {
pageIndex: 0,
- pageSize: 50,
+ pageSize:
+ JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
},
selectedChannelIds: [],
allQueryIds: [],
diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx
index bcb9fd88..1760bc45 100644
--- a/frontend/src/store/epgs.jsx
+++ b/frontend/src/store/epgs.jsx
@@ -5,8 +5,10 @@ const useEPGsStore = create((set) => ({
epgs: {},
tvgs: [],
tvgsById: {},
+ tvgsLoaded: false,
isLoading: false,
error: null,
+ refreshProgress: {},
fetchEPGs: async () => {
set({ isLoading: true, error: null });
@@ -35,11 +37,16 @@ const useEPGsStore = create((set) => ({
acc[tvg.id] = tvg;
return acc;
}, {}),
+ tvgsLoaded: true,
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch tvgs:', error);
- set({ error: 'Failed to load tvgs.', isLoading: false });
+ set({
+ error: 'Failed to load tvgs.',
+ tvgsLoaded: true,
+ isLoading: false,
+ });
}
},
@@ -49,9 +56,17 @@ const useEPGsStore = create((set) => ({
})),
updateEPG: (epg) =>
- set((state) => ({
- epgs: { ...state.epgs, [epg.id]: epg },
- })),
+ set((state) => {
+ // Validate that epg is an object with an id
+ if (!epg || typeof epg !== 'object' || !epg.id) {
+ console.error('updateEPG called with invalid epg:', epg);
+ return state;
+ }
+
+ return {
+ epgs: { ...state.epgs, [epg.id]: epg },
+ };
+ }),
removeEPGs: (epgIds) =>
set((state) => {
@@ -62,6 +77,75 @@ const useEPGsStore = create((set) => ({
return { epgs: updatedEPGs };
}),
+
+ updateEPGProgress: (data) =>
+ set((state) => {
+ // Validate that data is an object with a source
+ if (!data || typeof data !== 'object' || !data.source) {
+ console.error('updateEPGProgress called with invalid data:', data);
+ return state;
+ }
+
+ // Early exit if source doesn't exist in our EPGs store
+ if (!state.epgs[data.source] && !data.status) {
+ return state;
+ }
+
+ // Create a new refreshProgress object that includes the current update
+ const newRefreshProgress = {
+ ...state.refreshProgress,
+ [data.source]: {
+ action: data.action,
+ progress: data.progress,
+ speed: data.speed,
+ elapsed_time: data.elapsed_time,
+ time_remaining: data.time_remaining,
+ status: data.status || 'in_progress',
+ },
+ };
+
+ // 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';
+
+ // 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;
+
+ let newEpgs = state.epgs;
+ if (
+ currentEpg &&
+ (currentEpg.status !== sourceStatus ||
+ currentEpg.last_message !== newLastMessage)
+ ) {
+ newEpgs = {
+ ...state.epgs,
+ [data.source]: {
+ ...currentEpg,
+ status: sourceStatus,
+ last_message: newLastMessage,
+ },
+ };
+ }
+
+ return {
+ refreshProgress: newRefreshProgress,
+ epgs: newEpgs,
+ };
+ }),
}));
export default useEPGsStore;
diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx
new file mode 100644
index 00000000..5843b113
--- /dev/null
+++ b/frontend/src/store/logos.jsx
@@ -0,0 +1,358 @@
+import { create } from 'zustand';
+import api from '../api';
+
+const useLogosStore = create((set, get) => ({
+ logos: {},
+ channelLogos: {}, // Separate cache for channel forms to avoid reloading
+ isLoading: false,
+ backgroundLoading: false,
+ hasLoadedAll: false, // Track if we've loaded all logos
+ hasLoadedChannelLogos: false, // Track if we've loaded channel logos
+ error: null,
+ allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready
+
+ // Enable logo rendering (call this after tables have loaded and painted)
+ enableLogoRendering: () => set({ allowLogoRendering: true }),
+
+ addLogo: (newLogo) =>
+ set((state) => {
+ // Add to main logos store always
+ const newLogos = {
+ ...state.logos,
+ [newLogo.id]: { ...newLogo },
+ };
+
+ // 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 },
+ };
+ }
+
+ return {
+ logos: newLogos,
+ channelLogos: newChannelLogos,
+ };
+ }),
+
+ updateLogo: (logo) =>
+ set((state) => ({
+ logos: {
+ ...state.logos,
+ [logo.id]: { ...logo },
+ },
+ // Update in channelLogos if it exists there
+ channelLogos: state.channelLogos[logo.id]
+ ? {
+ ...state.channelLogos,
+ [logo.id]: { ...logo },
+ }
+ : state.channelLogos,
+ })),
+
+ removeLogo: (logoId) =>
+ set((state) => {
+ const newLogos = { ...state.logos };
+ const newChannelLogos = { ...state.channelLogos };
+ delete newLogos[logoId];
+ delete newChannelLogos[logoId];
+ return {
+ logos: newLogos,
+ channelLogos: newChannelLogos,
+ };
+ }),
+
+ // 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 });
+
+ // Handle both paginated and non-paginated responses
+ const logos = Array.isArray(response) ? response : response.results || [];
+
+ set({
+ logos: logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ isLoading: false,
+ });
+ return response;
+ } catch (error) {
+ console.error('Failed to fetch logos:', error);
+ set({ error: 'Failed to load logos.', isLoading: false });
+ throw error;
+ }
+ },
+
+ fetchAllLogos: async (force = false) => {
+ const { isLoading, hasLoadedAll, logos } = get();
+
+ // Prevent unnecessary reloading if we already have all logos
+ if (
+ !force &&
+ (isLoading || (hasLoadedAll && Object.keys(logos).length > 0))
+ ) {
+ return Object.values(logos);
+ }
+
+ set({ isLoading: true, error: null });
+ try {
+ // Disable pagination to get all logos for management interface
+ const response = await api.getLogos({ no_pagination: 'true' });
+
+ // Handle both paginated and non-paginated responses
+ const logosArray = Array.isArray(response)
+ ? response
+ : response.results || [];
+
+ set({
+ logos: logosArray.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ hasLoadedAll: true, // Mark that we've loaded all logos
+ isLoading: false,
+ });
+ return logosArray;
+ } catch (error) {
+ console.error('Failed to fetch all logos:', error);
+ set({ error: 'Failed to load all logos.', isLoading: false });
+ throw error;
+ }
+ },
+
+ fetchUsedLogos: async (pageSize = 100) => {
+ set({ isLoading: true, error: null });
+ try {
+ // Load used logos with pagination for better performance
+ const response = await api.getLogos({
+ used: 'true',
+ page_size: pageSize,
+ });
+
+ // Handle both paginated and non-paginated responses
+ const logos = Array.isArray(response) ? response : response.results || [];
+
+ set((state) => ({
+ logos: {
+ ...state.logos,
+ ...logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ },
+ isLoading: false,
+ }));
+ return response;
+ } catch (error) {
+ console.error('Failed to fetch used logos:', error);
+ set({ error: 'Failed to load used logos.', isLoading: false });
+ throw error;
+ }
+ },
+
+ fetchChannelAssignableLogos: async () => {
+ const { hasLoadedChannelLogos, channelLogos } = get();
+
+ // Return cached if already loaded
+ if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) {
+ return Object.values(channelLogos);
+ }
+
+ // Fetch all logos and cache them as channel logos
+ const logos = await get().fetchAllLogos();
+
+ set({
+ channelLogos: logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ hasLoadedChannelLogos: true,
+ });
+
+ return logos;
+ },
+
+ fetchLogosByIds: async (logoIds) => {
+ try {
+ // Filter out logos we already have
+ const missingIds = logoIds.filter((id) => !get().logos[id]);
+ if (missingIds.length === 0) return [];
+
+ const response = await api.getLogosByIds(missingIds);
+
+ // Handle both paginated and non-paginated responses
+ const logos = Array.isArray(response) ? response : response.results || [];
+
+ set((state) => ({
+ logos: {
+ ...state.logos,
+ ...logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ },
+ }));
+ return logos;
+ } catch (error) {
+ console.error('Failed to fetch logos by IDs:', error);
+ throw error;
+ }
+ },
+
+ fetchLogosInBackground: async () => {
+ set({ backgroundLoading: true });
+ try {
+ // Load logos in chunks using pagination for better performance
+ let page = 1;
+ const pageSize = 200;
+ let hasMore = true;
+
+ while (hasMore) {
+ const response = await api.getLogos({ page, page_size: pageSize });
+
+ set((state) => ({
+ logos: {
+ ...state.logos,
+ ...response.results.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ },
+ }));
+
+ // Check if there are more pages
+ hasMore = !!response.next;
+ page++;
+
+ // Add a small delay between chunks to avoid overwhelming the server
+ if (hasMore) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
+ } catch (error) {
+ console.error('Background logo loading failed:', error);
+ // Don't throw error for background loading
+ } finally {
+ set({ backgroundLoading: false });
+ }
+ },
+
+ // Background loading specifically for all logos after login
+ backgroundLoadAllLogos: async () => {
+ const { backgroundLoading, hasLoadedAll } = get();
+
+ // Don't start if already loading or if we already have all logos loaded
+ if (backgroundLoading || hasLoadedAll) {
+ return;
+ }
+
+ set({ backgroundLoading: true });
+
+ // Use setTimeout to make this truly non-blocking
+ setTimeout(async () => {
+ 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 || [];
+
+ // Process logos in smaller chunks to avoid blocking the main thread
+ const chunkSize = 1000;
+ const logoObject = {};
+
+ for (let i = 0; i < logosArray.length; i += chunkSize) {
+ const chunk = logosArray.slice(i, i + chunkSize);
+ chunk.forEach((logo) => {
+ logoObject[logo.id] = { ...logo };
+ });
+
+ // Yield control back to the main thread between chunks
+ if (i + chunkSize < logosArray.length) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ }
+
+ set({
+ logos: logoObject,
+ hasLoadedAll: true,
+ backgroundLoading: false,
+ });
+ } catch (error) {
+ console.error('Background all logos loading failed:', error);
+ set({ backgroundLoading: false });
+ }
+ }, 0); // Execute immediately but asynchronously
+ },
+
+ // Background loading for channel logos after login
+ backgroundLoadChannelLogos: async () => {
+ const { backgroundLoading, channelLogos, hasLoadedChannelLogos } = get();
+
+ // Don't start if already loading or if we already have channel logos loaded
+ if (
+ backgroundLoading ||
+ hasLoadedChannelLogos ||
+ Object.keys(channelLogos).length > 100
+ ) {
+ return;
+ }
+
+ set({ backgroundLoading: true });
+ try {
+ console.log('Background loading channel logos...');
+ await get().fetchChannelAssignableLogos();
+ console.log(
+ `Background loaded ${Object.keys(get().channelLogos).length} channel logos`
+ );
+ } catch (error) {
+ console.error('Background channel logo loading failed:', error);
+ // Don't throw error for background loading
+ } finally {
+ set({ backgroundLoading: false });
+ }
+ },
+
+ // Start background loading after app is fully initialized
+ startBackgroundLoading: () => {
+ // Use a longer delay to ensure app is fully loaded
+ setTimeout(() => {
+ // Fire and forget - don't await this
+ get()
+ .backgroundLoadAllLogos()
+ .catch((error) => {
+ console.error('Background logo loading failed:', error);
+ });
+ }, 3000); // Wait 3 seconds after app initialization
+ },
+
+ // Helper methods
+ getLogoById: (logoId) => {
+ return get().logos[logoId] || null;
+ },
+
+ hasLogo: (logoId) => {
+ return !!get().logos[logoId];
+ },
+
+ getLogosCount: () => {
+ return Object.keys(get().logos).length;
+ },
+
+ // Check if we need to fetch all logos (haven't loaded them yet or store is empty)
+ needsAllLogos: () => {
+ const state = get();
+ return !state.hasLoadedAll || Object.keys(state.logos).length === 0;
+ },
+}));
+
+export default useLogosStore;
diff --git a/frontend/src/store/playlists.jsx b/frontend/src/store/playlists.jsx
index e04263e7..30d01906 100644
--- a/frontend/src/store/playlists.jsx
+++ b/frontend/src/store/playlists.jsx
@@ -11,6 +11,34 @@ const usePlaylistsStore = create((set) => ({
profileSearchPreview: '',
profileResult: '',
+ // Add a state variable to trigger M3U editing
+ editPlaylistId: null,
+
+ setEditPlaylistId: (id) =>
+ set((state) => ({
+ editPlaylistId: id,
+ })),
+
+ fetchPlaylist: async (id) => {
+ set({ isLoading: true, error: null });
+ try {
+ const playlist = await api.getPlaylist(id);
+ set((state) => ({
+ playlists: state.playlists.map((p) => (p.id == id ? playlist : p)),
+ isLoading: false,
+ profiles: {
+ ...state.profiles,
+ [id]: playlist.profiles,
+ },
+ }));
+
+ return playlist;
+ } catch (error) {
+ console.error('Failed to fetch playlists:', error);
+ set({ error: 'Failed to load playlists.', isLoading: false });
+ }
+ },
+
fetchPlaylists: async () => {
set({ isLoading: true, error: null });
try {
@@ -65,13 +93,39 @@ const usePlaylistsStore = create((set) => ({
// @TODO: remove playlist profiles here
})),
- setRefreshProgress: (data) =>
- set((state) => ({
- refreshProgress: {
- ...state.refreshProgress,
- [data.account]: data,
- },
- })),
+ setRefreshProgress: (accountIdOrData, data) =>
+ set((state) => {
+ // If called with two parameters, it's the direct setter
+ if (data !== undefined) {
+ return {
+ refreshProgress: {
+ ...state.refreshProgress,
+ [accountIdOrData]: data,
+ },
+ };
+ }
+
+ // If called with WebSocket data, preserve 'initializing' status
+ // until we get a real progress update from the server
+ const accountId = accountIdOrData.account;
+ const existingProgress = state.refreshProgress[accountId];
+
+ // Don't replace 'initializing' status with empty/early server messages
+ if (
+ existingProgress &&
+ existingProgress.action === 'initializing' &&
+ accountIdOrData.progress === 0
+ ) {
+ return state; // Keep showing 'initializing' until real progress comes
+ }
+
+ return {
+ refreshProgress: {
+ ...state.refreshProgress,
+ [accountId]: accountIdOrData,
+ },
+ };
+ }),
removeRefreshProgress: (id) =>
set((state) => {
diff --git a/frontend/src/store/plugins.jsx b/frontend/src/store/plugins.jsx
new file mode 100644
index 00000000..e8d0b065
--- /dev/null
+++ b/frontend/src/store/plugins.jsx
@@ -0,0 +1,41 @@
+import { create } from 'zustand';
+import API from '../api';
+
+export const usePluginStore = create((set, get) => ({
+ plugins: [],
+ loading: false,
+ error: null,
+
+ fetchPlugins: async () => {
+ set({ loading: true, error: null });
+ try {
+ const response = await API.getPlugins();
+ set({ plugins: response || [], loading: false });
+ } catch (error) {
+ set({ error, loading: false });
+ }
+ },
+
+ updatePlugin: (key, updates) => {
+ set((state) => ({
+ plugins: state.plugins.map((p) =>
+ p.key === key ? { ...p, ...updates } : p
+ ),
+ }));
+ },
+
+ addPlugin: (plugin) => {
+ set((state) => ({ plugins: [...state.plugins, plugin] }));
+ },
+
+ removePlugin: (key) => {
+ set((state) => ({
+ plugins: state.plugins.filter((p) => p.key !== key),
+ }));
+ },
+
+ invalidatePlugins: () => {
+ set({ plugins: [] });
+ get().fetchPlugins();
+ },
+}));
\ No newline at end of file
diff --git a/frontend/src/store/settings.jsx b/frontend/src/store/settings.jsx
index 5dffbed6..99390320 100644
--- a/frontend/src/store/settings.jsx
+++ b/frontend/src/store/settings.jsx
@@ -3,7 +3,13 @@ import api from '../api';
const useSettingsStore = create((set) => ({
settings: {},
- environment: {},
+ environment: {
+ // Add default values for environment settings
+ public_ip: '',
+ country_code: '',
+ country_name: '',
+ env_mode: 'prod',
+ },
isLoading: false,
error: null,
@@ -18,7 +24,12 @@ const useSettingsStore = create((set) => ({
return acc;
}, {}),
isLoading: false,
- environment: env,
+ environment: env || {
+ public_ip: '',
+ country_code: '',
+ country_name: '',
+ env_mode: 'prod',
+ },
});
} catch (error) {
set({ error: 'Failed to load settings.', isLoading: false });
diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
new file mode 100644
index 00000000..43edb1c9
--- /dev/null
+++ b/frontend/src/store/useVODStore.jsx
@@ -0,0 +1,432 @@
+import { create } from 'zustand';
+import api from '../api';
+
+const useVODStore = create((set, get) => ({
+ content: {}, // Store for individual content details (when fetching movie/series details)
+ currentPageContent: [], // Store the current page's results
+ episodes: {},
+ categories: {},
+ loading: false,
+ error: null,
+ filters: {
+ type: 'all', // 'all', 'movies', 'series'
+ search: '',
+ category: '',
+ },
+ currentPage: 1,
+ totalCount: 0,
+ pageSize: 24,
+
+ setFilters: (newFilters) =>
+ set((state) => ({
+ filters: { ...state.filters, ...newFilters },
+ currentPage: 1, // Reset to first page when filters change
+ })),
+
+ setPage: (page) =>
+ set(() => ({
+ currentPage: page,
+ })),
+
+ setPageSize: (size) =>
+ set(() => ({
+ pageSize: size,
+ currentPage: 1, // Reset to first page when page size changes
+ })),
+
+ fetchContent: async () => {
+ try {
+ 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);
+ }
+
+ let allResults = [];
+ let totalCount = 0;
+
+ if (state.filters.type === 'movies') {
+ // 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',
+ }));
+ totalCount = response.count || results.length;
+ } else {
+ // 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)) {
+ console.error('Results is not an array:', results);
+ throw new Error('Invalid response format - results is not an array');
+ }
+
+ // The backend already provides content_type and proper sorting/pagination
+ allResults = results.map((item) => ({
+ ...item,
+ contentType: item.content_type, // Backend provides this field
+ }));
+ totalCount = response.count || results.length;
+ }
+
+ // Store the current page results directly (don't accumulate all pages)
+ set({
+ currentPageContent: allResults, // This is the paginated data for current page
+ totalCount,
+ loading: false,
+ });
+ } catch (error) {
+ console.error('Failed to fetch content:', error);
+ set({ error: 'Failed to load content.', loading: false });
+ }
+ },
+
+ 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 {
+ 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 || '',
+ };
+ console.log('Fetched Movie Details:', movieDetails);
+ set((state) => ({
+ content: {
+ ...state.content,
+ [`movie_${movieDetails.id}`]: {
+ ...movieDetails,
+ contentType: 'movie',
+ },
+ },
+ loading: false,
+ }));
+
+ return movieDetails;
+ } catch (error) {
+ console.error('Failed to fetch movie details:', error);
+ set({ error: 'Failed to load movie details.', loading: false });
+ throw error;
+ }
+ },
+
+ fetchMovieDetailsFromProvider: async (movieId) => {
+ set({ loading: true, error: null });
+ try {
+ 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 || {},
+ };
+
+ set({ loading: false }); // Only update loading state
+
+ // Do NOT merge or overwrite the store entry
+ return movieDetails;
+ } catch (error) {
+ console.error('Failed to fetch movie details from provider:', error);
+ set({
+ error: 'Failed to load movie details from provider.',
+ loading: false,
+ });
+ throw error;
+ }
+ },
+
+ fetchMovieProviders: async (movieId) => {
+ try {
+ const response = await api.getMovieProviders(movieId);
+ return response || [];
+ } catch (error) {
+ console.error('Failed to fetch movie providers:', error);
+ throw error;
+ }
+ },
+
+ fetchSeriesProviders: async (seriesId) => {
+ try {
+ const response = await api.getSeriesProviders(seriesId);
+ return response || [];
+ } catch (error) {
+ console.error('Failed to fetch series providers:', error);
+ throw error;
+ }
+ },
+
+ fetchCategories: async () => {
+ try {
+ const response = await api.getVODCategories();
+ // Handle both array and paginated responses
+ const results = response.results || response;
+
+ set({
+ categories: results.reduce((acc, category) => {
+ acc[category.id] = category;
+ return acc;
+ }, {}),
+ });
+ } catch (error) {
+ console.error('Failed to fetch VOD categories:', error);
+ set({ error: 'Failed to load categories.' });
+ }
+ },
+
+ addMovie: (movie) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ [`movie_${movie.id}`]: { ...movie, contentType: 'movie' },
+ },
+ })),
+
+ updateMovie: (movie) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ [`movie_${movie.id}`]: { ...movie, contentType: 'movie' },
+ },
+ })),
+
+ removeMovie: (movieId) =>
+ set((state) => {
+ const updatedContent = { ...state.content };
+ delete updatedContent[`movie_${movieId}`];
+ return { content: updatedContent };
+ }),
+
+ addSeries: (series) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ [`series_${series.id}`]: { ...series, contentType: 'series' },
+ },
+ })),
+
+ updateSeries: (series) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ [`series_${series.id}`]: { ...series, contentType: 'series' },
+ },
+ })),
+
+ removeSeries: (seriesId) =>
+ set((state) => {
+ const updatedContent = { ...state.content };
+ delete updatedContent[`series_${seriesId}`];
+ return { content: updatedContent };
+ }),
+
+ fetchSeriesInfo: async (seriesId) => {
+ set({ loading: true, error: null });
+ try {
+ 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 || '',
+ };
+
+ let episodesData = {};
+
+ // Handle episodes - check if they're in the response
+ if (response.episodes) {
+ 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;
+ });
+ }
+ );
+
+ // Update episodes in the store
+ set((state) => ({
+ episodes: {
+ ...state.episodes,
+ ...episodesData,
+ },
+ }));
+ }
+
+ set((state) => ({
+ content: {
+ ...state.content,
+ [`series_${seriesInfo.id}`]: { ...seriesInfo, contentType: 'series' },
+ },
+ loading: false,
+ }));
+
+ // Return series info with episodes array for easy access
+ return {
+ ...seriesInfo,
+ episodesList: Object.values(episodesData),
+ };
+ } catch (error) {
+ console.error('Failed to fetch series info:', error);
+ set({ error: 'Failed to load series details.', loading: false });
+ throw error;
+ }
+ },
+
+ // Helper methods for getting filtered content
+ getFilteredContent: () => {
+ const state = get();
+ // Return the current page content directly - backend handles all filtering/pagination
+ return state.currentPageContent;
+ },
+
+ getMovies: () => {
+ const state = get();
+ return Object.values(state.content).filter(
+ (item) => item.contentType === 'movie'
+ );
+ },
+
+ getSeries: () => {
+ const state = get();
+ return Object.values(state.content).filter(
+ (item) => item.contentType === 'series'
+ );
+ },
+
+ clearContent: () => set({ content: {}, totalCount: 0 }),
+}));
+
+export default useVODStore;
diff --git a/frontend/src/store/useVideoStore.jsx b/frontend/src/store/useVideoStore.jsx
index 0229552a..1ac21542 100644
--- a/frontend/src/store/useVideoStore.jsx
+++ b/frontend/src/store/useVideoStore.jsx
@@ -7,17 +7,23 @@ import { create } from 'zustand';
const useVideoStore = create((set) => ({
isVisible: false,
streamUrl: null,
+ contentType: 'live', // 'live' for MPEG-TS streams, 'vod' for MP4/MKV files
+ metadata: null, // Store additional metadata for VOD content
- showVideo: (url) =>
+ showVideo: (url, type = 'live', metadata = null) =>
set({
isVisible: true,
streamUrl: url,
+ contentType: type,
+ metadata: metadata,
}),
hideVideo: () =>
set({
isVisible: false,
streamUrl: null,
+ contentType: 'live',
+ metadata: null,
}),
}));
diff --git a/frontend/src/store/userAgents.jsx b/frontend/src/store/userAgents.jsx
index 6693e830..2254118a 100644
--- a/frontend/src/store/userAgents.jsx
+++ b/frontend/src/store/userAgents.jsx
@@ -1,5 +1,5 @@
-import { create } from "zustand";
-import api from "../api";
+import { create } from 'zustand';
+import api from '../api';
const useUserAgentsStore = create((set) => ({
userAgents: [],
@@ -12,8 +12,8 @@ const useUserAgentsStore = create((set) => ({
const userAgents = await api.getUserAgents();
set({ userAgents: userAgents, isLoading: false });
} catch (error) {
- console.error("Failed to fetch userAgents:", error);
- set({ error: "Failed to load userAgents.", isLoading: false });
+ console.error('Failed to fetch userAgents:', error);
+ set({ error: 'Failed to load userAgents.', isLoading: false });
}
},
@@ -25,14 +25,14 @@ const useUserAgentsStore = create((set) => ({
updateUserAgent: (userAgent) =>
set((state) => ({
userAgents: state.userAgents.map((ua) =>
- ua.id === userAgent.id ? userAgent : ua,
+ ua.id === userAgent.id ? userAgent : ua
),
})),
removeUserAgents: (userAgentIds) =>
set((state) => ({
userAgents: state.userAgents.filter(
- (userAgent) => !userAgentIds.includes(userAgent.id),
+ (userAgent) => !userAgentIds.includes(userAgent.id)
),
})),
}));
diff --git a/frontend/src/store/users.jsx b/frontend/src/store/users.jsx
new file mode 100644
index 00000000..1ca4c7e0
--- /dev/null
+++ b/frontend/src/store/users.jsx
@@ -0,0 +1,41 @@
+import { create } from 'zustand';
+import api from '../api';
+
+const useUsersStore = create((set) => ({
+ users: [],
+ isLoading: false,
+ error: null,
+
+ fetchUsers: async () => {
+ set({ isLoading: true, error: null });
+ try {
+ const users = await api.getUsers();
+ set({
+ users,
+ isLoading: false,
+ });
+ } catch (error) {
+ console.error('Failed to fetch users:', error);
+ set({ error: 'Failed to load users.', isLoading: false });
+ }
+ },
+
+ addUser: (user) =>
+ set((state) => ({
+ users: state.users.concat([user]),
+ })),
+
+ updateUser: (updatedUser) =>
+ set((state) => ({
+ users: state.users.map((user) =>
+ user.id === updatedUser.id ? updatedUser : user
+ ),
+ })),
+
+ removeUser: (userId) =>
+ set((state) => ({
+ users: state.users.filter((user) => (user.id === userId ? false : true)),
+ })),
+}));
+
+export default useUsersStore;
diff --git a/frontend/src/store/vodLogos.jsx b/frontend/src/store/vodLogos.jsx
new file mode 100644
index 00000000..4df2dd17
--- /dev/null
+++ b/frontend/src/store/vodLogos.jsx
@@ -0,0 +1,130 @@
+import { create } from 'zustand';
+import api from '../api';
+
+const useVODLogosStore = create((set) => ({
+ vodLogos: {},
+ logos: [],
+ isLoading: false,
+ hasLoaded: false,
+ error: null,
+ totalCount: 0,
+ currentPage: 1,
+ pageSize: 25,
+
+ setVODLogos: (logos, totalCount = 0) => {
+ set({
+ vodLogos: logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ totalCount,
+ hasLoaded: true,
+ });
+ },
+
+ removeVODLogo: (logoId) =>
+ set((state) => {
+ const newVODLogos = { ...state.vodLogos };
+ delete newVODLogos[logoId];
+ return {
+ vodLogos: newVODLogos,
+ totalCount: Math.max(0, state.totalCount - 1),
+ };
+ }),
+
+ fetchVODLogos: async (params = {}) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await api.getVODLogos(params);
+
+ // Handle both paginated and non-paginated responses
+ const logos = Array.isArray(response) ? response : response.results || [];
+ const total = response.count || logos.length;
+
+ set({
+ vodLogos: logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ logos: logos,
+ totalCount: total,
+ isLoading: false,
+ hasLoaded: true,
+ });
+ return response;
+ } catch (error) {
+ console.error('Failed to fetch VOD logos:', error);
+ set({ error: 'Failed to load VOD logos.', isLoading: false });
+ throw error;
+ }
+ },
+
+ 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),
+ };
+ });
+ } catch (error) {
+ console.error('Failed to delete VOD logo:', error);
+ throw error;
+ }
+ },
+
+ 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),
+ };
+ });
+ } catch (error) {
+ console.error('Failed to delete VOD logos:', error);
+ throw error;
+ }
+ },
+
+ cleanupUnusedVODLogos: async () => {
+ try {
+ const result = await api.cleanupUnusedVODLogos();
+
+ // Refresh the logos after cleanup
+ const state = useVODLogosStore.getState();
+ await state.fetchVODLogos({
+ page: state.currentPage,
+ page_size: state.pageSize,
+ });
+
+ return result;
+ } catch (error) {
+ console.error('Failed to cleanup unused VOD logos:', error);
+ throw error;
+ }
+ },
+
+ clearVODLogos: () => {
+ set({
+ vodLogos: {},
+ logos: [],
+ hasLoaded: false,
+ totalCount: 0,
+ error: null,
+ });
+ },
+}));
+
+export default useVODLogosStore;
diff --git a/frontend/src/store/warnings.jsx b/frontend/src/store/warnings.jsx
new file mode 100644
index 00000000..59f5f157
--- /dev/null
+++ b/frontend/src/store/warnings.jsx
@@ -0,0 +1,29 @@
+import { create } from 'zustand';
+
+const useWarningsStore = create((set) => ({
+ // Map of action keys to whether they're suppressed
+ suppressedWarnings: {},
+
+ // Function to check if a warning is suppressed
+ isWarningSuppressed: (actionKey) => {
+ const state = useWarningsStore.getState();
+ return state.suppressedWarnings[actionKey] === true;
+ },
+
+ // Function to suppress a warning
+ suppressWarning: (actionKey, suppressed = true) => {
+ set((state) => ({
+ suppressedWarnings: {
+ ...state.suppressedWarnings,
+ [actionKey]: suppressed,
+ },
+ }));
+ },
+
+ // Function to reset all suppressions
+ resetSuppressions: () => {
+ set({ suppressedWarnings: {} });
+ },
+}));
+
+export default useWarningsStore;
diff --git a/frontend/src/test/setupTests.js b/frontend/src/test/setupTests.js
new file mode 100644
index 00000000..b5f53af0
--- /dev/null
+++ b/frontend/src/test/setupTests.js
@@ -0,0 +1,42 @@
+import '@testing-library/jest-dom/vitest';
+import { afterEach, vi } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+afterEach(() => {
+ cleanup();
+});
+
+if (typeof window !== 'undefined' && !window.matchMedia) {
+ window.matchMedia = vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+}
+
+if (typeof window !== 'undefined' && !window.ResizeObserver) {
+ class ResizeObserver {
+ constructor(callback) {
+ this.callback = callback;
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+
+ window.ResizeObserver = ResizeObserver;
+}
+
+if (typeof window !== 'undefined') {
+ if (!window.requestAnimationFrame) {
+ window.requestAnimationFrame = (cb) => setTimeout(cb, 16);
+ }
+ if (!window.cancelAnimationFrame) {
+ window.cancelAnimationFrame = (id) => clearTimeout(id);
+ }
+}
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index 619823c8..81836f0a 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -38,12 +38,15 @@ export default {
};
// Custom debounce hook
-export function useDebounce(value, delay = 500) {
+export function useDebounce(value, delay = 500, callback = null) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
+ if (callback) {
+ callback();
+ }
}, delay);
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
@@ -62,24 +65,54 @@ export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
export const copyToClipboard = async (value) => {
- let copied = false;
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
- copied = true;
+ return true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
- if (!copied) {
- // Fallback method for environments without clipboard support
+ // Fallback method for environments without clipboard support
+ try {
const textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
- document.execCommand('copy');
+ const successful = document.execCommand('copy');
document.body.removeChild(textarea);
+ return successful;
+ } catch (err) {
+ console.error('Failed to copy with fallback method: ', err);
+ return false;
}
};
+
+export const setCustomProperty = (input, key, value, serialize = false) => {
+ let obj;
+
+ if (input == null) {
+ // matches null or undefined
+ obj = {};
+ } else if (typeof input === 'string') {
+ try {
+ obj = JSON.parse(input);
+ } catch (e) {
+ obj = {};
+ }
+ } else if (typeof input === 'object' && !Array.isArray(input)) {
+ obj = { ...input }; // shallow copy
+ } else {
+ obj = {};
+ }
+
+ obj[key] = value;
+
+ if (serialize === true) {
+ return JSON.stringify(obj);
+ }
+
+ return obj;
+};
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
new file mode 100644
index 00000000..54644dcd
--- /dev/null
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -0,0 +1,473 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import * as dateTimeUtils from '../dateTimeUtils';
+import useSettingsStore from '../../store/settings';
+import useLocalStorage from '../../hooks/useLocalStorage';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+vi.mock('../../store/settings');
+vi.mock('../../hooks/useLocalStorage');
+
+describe('dateTimeUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('convertToMs', () => {
+ it('should convert date to milliseconds', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.convertToMs(date);
+ expect(result).toBe(dayjs(date).valueOf());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.convertToMs(date);
+ expect(result).toBe(dayjs(date).valueOf());
+ });
+ });
+
+ describe('convertToSec', () => {
+ it('should convert date to unix timestamp', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.convertToSec(date);
+ expect(result).toBe(dayjs(date).unix());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.convertToSec(date);
+ expect(result).toBe(dayjs(date).unix());
+ });
+ });
+
+ describe('initializeTime', () => {
+ it('should create dayjs object from date string', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.initializeTime(date);
+ expect(result.format()).toBe(dayjs(date).format());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.initializeTime(date);
+ expect(result.format()).toBe(dayjs(date).format());
+ });
+ });
+
+ describe('startOfDay', () => {
+ it('should return start of day', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.startOfDay(date);
+ expect(result.hour()).toBe(0);
+ expect(result.minute()).toBe(0);
+ expect(result.second()).toBe(0);
+ });
+ });
+
+ describe('isBefore', () => {
+ it('should return true when first date is before second', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isBefore(date1, date2)).toBe(true);
+ });
+
+ it('should return false when first date is after second', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.isBefore(date1, date2)).toBe(false);
+ });
+ });
+
+ describe('isAfter', () => {
+ it('should return true when first date is after second', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.isAfter(date1, date2)).toBe(true);
+ });
+
+ it('should return false when first date is before second', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isAfter(date1, date2)).toBe(false);
+ });
+ });
+
+ describe('isSame', () => {
+ it('should return true when dates are same day', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isSame(date1, date2)).toBe(true);
+ });
+
+ it('should return false when dates are different days', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-16T10:00:00Z';
+ expect(dateTimeUtils.isSame(date1, date2)).toBe(false);
+ });
+
+ it('should accept unit parameter', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T10:30:00Z';
+ expect(dateTimeUtils.isSame(date1, date2, 'hour')).toBe(true);
+ expect(dateTimeUtils.isSame(date1, date2, 'minute')).toBe(false);
+ });
+ });
+
+ describe('add', () => {
+ it('should add time to date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.add(date, 1, 'hour');
+ expect(result.hour()).toBe(11);
+ });
+
+ it('should handle different units', () => {
+ const date = '2024-01-15T10:00:00Z';
+ const dayResult = dateTimeUtils.add(date, 1, 'day');
+ expect(dayResult.date()).toBe(16);
+
+ const monthResult = dateTimeUtils.add(date, 1, 'month');
+ expect(monthResult.month()).toBe(1);
+ });
+ });
+
+ describe('subtract', () => {
+ it('should subtract time from date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.subtract(date, 1, 'hour');
+ expect(result.hour()).toBe(9);
+ });
+
+ it('should handle different units', () => {
+ const date = '2024-01-15T10:00:00Z';
+ const dayResult = dateTimeUtils.subtract(date, 1, 'day');
+ expect(dayResult.date()).toBe(14);
+ });
+ });
+
+ describe('diff', () => {
+ it('should calculate difference in milliseconds by default', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ const result = dateTimeUtils.diff(date1, date2);
+ expect(result).toBe(3600000);
+ });
+
+ it('should calculate difference in specified unit', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.diff(date1, date2, 'hour')).toBe(1);
+ expect(dateTimeUtils.diff(date1, date2, 'minute')).toBe(60);
+ });
+ });
+
+ describe('format', () => {
+ it('should format date with given format string', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.format(date, 'YYYY-MM-DD');
+ expect(result).toMatch(/2024-01-15/);
+ });
+
+ it('should handle time formatting', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.format(date, 'HH:mm');
+ expect(result).toMatch(/\d{2}:\d{2}/);
+ });
+ });
+
+ describe('getNow', () => {
+ it('should return current time as dayjs object', () => {
+ const result = dateTimeUtils.getNow();
+ expect(result.isValid()).toBe(true);
+ });
+ });
+
+ describe('toFriendlyDuration', () => {
+ it('should convert duration to human readable format', () => {
+ const result = dateTimeUtils.toFriendlyDuration(60, 'minutes');
+ expect(result).toBe('an hour');
+ });
+
+ it('should handle different units', () => {
+ const result = dateTimeUtils.toFriendlyDuration(2, 'hours');
+ expect(result).toBe('2 hours');
+ });
+ });
+
+ describe('fromNow', () => {
+ it('should return relative time from now', () => {
+ const pastDate = dayjs().subtract(1, 'hour').toISOString();
+ const result = dateTimeUtils.fromNow(pastDate);
+ expect(result).toMatch(/ago/);
+ });
+ });
+
+ describe('getNowMs', () => {
+ it('should return current time in milliseconds', () => {
+ const result = dateTimeUtils.getNowMs();
+ expect(typeof result).toBe('number');
+ expect(result).toBeGreaterThan(0);
+ });
+ });
+
+ describe('roundToNearest', () => {
+ it('should round to nearest 15 minutes', () => {
+ const date = dayjs('2024-01-15T10:17:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.minute()).toBe(15);
+ });
+
+ it('should round up when past halfway point', () => {
+ const date = dayjs('2024-01-15T10:23:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.minute()).toBe(30);
+ });
+
+ it('should handle rounding to next hour', () => {
+ const date = dayjs.utc('2024-01-15T10:53:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.hour()).toBe(11);
+ expect(result.minute()).toBe(0);
+ });
+
+ it('should handle different minute intervals', () => {
+ const date = dayjs('2024-01-15T10:20:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 30);
+ expect(result.minute()).toBe(30);
+ });
+ });
+
+ describe('useUserTimeZone', () => {
+ it('should return time zone from local storage', () => {
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ useSettingsStore.mockReturnValue({});
+
+ const { result } = renderHook(() => dateTimeUtils.useUserTimeZone());
+
+ expect(result.current).toBe('America/New_York');
+ });
+
+ it('should update time zone from settings', () => {
+ const setTimeZone = vi.fn();
+ useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
+ useSettingsStore.mockReturnValue({
+ 'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
+ });
+
+ renderHook(() => dateTimeUtils.useUserTimeZone());
+
+ expect(setTimeZone).toHaveBeenCalledWith('America/Los_Angeles');
+ });
+ });
+
+ describe('useTimeHelpers', () => {
+ beforeEach(() => {
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ useSettingsStore.mockReturnValue({});
+ });
+
+ it('should return time zone, toUserTime, and userNow', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ expect(result.current).toHaveProperty('timeZone');
+ expect(result.current).toHaveProperty('toUserTime');
+ expect(result.current).toHaveProperty('userNow');
+ });
+
+ it('should convert value to user time zone', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+ const date = '2024-01-15T10:00:00Z';
+
+ const converted = result.current.toUserTime(date);
+
+ expect(converted.isValid()).toBe(true);
+ });
+
+ it('should return null for null value', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ const converted = result.current.toUserTime(null);
+
+ expect(converted).toBeDefined();
+ expect(converted.isValid()).toBe(false);
+ });
+
+ it('should handle timezone conversion errors', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+ const date = '2024-01-15T10:00:00Z';
+
+ const converted = result.current.toUserTime(date);
+
+ expect(converted.isValid()).toBe(true);
+ });
+
+ it('should return current time in user timezone', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ const now = result.current.userNow();
+
+ expect(now.isValid()).toBe(true);
+ });
+ });
+
+ describe('RECURRING_DAY_OPTIONS', () => {
+ it('should have 7 day options', () => {
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS).toHaveLength(7);
+ });
+
+ it('should start with Sunday', () => {
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' });
+ });
+
+ it('should include all weekdays', () => {
+ const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(opt => opt.label);
+ expect(labels).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']);
+ });
+ });
+
+ describe('useDateTimeFormat', () => {
+ it('should return 12h format and mdy date format by default', () => {
+ useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current).toEqual(['h:mma', 'MMM D']);
+ });
+
+ it('should return 24h format when set', () => {
+ useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current[0]).toBe('HH:mm');
+ });
+
+ it('should return dmy date format when set', () => {
+ useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current[1]).toBe('D MMM');
+ });
+ });
+
+ describe('toTimeString', () => {
+ it('should return 00:00 for null value', () => {
+ expect(dateTimeUtils.toTimeString(null)).toBe('00:00');
+ });
+
+ it('should parse HH:mm format', () => {
+ expect(dateTimeUtils.toTimeString('14:30')).toBe('14:30');
+ });
+
+ it('should parse HH:mm:ss format', () => {
+ const result = dateTimeUtils.toTimeString('14:30:45');
+ expect(result).toMatch(/14:30/);
+ });
+
+ it('should return original string for unparseable format', () => {
+ expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM');
+ });
+
+ it('should return original string for invalid format', () => {
+ expect(dateTimeUtils.toTimeString('invalid')).toBe('invalid');
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T14:30:00Z');
+ const result = dateTimeUtils.toTimeString(date);
+ expect(result).toMatch(/\d{2}:\d{2}/);
+ });
+
+ it('should return 00:00 for invalid Date', () => {
+ expect(dateTimeUtils.toTimeString(new Date('invalid'))).toBe('00:00');
+ });
+ });
+
+ describe('parseDate', () => {
+ it('should return null for null value', () => {
+ expect(dateTimeUtils.parseDate(null)).toBeNull();
+ });
+
+ it('should parse YYYY-MM-DD format', () => {
+ const result = dateTimeUtils.parseDate('2024-01-15');
+ expect(result).toBeInstanceOf(Date);
+ expect(result?.getFullYear()).toBe(2024);
+ });
+
+ it('should parse ISO 8601 format', () => {
+ const result = dateTimeUtils.parseDate('2024-01-15T10:30:00Z');
+ expect(result).toBeInstanceOf(Date);
+ });
+
+ it('should return null for invalid date', () => {
+ expect(dateTimeUtils.parseDate('invalid')).toBeNull();
+ });
+ });
+
+ describe('buildTimeZoneOptions', () => {
+ it('should return array of timezone options', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ expect(Array.isArray(result)).toBe(true);
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should format timezone with offset', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ expect(result[0]).toHaveProperty('value');
+ expect(result[0]).toHaveProperty('label');
+ expect(result[0].label).toMatch(/UTC[+-]\d{2}:\d{2}/);
+ });
+
+ it('should sort by offset then name', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ for (let i = 1; i < result.length; i++) {
+ expect(result[i].numericOffset).toBeGreaterThanOrEqual(result[i - 1].numericOffset);
+ }
+ });
+
+ it('should include DST information when applicable', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ const dstZone = result.find(opt => opt.label.includes('DST range'));
+ expect(dstZone).toBeDefined();
+ });
+
+ it('should add preferred zone if not in list', () => {
+ const preferredZone = 'Custom/Zone';
+ const result = dateTimeUtils.buildTimeZoneOptions(preferredZone);
+ const found = result.find(opt => opt.value === preferredZone);
+ expect(found).toBeDefined();
+ });
+
+ it('should not duplicate existing zones', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions('UTC');
+ const utcOptions = result.filter(opt => opt.value === 'UTC');
+ expect(utcOptions).toHaveLength(1);
+ });
+ });
+
+ describe('getDefaultTimeZone', () => {
+ it('should return system timezone', () => {
+ const result = dateTimeUtils.getDefaultTimeZone();
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should return UTC on error', () => {
+ const originalDateTimeFormat = Intl.DateTimeFormat;
+ Intl.DateTimeFormat = vi.fn(() => {
+ throw new Error('Test error');
+ });
+
+ const result = dateTimeUtils.getDefaultTimeZone();
+ expect(result).toBe('UTC');
+
+ Intl.DateTimeFormat = originalDateTimeFormat;
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/networkUtils.test.js b/frontend/src/utils/__tests__/networkUtils.test.js
new file mode 100644
index 00000000..bb820589
--- /dev/null
+++ b/frontend/src/utils/__tests__/networkUtils.test.js
@@ -0,0 +1,144 @@
+import { describe, it, expect } from 'vitest';
+import * as networkUtils from '../networkUtils';
+
+describe('networkUtils', () => {
+ describe('IPV4_CIDR_REGEX', () => {
+ it('should match valid IPv4 CIDR notation', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0/24')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('10.0.0.0/8')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('172.16.0.0/12')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('0.0.0.0/0')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(true);
+ });
+
+ it('should not match invalid IPv4 CIDR notation', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0/33')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('256.168.1.0/24')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168/24')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('invalid')).toBe(false);
+ });
+
+ it('should not match IPv6 addresses', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('2001:db8::/32')).toBe(false);
+ });
+ });
+
+ describe('IPV6_CIDR_REGEX', () => {
+ it('should match valid IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/32')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('fe80::/10')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::/0')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334/64')).toBe(true);
+ });
+
+ it('should match compressed IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::1/128')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::1/128')).toBe(true);
+ });
+
+ it('should match IPv6 with embedded IPv4', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true);
+ });
+
+ it('should not match invalid IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::')).toBe(false);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/129')).toBe(false);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('invalid/64')).toBe(false);
+ });
+
+ it('should not match IPv4 addresses', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('192.168.1.0/24')).toBe(false);
+ });
+ });
+
+ describe('formatBytes', () => {
+ it('should return "0 Bytes" for zero bytes', () => {
+ expect(networkUtils.formatBytes(0)).toBe('0 Bytes');
+ });
+
+ it('should format bytes correctly', () => {
+ expect(networkUtils.formatBytes(100)).toBe('100.00 Bytes');
+ expect(networkUtils.formatBytes(500)).toBe('500.00 Bytes');
+ });
+
+ it('should format kilobytes correctly', () => {
+ expect(networkUtils.formatBytes(1024)).toBe('1.00 KB');
+ expect(networkUtils.formatBytes(2048)).toBe('2.00 KB');
+ expect(networkUtils.formatBytes(1536)).toBe('1.50 KB');
+ });
+
+ it('should format megabytes correctly', () => {
+ expect(networkUtils.formatBytes(1048576)).toBe('1.00 MB');
+ expect(networkUtils.formatBytes(2097152)).toBe('2.00 MB');
+ expect(networkUtils.formatBytes(5242880)).toBe('5.00 MB');
+ });
+
+ it('should format gigabytes correctly', () => {
+ expect(networkUtils.formatBytes(1073741824)).toBe('1.00 GB');
+ expect(networkUtils.formatBytes(2147483648)).toBe('2.00 GB');
+ });
+
+ it('should format terabytes correctly', () => {
+ expect(networkUtils.formatBytes(1099511627776)).toBe('1.00 TB');
+ });
+
+ it('should format large numbers', () => {
+ expect(networkUtils.formatBytes(1125899906842624)).toBe('1.00 PB');
+ });
+
+ it('should handle decimal values', () => {
+ const result = networkUtils.formatBytes(1536);
+ expect(result).toMatch(/1\.50 KB/);
+ });
+
+ it('should always show two decimal places', () => {
+ const result = networkUtils.formatBytes(1024);
+ expect(result).toBe('1.00 KB');
+ });
+ });
+
+ describe('formatSpeed', () => {
+ it('should return "0 Bytes" for zero speed', () => {
+ expect(networkUtils.formatSpeed(0)).toBe('0 Bytes');
+ });
+
+ it('should format bits per second correctly', () => {
+ expect(networkUtils.formatSpeed(100)).toBe('100.00 bps');
+ expect(networkUtils.formatSpeed(500)).toBe('500.00 bps');
+ });
+
+ it('should format kilobits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1024)).toBe('1.00 Kbps');
+ expect(networkUtils.formatSpeed(2048)).toBe('2.00 Kbps');
+ expect(networkUtils.formatSpeed(1536)).toBe('1.50 Kbps');
+ });
+
+ it('should format megabits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1048576)).toBe('1.00 Mbps');
+ expect(networkUtils.formatSpeed(2097152)).toBe('2.00 Mbps');
+ expect(networkUtils.formatSpeed(10485760)).toBe('10.00 Mbps');
+ });
+
+ it('should format gigabits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1073741824)).toBe('1.00 Gbps');
+ expect(networkUtils.formatSpeed(2147483648)).toBe('2.00 Gbps');
+ });
+
+ it('should handle decimal values', () => {
+ const result = networkUtils.formatSpeed(1536);
+ expect(result).toMatch(/1\.50 Kbps/);
+ });
+
+ it('should always show two decimal places', () => {
+ const result = networkUtils.formatSpeed(1024);
+ expect(result).toBe('1.00 Kbps');
+ });
+
+ it('should use speed units not byte units', () => {
+ const result = networkUtils.formatSpeed(1024);
+ expect(result).not.toContain('KB');
+ expect(result).toContain('Kbps');
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/notificationUtils.test.js b/frontend/src/utils/__tests__/notificationUtils.test.js
new file mode 100644
index 00000000..bfea55d8
--- /dev/null
+++ b/frontend/src/utils/__tests__/notificationUtils.test.js
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { notifications } from '@mantine/notifications';
+import * as notificationUtils from '../notificationUtils';
+
+vi.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: vi.fn(),
+ update: vi.fn(),
+ },
+}));
+
+describe('notificationUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('showNotification', () => {
+ it('should call notifications.show with notification object', () => {
+ const notificationObject = {
+ title: 'Test Title',
+ message: 'Test message',
+ color: 'blue',
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ expect(notifications.show).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the result from notifications.show', () => {
+ const mockReturnValue = 'notification-id-123';
+ notifications.show.mockReturnValue(mockReturnValue);
+
+ const result = notificationUtils.showNotification({ message: 'test' });
+
+ expect(result).toBe(mockReturnValue);
+ });
+
+ it('should handle notification with all properties', () => {
+ const notificationObject = {
+ id: 'custom-id',
+ title: 'Success',
+ message: 'Operation completed',
+ color: 'green',
+ autoClose: 5000,
+ withCloseButton: true,
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ });
+
+ it('should handle minimal notification object', () => {
+ const notificationObject = {
+ message: 'Simple message',
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ });
+ });
+
+ describe('updateNotification', () => {
+ it('should call notifications.update with id and notification object', () => {
+ const notificationId = 'notification-123';
+ const notificationObject = {
+ title: 'Updated Title',
+ message: 'Updated message',
+ color: 'green',
+ };
+
+ notificationUtils.updateNotification(notificationId, notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject);
+ expect(notifications.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the result from notifications.update', () => {
+ const mockReturnValue = { success: true };
+ notifications.update.mockReturnValue(mockReturnValue);
+
+ const result = notificationUtils.updateNotification('id', { message: 'test' });
+
+ expect(result).toBe(mockReturnValue);
+ });
+
+ it('should handle loading to success transition', () => {
+ const notificationId = 'loading-notification';
+ const updateObject = {
+ title: 'Success',
+ message: 'Operation completed successfully',
+ color: 'green',
+ loading: false,
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle loading to error transition', () => {
+ const notificationId = 'loading-notification';
+ const updateObject = {
+ title: 'Error',
+ message: 'Operation failed',
+ color: 'red',
+ loading: false,
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle partial updates', () => {
+ const notificationId = 'notification-123';
+ const updateObject = {
+ color: 'yellow',
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle empty notification id', () => {
+ const notificationObject = { message: 'test' };
+
+ notificationUtils.updateNotification('', notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith('', notificationObject);
+ });
+
+ it('should handle null notification id', () => {
+ const notificationObject = { message: 'test' };
+
+ notificationUtils.updateNotification(null, notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(null, notificationObject);
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/PluginCardUtils.js b/frontend/src/utils/cards/PluginCardUtils.js
new file mode 100644
index 00000000..8752e019
--- /dev/null
+++ b/frontend/src/utils/cards/PluginCardUtils.js
@@ -0,0 +1,24 @@
+export const getConfirmationDetails = (action, plugin, settings) => {
+ const actionConfirm = action.confirm;
+ const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
+ let requireConfirm = false;
+ let confirmTitle = `Run ${action.label}?`;
+ let confirmMessage = `You're about to run "${action.label}" from "${plugin.name}".`;
+
+ if (actionConfirm) {
+ if (typeof actionConfirm === 'boolean') {
+ requireConfirm = actionConfirm;
+ } else if (typeof actionConfirm === 'object') {
+ requireConfirm = actionConfirm.required !== false;
+ if (actionConfirm.title) confirmTitle = actionConfirm.title;
+ if (actionConfirm.message) confirmMessage = actionConfirm.message;
+ }
+ } else if (confirmField) {
+ const settingVal = settings?.confirm;
+ const effectiveConfirm =
+ (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
+ requireConfirm = !!effectiveConfirm;
+ }
+
+ return { requireConfirm, confirmTitle, confirmMessage };
+};
diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js
new file mode 100644
index 00000000..65b3da3a
--- /dev/null
+++ b/frontend/src/utils/cards/RecordingCardUtils.js
@@ -0,0 +1,92 @@
+import API from '../../api.js';
+import useChannelsStore from '../../store/channels.jsx';
+
+export const removeRecording = (id) => {
+ // Optimistically remove immediately from UI
+ try {
+ useChannelsStore.getState().removeRecording(id);
+ } catch (error) {
+ console.error('Failed to optimistically remove recording', error);
+ }
+ // Fire-and-forget server delete; websocket will keep others in sync
+ API.deleteRecording(id).catch(() => {
+ // On failure, fallback to refetch to restore state
+ try {
+ useChannelsStore.getState().fetchRecordings();
+ } catch (error) {
+ console.error('Failed to refresh recordings after delete', error);
+ }
+ });
+};
+
+export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => {
+ let purl = posterLogoId
+ ? `/api/channels/logos/${posterLogoId}/cache/`
+ : customProperties?.poster_url || posterUrl || '/logo.png';
+ if (
+ typeof import.meta !== 'undefined' &&
+ import.meta.env &&
+ import.meta.env.DEV &&
+ purl &&
+ purl.startsWith('/')
+ ) {
+ purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
+ }
+ return purl;
+};
+
+export const getShowVideoUrl = (channel, env_mode) => {
+ let url = `/proxy/ts/stream/${channel.uuid}`;
+ if (env_mode === 'dev') {
+ url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
+ }
+ return url;
+};
+
+export const runComSkip = async (recording) => {
+ await API.runComskip(recording.id);
+};
+
+export const deleteRecordingById = async (recordingId) => {
+ await API.deleteRecording(recordingId);
+};
+
+export const deleteSeriesAndRule = async (seriesInfo) => {
+ const { tvg_id, title } = seriesInfo;
+ if (tvg_id) {
+ try {
+ await API.bulkRemoveSeriesRecordings({
+ tvg_id,
+ title,
+ scope: 'title',
+ });
+ } catch (error) {
+ console.error('Failed to remove series recordings', error);
+ }
+ try {
+ await API.deleteSeriesRule(tvg_id);
+ } catch (error) {
+ console.error('Failed to delete series rule', error);
+ }
+ }
+};
+
+export const getRecordingUrl = (customProps, env_mode) => {
+ let fileUrl = customProps?.file_url || customProps?.output_file_url;
+ if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) {
+ fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
+ }
+ return fileUrl;
+};
+
+export const getSeasonLabel = (season, episode, onscreen) => {
+ return season && episode
+ ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
+ : onscreen || null;
+};
+
+export const getSeriesInfo = (customProps) => {
+ const cp = customProps || {};
+ const pr = cp.program || {};
+ return { tvg_id: pr.tvg_id, title: pr.title };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js
new file mode 100644
index 00000000..5c9d9ccc
--- /dev/null
+++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js
@@ -0,0 +1,131 @@
+import API from '../../api.js';
+import {
+ format,
+ getNow,
+ initializeTime,
+ subtract,
+ toFriendlyDuration,
+} from '../dateTimeUtils.js';
+
+// Get buffering_speed from proxy settings
+export const getBufferingSpeedThreshold = (proxySetting) => {
+ try {
+ if (proxySetting?.value) {
+ return parseFloat(proxySetting.value.buffering_speed) || 1.0;
+ }
+ } catch (error) {
+ console.error('Error getting buffering speed:', error);
+ }
+ return 1.0; // Default fallback
+};
+
+export const getStartDate = (uptime) => {
+ // Get the current date and time
+ const currentDate = new Date();
+ // Calculate the start date by subtracting uptime (in milliseconds)
+ const startDate = new Date(currentDate.getTime() - uptime * 1000);
+ // Format the date as a string (you can adjust the format as needed)
+ return startDate.toLocaleString({
+ weekday: 'short', // optional, adds day of the week
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: true, // 12-hour format with AM/PM
+ });
+};
+
+export const getM3uAccountsMap = (m3uAccounts) => {
+ const map = {};
+ if (m3uAccounts && Array.isArray(m3uAccounts)) {
+ m3uAccounts.forEach((account) => {
+ if (account.id) {
+ map[account.id] = account.name;
+ }
+ });
+ }
+ return map;
+};
+
+export const getChannelStreams = async (channelId) => {
+ return await API.getChannelStreams(channelId);
+};
+
+export const getMatchingStreamByUrl = (streamData, channelUrl) => {
+ return streamData.find(
+ (stream) =>
+ channelUrl.includes(stream.url) || stream.url.includes(channelUrl)
+ );
+};
+
+export const getSelectedStream = (availableStreams, streamId) => {
+ return availableStreams.find((s) => s.id.toString() === streamId);
+};
+
+export const switchStream = (channel, streamId) => {
+ return API.switchStream(channel.channel_id, streamId);
+};
+
+export const connectedAccessor = (dateFormat) => {
+ 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`);
+ }
+
+ // 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 'Unknown';
+ };
+};
+
+export const durationAccessor = () => {
+ return (row) => {
+ if (row.connected_since) {
+ return toFriendlyDuration(row.connected_since, 'seconds');
+ }
+
+ if (row.connection_duration) {
+ return toFriendlyDuration(row.connection_duration, 'seconds');
+ }
+
+ return '-';
+ };
+};
+
+export const getLogoUrl = (logoId, logos, previewedStream) => {
+ return (
+ (logoId && logos && logos[logoId] ? logos[logoId].cache_url : null) ||
+ previewedStream?.logo_url ||
+ null
+ );
+};
+
+export const getStreamsByIds = (streamId) => {
+ return API.getStreamsByIds([streamId]);
+};
+
+export const getStreamOptions = (availableStreams, m3uAccountsMap) => {
+ return availableStreams.map((stream) => {
+ // Get account name from our mapping if it exists
+ const accountName =
+ stream.m3u_account && m3uAccountsMap[stream.m3u_account]
+ ? m3uAccountsMap[stream.m3u_account]
+ : stream.m3u_account
+ ? `M3U #${stream.m3u_account}`
+ : 'Unknown M3U';
+
+ return {
+ value: stream.id.toString(),
+ label: `${stream.name || `Stream #${stream.id}`} [${accountName}]`,
+ };
+ });
+};
diff --git a/frontend/src/utils/cards/VODCardUtils.js b/frontend/src/utils/cards/VODCardUtils.js
new file mode 100644
index 00000000..3ec456e7
--- /dev/null
+++ b/frontend/src/utils/cards/VODCardUtils.js
@@ -0,0 +1,13 @@
+export const formatDuration = (seconds) => {
+ if (!seconds) return '';
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
+};
+
+export const getSeasonLabel = (vod) => {
+ return vod.season_number && vod.episode_number
+ ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
+ : '';
+};
diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js
new file mode 100644
index 00000000..3bf635b6
--- /dev/null
+++ b/frontend/src/utils/cards/VodConnectionCardUtils.js
@@ -0,0 +1,139 @@
+import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js';
+
+export const formatDuration = (seconds) => {
+ if (!seconds) return 'Unknown';
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+};
+
+// Format time for display (e.g., "1:23:45" or "23:45")
+export const formatTime = (seconds) => {
+ if (!seconds || seconds === 0) return '0:00';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ } else {
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+ }
+};
+
+export const getMovieDisplayTitle = (vodContent) => {
+ return vodContent.content_name;
+}
+
+export const getEpisodeDisplayTitle = (metadata) => {
+ const season = metadata.season_number
+ ? `S${metadata.season_number.toString().padStart(2, '0')}`
+ : 'S??';
+ const episode = metadata.episode_number
+ ? `E${metadata.episode_number.toString().padStart(2, '0')}`
+ : 'E??';
+ return `${metadata.series_name} - ${season}${episode}`;
+}
+
+export const getMovieSubtitle = (metadata) => {
+ const parts = [];
+ if (metadata.genre) parts.push(metadata.genre);
+ // We'll handle rating separately as a badge now
+ return parts;
+}
+
+export const getEpisodeSubtitle = (metadata) => {
+ return [metadata.episode_name || 'Episode'];
+}
+
+export const calculateProgress = (connection, duration_secs) => {
+ if (!connection || !duration_secs) {
+ return {
+ percentage: 0,
+ currentTime: 0,
+ totalTime: duration_secs || 0,
+ };
+ }
+
+ const totalSeconds = duration_secs;
+ let percentage = 0;
+ let currentTime = 0;
+ const now = getNowMs() / 1000; // Current time in seconds
+
+ // Priority 1: Use last_seek_percentage if available (most accurate from range requests)
+ if (
+ connection.last_seek_percentage &&
+ connection.last_seek_percentage > 0 &&
+ connection.last_seek_timestamp
+ ) {
+ // Calculate the position at the time of seek
+ const seekPosition = Math.round(
+ (connection.last_seek_percentage / 100) * totalSeconds
+ );
+
+ // Add elapsed time since the seek
+ const elapsedSinceSeek = now - connection.last_seek_timestamp;
+ currentTime = seekPosition + Math.floor(elapsedSinceSeek);
+
+ // Don't exceed the total duration
+ currentTime = Math.min(currentTime, totalSeconds);
+
+ percentage = (currentTime / totalSeconds) * 100;
+ }
+ // Priority 2: Use position_seconds if available
+ else if (connection.position_seconds && connection.position_seconds > 0) {
+ currentTime = connection.position_seconds;
+ percentage = (currentTime / totalSeconds) * 100;
+ }
+
+ return {
+ percentage: Math.min(percentage, 100), // Cap at 100%
+ currentTime: Math.max(0, currentTime), // Don't go negative
+ totalTime: totalSeconds,
+ };
+}
+
+export const calculateConnectionDuration = (connection) => {
+ // If duration is provided by API, use it
+ if (connection.duration && connection.duration > 0) {
+ return toFriendlyDuration(connection.duration, 'seconds');
+ }
+
+ // Fallback: try to extract from client_id timestamp
+ if (connection.client_id && connection.client_id.startsWith('vod_')) {
+ try {
+ const parts = connection.client_id.split('_');
+ if (parts.length >= 2) {
+ const clientStartTime = parseInt(parts[1]) / 1000; // Convert ms to seconds
+ const currentTime = getNowMs() / 1000;
+ return toFriendlyDuration(currentTime - clientStartTime, 'seconds');
+ }
+ } catch {
+ // Ignore parsing errors
+ }
+ }
+
+ return 'Unknown duration';
+}
+
+export const calculateConnectionStartTime = (connection, dateFormat) => {
+ if (connection.connected_at) {
+ return format(connection.connected_at * 1000, `${dateFormat} HH:mm:ss`);
+ }
+
+ // Fallback: calculate from client_id timestamp
+ if (connection.client_id && connection.client_id.startsWith('vod_')) {
+ try {
+ const parts = connection.client_id.split('_');
+ if (parts.length >= 2) {
+ const clientStartTime = parseInt(parts[1]);
+ return format(clientStartTime, `${dateFormat} HH:mm:ss`);
+ }
+ } catch {
+ // Ignore parsing errors
+ }
+ }
+
+ return 'Unknown';
+}
\ No newline at end of file
diff --git a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
new file mode 100644
index 00000000..a6074a4a
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
@@ -0,0 +1,158 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getConfirmationDetails,
+} from '../PluginCardUtils';
+
+describe('PluginCardUtils', () => {
+ describe('getConfirmationDetails', () => {
+ it('requires confirmation when action.confirm is true', () => {
+ const action = { label: 'Test Action', confirm: true };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result).toEqual({
+ requireConfirm: true,
+ confirmTitle: 'Run Test Action?',
+ confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".',
+ });
+ });
+
+ it('does not require confirmation when action.confirm is false', () => {
+ const action = { label: 'Test Action', confirm: false };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('uses custom title and message from action.confirm object', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ required: true,
+ title: 'Custom Title',
+ message: 'Custom message',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result).toEqual({
+ requireConfirm: true,
+ confirmTitle: 'Custom Title',
+ confirmMessage: 'Custom message',
+ });
+ });
+
+ it('requires confirmation when action.confirm.required is not explicitly false', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ title: 'Custom Title',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('does not require confirmation when action.confirm.required is false', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ required: false,
+ title: 'Custom Title',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('uses confirm field from plugin when action.confirm is undefined', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('uses settings value over field default', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: false }],
+ };
+ const settings = { confirm: true };
+ const result = getConfirmationDetails(action, plugin, settings);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('uses field default when settings value is undefined', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const settings = {};
+ const result = getConfirmationDetails(action, plugin, settings);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('does not require confirmation when no confirm configuration exists', () => {
+ const action = { label: 'Test Action' };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('handles plugin without fields array', () => {
+ const action = { label: 'Test Action' };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('handles null or undefined settings', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const result = getConfirmationDetails(action, plugin, null);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('converts truthy confirm field values to boolean', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: 1 }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('handles confirm field with null default', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: null }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
new file mode 100644
index 00000000..3410c596
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
@@ -0,0 +1,390 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ removeRecording,
+ getPosterUrl,
+ getShowVideoUrl,
+ runComSkip,
+ deleteRecordingById,
+ deleteSeriesAndRule,
+ getRecordingUrl,
+ getSeasonLabel,
+ getSeriesInfo,
+} from '../RecordingCardUtils';
+import API from '../../../api';
+import useChannelsStore from '../../../store/channels';
+
+vi.mock('../../../api');
+vi.mock('../../../store/channels');
+
+describe('RecordingCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('removeRecording', () => {
+ let mockRemoveRecording;
+ let mockFetchRecordings;
+
+ beforeEach(() => {
+ mockRemoveRecording = vi.fn();
+ mockFetchRecordings = vi.fn();
+ useChannelsStore.getState = vi.fn(() => ({
+ removeRecording: mockRemoveRecording,
+ fetchRecordings: mockFetchRecordings,
+ }));
+ });
+
+ it('optimistically removes recording from store', () => {
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(mockRemoveRecording).toHaveBeenCalledWith('recording-1');
+ });
+
+ it('calls API to delete recording', () => {
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(API.deleteRecording).toHaveBeenCalledWith('recording-1');
+ });
+
+ it('handles optimistic removal error', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ mockRemoveRecording.mockImplementation(() => {
+ throw new Error('Store error');
+ });
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to optimistically remove recording',
+ expect.any(Error)
+ );
+ consoleError.mockRestore();
+ });
+
+ it('refetches recordings when API delete fails', async () => {
+ API.deleteRecording.mockRejectedValue(new Error('Delete failed'));
+
+ removeRecording('recording-1');
+
+ await vi.waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('handles fetch error after failed delete', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.deleteRecording.mockRejectedValue(new Error('Delete failed'));
+ mockFetchRecordings.mockImplementation(() => {
+ throw new Error('Fetch error');
+ });
+
+ removeRecording('recording-1');
+
+ await vi.waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to refresh recordings after delete',
+ expect.any(Error)
+ );
+ });
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('getPosterUrl', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('returns logo URL when posterLogoId is provided', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl('logo-123', {}, '');
+
+ expect(result).toBe('/api/channels/logos/logo-123/cache/');
+ });
+
+ it('returns custom poster_url when no posterLogoId', () => {
+ vi.stubEnv('DEV', false);
+ const customProps = { poster_url: '/custom/poster.jpg' };
+ const result = getPosterUrl(null, customProps, '');
+
+ expect(result).toBe('/custom/poster.jpg');
+ });
+
+ it('returns posterUrl when no posterLogoId or custom poster_url', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl(null, {}, '/fallback/poster.jpg');
+
+ expect(result).toBe('/fallback/poster.jpg');
+ });
+
+ it('returns default logo when no parameters provided', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl(null, {}, '');
+
+ expect(result).toBe('/logo.png');
+ });
+
+ it('prepends dev server URL in dev mode for relative paths', () => {
+ vi.stubEnv('DEV', true);
+ const result = getPosterUrl(null, {}, '/poster.jpg');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/poster\.jpg$/);
+ });
+
+ it('does not prepend dev URL for absolute URLs', () => {
+ vi.stubEnv('DEV', true);
+ const result = getPosterUrl(null, {}, 'https://example.com/poster.jpg');
+
+ expect(result).toBe('https://example.com/poster.jpg');
+ });
+ });
+
+ describe('getShowVideoUrl', () => {
+ it('returns proxy URL for channel', () => {
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'production');
+
+ expect(result).toBe('/proxy/ts/stream/channel-123');
+ });
+
+ it('prepends dev server URL in dev mode', () => {
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'dev');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/);
+ });
+ });
+
+ describe('runComSkip', () => {
+ it('calls API runComskip with recording id', async () => {
+ API.runComskip.mockResolvedValue();
+ const recording = { id: 'recording-1' };
+
+ await runComSkip(recording);
+
+ expect(API.runComskip).toHaveBeenCalledWith('recording-1');
+ });
+ });
+
+ describe('deleteRecordingById', () => {
+ it('calls API deleteRecording with id', async () => {
+ API.deleteRecording.mockResolvedValue();
+
+ await deleteRecordingById('recording-1');
+
+ expect(API.deleteRecording).toHaveBeenCalledWith('recording-1');
+ });
+ });
+
+ describe('deleteSeriesAndRule', () => {
+ it('removes series recordings and deletes series rule', async () => {
+ API.bulkRemoveSeriesRecordings.mockResolvedValue();
+ API.deleteSeriesRule.mockResolvedValue();
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({
+ tvg_id: 'series-123',
+ title: 'Test Series',
+ scope: 'title',
+ });
+ expect(API.deleteSeriesRule).toHaveBeenCalledWith('series-123');
+ });
+
+ it('does nothing when tvg_id is not provided', async () => {
+ const seriesInfo = { title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(API.bulkRemoveSeriesRecordings).not.toHaveBeenCalled();
+ expect(API.deleteSeriesRule).not.toHaveBeenCalled();
+ });
+
+ it('handles bulk remove error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed'));
+ API.deleteSeriesRule.mockResolvedValue();
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to remove series recordings',
+ expect.any(Error)
+ );
+ expect(API.deleteSeriesRule).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ it('handles delete rule error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.bulkRemoveSeriesRecordings.mockResolvedValue();
+ API.deleteSeriesRule.mockRejectedValue(new Error('Delete rule failed'));
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to delete series rule',
+ expect.any(Error)
+ );
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('getRecordingUrl', () => {
+ it('returns file_url when available', () => {
+ const customProps = { file_url: '/recordings/file.mp4' };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/recordings/file.mp4');
+ });
+
+ it('returns output_file_url when file_url is not available', () => {
+ const customProps = { output_file_url: '/output/file.mp4' };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/output/file.mp4');
+ });
+
+ it('prefers file_url over output_file_url', () => {
+ const customProps = {
+ file_url: '/recordings/file.mp4',
+ output_file_url: '/output/file.mp4',
+ };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/recordings/file.mp4');
+ });
+
+ it('prepends dev server URL in dev mode for relative paths', () => {
+ const customProps = { file_url: '/recordings/file.mp4' };
+ const result = getRecordingUrl(customProps, 'dev');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/);
+ });
+
+ it('does not prepend dev URL for absolute URLs', () => {
+ const customProps = { file_url: 'https://example.com/file.mp4' };
+ const result = getRecordingUrl(customProps, 'dev');
+
+ expect(result).toBe('https://example.com/file.mp4');
+ });
+
+ it('returns undefined when no file URL is available', () => {
+ const result = getRecordingUrl({}, 'production');
+
+ expect(result).toBeUndefined();
+ });
+
+ it('handles null customProps', () => {
+ const result = getRecordingUrl(null, 'production');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getSeasonLabel', () => {
+ it('returns formatted season and episode label', () => {
+ const result = getSeasonLabel(1, 5, null);
+
+ expect(result).toBe('S01E05');
+ });
+
+ it('pads single digit season and episode numbers', () => {
+ const result = getSeasonLabel(2, 3, null);
+
+ expect(result).toBe('S02E03');
+ });
+
+ it('handles multi-digit season and episode numbers', () => {
+ const result = getSeasonLabel(12, 34, null);
+
+ expect(result).toBe('S12E34');
+ });
+
+ it('returns onscreen value when season or episode is missing', () => {
+ const result = getSeasonLabel(null, 5, 'Episode 5');
+
+ expect(result).toBe('Episode 5');
+ });
+
+ it('returns onscreen value when only episode is missing', () => {
+ const result = getSeasonLabel(1, null, 'Special');
+
+ expect(result).toBe('Special');
+ });
+
+ it('returns null when no season, episode, or onscreen provided', () => {
+ const result = getSeasonLabel(null, null, null);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns formatted label even when onscreen is provided', () => {
+ const result = getSeasonLabel(1, 5, 'Episode 5');
+
+ expect(result).toBe('S01E05');
+ });
+ });
+
+ describe('getSeriesInfo', () => {
+ it('extracts tvg_id and title from program', () => {
+ const customProps = {
+ program: { tvg_id: 'series-123', title: 'Test Series' },
+ };
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: 'series-123',
+ title: 'Test Series',
+ });
+ });
+
+ it('handles missing program object', () => {
+ const customProps = {};
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles null customProps', () => {
+ const result = getSeriesInfo(null);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles undefined customProps', () => {
+ const result = getSeriesInfo(undefined);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles partial program data', () => {
+ const customProps = {
+ program: { tvg_id: 'series-123' },
+ };
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: 'series-123',
+ title: undefined,
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
new file mode 100644
index 00000000..92c028c9
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
@@ -0,0 +1,300 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamConnectionCardUtils from '../StreamConnectionCardUtils';
+import API from '../../../api.js';
+import * as dateTimeUtils from '../../dateTimeUtils.js';
+
+vi.mock('../../../api.js');
+vi.mock('../../dateTimeUtils.js');
+
+describe('StreamConnectionCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getBufferingSpeedThreshold', () => {
+ it('should return parsed buffering_speed from proxy settings', () => {
+ const proxySetting = {
+ value: { buffering_speed: 2.5 }
+ };
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5);
+ });
+
+ it('should return 1.0 for invalid JSON', () => {
+ const proxySetting = { value: { buffering_speed: 'invalid' } };
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ consoleSpy.mockRestore();
+ });
+
+ it('should return 1.0 when buffering_speed is not a number', () => {
+ const proxySetting = {
+ value: JSON.stringify({ buffering_speed: 'not a number' })
+ };
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ });
+
+ it('should return 1.0 when proxySetting is null', () => {
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0);
+ });
+
+ it('should return 1.0 when value is missing', () => {
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0);
+ });
+ });
+
+ describe('getStartDate', () => {
+ it('should calculate start date from uptime in seconds', () => {
+ const uptime = 3600; // 1 hour
+ const result = StreamConnectionCardUtils.getStartDate(uptime);
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should handle zero uptime', () => {
+ const result = StreamConnectionCardUtils.getStartDate(0);
+ expect(typeof result).toBe('string');
+ });
+ });
+
+ describe('getM3uAccountsMap', () => {
+ it('should create map from m3u accounts array', () => {
+ const m3uAccounts = [
+ { id: 1, name: 'Account 1' },
+ { id: 2, name: 'Account 2' }
+ ];
+ const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
+ expect(result).toEqual({ 1: 'Account 1', 2: 'Account 2' });
+ });
+
+ it('should handle accounts without id', () => {
+ const m3uAccounts = [
+ { name: 'Account 1' },
+ { id: 2, name: 'Account 2' }
+ ];
+ const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
+ expect(result).toEqual({ 2: 'Account 2' });
+ });
+
+ it('should return empty object for null input', () => {
+ expect(StreamConnectionCardUtils.getM3uAccountsMap(null)).toEqual({});
+ });
+
+ it('should return empty object for non-array input', () => {
+ expect(StreamConnectionCardUtils.getM3uAccountsMap({})).toEqual({});
+ });
+ });
+
+ describe('getChannelStreams', () => {
+ it('should call API.getChannelStreams with channelId', async () => {
+ const mockStreams = [{ id: 1, name: 'Stream 1' }];
+ API.getChannelStreams.mockResolvedValue(mockStreams);
+
+ const result = await StreamConnectionCardUtils.getChannelStreams(123);
+
+ expect(API.getChannelStreams).toHaveBeenCalledWith(123);
+ expect(result).toEqual(mockStreams);
+ });
+ });
+
+ describe('getMatchingStreamByUrl', () => {
+ it('should find stream when channelUrl includes stream url', () => {
+ const streamData = [
+ { id: 1, url: 'http://example.com/stream1' },
+ { id: 2, url: 'http://example.com/stream2' }
+ ];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://example.com/stream1/playlist.m3u8'
+ );
+ expect(result).toEqual(streamData[0]);
+ });
+
+ it('should find stream when stream url includes channelUrl', () => {
+ const streamData = [
+ { id: 1, url: 'http://example.com/stream1/playlist.m3u8' }
+ ];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://example.com/stream1'
+ );
+ expect(result).toEqual(streamData[0]);
+ });
+
+ it('should return undefined when no match found', () => {
+ const streamData = [{ id: 1, url: 'http://example.com/stream1' }];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://different.com/stream'
+ );
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getSelectedStream', () => {
+ it('should find stream by id as string', () => {
+ const streams = [
+ { id: 1, name: 'Stream 1' },
+ { id: 2, name: 'Stream 2' }
+ ];
+ const result = StreamConnectionCardUtils.getSelectedStream(streams, '2');
+ expect(result).toEqual(streams[1]);
+ });
+
+ it('should return undefined when stream not found', () => {
+ const streams = [{ id: 1, name: 'Stream 1' }];
+ const result = StreamConnectionCardUtils.getSelectedStream(streams, '99');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('switchStream', () => {
+ it('should call API.switchStream with channel_id and streamId', () => {
+ const channel = { channel_id: 123 };
+ API.switchStream.mockResolvedValue({ success: true });
+
+ StreamConnectionCardUtils.switchStream(channel, 456);
+
+ expect(API.switchStream).toHaveBeenCalledWith(123, 456);
+ });
+ });
+
+ describe('connectedAccessor', () => {
+ it('should format connected_since correctly', () => {
+ const mockNow = new Date('2024-01-01T12:00:00');
+ const mockConnectedTime = new Date('2024-01-01T10:00:00');
+
+ dateTimeUtils.getNow.mockReturnValue(mockNow);
+ dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
+ dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
+
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ 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(result).toBe('01/01/2024 10:00:00');
+ });
+
+ it('should fallback to connected_at when connected_since is missing', () => {
+ const mockTime = new Date('2024-01-01T10:00:00');
+
+ dateTimeUtils.initializeTime.mockReturnValue(mockTime);
+ dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
+
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({ connected_at: 1704103200 });
+
+ expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
+ expect(result).toBe('01/01/2024 10:00:00');
+ });
+
+ it('should return Unknown when no time data available', () => {
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({});
+ expect(result).toBe('Unknown');
+ });
+ });
+
+ describe('durationAccessor', () => {
+ it('should format connected_since duration', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('2h 30m');
+
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({ connected_since: 9000 });
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds');
+ expect(result).toBe('2h 30m');
+ });
+
+ it('should fallback to connection_duration', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 15m');
+
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({ connection_duration: 4500 });
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds');
+ expect(result).toBe('1h 15m');
+ });
+
+ it('should return - when no duration data available', () => {
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({});
+ expect(result).toBe('-');
+ });
+ });
+
+ describe('getLogoUrl', () => {
+ it('should return cache_url from logos map when logoId exists', () => {
+ const logos = {
+ 'logo-123': { cache_url: '/api/logos/logo-123/cache/' }
+ };
+ const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null);
+ expect(result).toBe('/api/logos/logo-123/cache/');
+ });
+
+ it('should fallback to previewedStream logo_url when logoId not in map', () => {
+ const previewedStream = { logo_url: 'http://example.com/logo.png' };
+ const result = StreamConnectionCardUtils.getLogoUrl('logo-456', {}, previewedStream);
+ expect(result).toBe('http://example.com/logo.png');
+ });
+
+ it('should return null when no logo available', () => {
+ const result = StreamConnectionCardUtils.getLogoUrl(null, {}, null);
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getStreamsByIds', () => {
+ it('should call API.getStreamsByIds with array containing streamId', async () => {
+ const mockStreams = [{ id: 123, name: 'Stream' }];
+ API.getStreamsByIds.mockResolvedValue(mockStreams);
+
+ const result = await StreamConnectionCardUtils.getStreamsByIds(123);
+
+ expect(API.getStreamsByIds).toHaveBeenCalledWith([123]);
+ expect(result).toEqual(mockStreams);
+ });
+ });
+
+ describe('getStreamOptions', () => {
+ it('should format stream options with account names from map', () => {
+ const streams = [
+ { id: 1, name: 'Stream 1', m3u_account: 100 },
+ { id: 2, name: 'Stream 2', m3u_account: 200 }
+ ];
+ const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' };
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+
+ expect(result).toEqual([
+ { value: '1', label: 'Stream 1 [Premium Account]' },
+ { value: '2', label: 'Stream 2 [Basic Account]' }
+ ]);
+ });
+
+ it('should use default M3U label when account not in map', () => {
+ const streams = [{ id: 1, name: 'Stream 1', m3u_account: 999 }];
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, {});
+
+ expect(result[0].label).toBe('Stream 1 [M3U #999]');
+ });
+
+ it('should handle streams without name', () => {
+ const streams = [{ id: 5, m3u_account: 100 }];
+ const accountsMap = { 100: 'Account' };
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+
+ expect(result[0].label).toBe('Stream #5 [Account]');
+ });
+
+ it('should handle streams without m3u_account', () => {
+ const streams = [{ id: 1, name: 'Stream 1' }];
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, {});
+
+ expect(result[0].label).toBe('Stream 1 [Unknown M3U]');
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/VODCardUtils.test.js b/frontend/src/utils/cards/__tests__/VODCardUtils.test.js
new file mode 100644
index 00000000..b9ada55c
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/VODCardUtils.test.js
@@ -0,0 +1,90 @@
+import { describe, it, expect } from 'vitest';
+import * as VODCardUtils from '../VODCardUtils';
+
+describe('VODCardUtils', () => {
+ describe('formatDuration', () => {
+ it('should format duration with hours and minutes', () => {
+ const result = VODCardUtils.formatDuration(3661); // 1h 1m 1s
+ expect(result).toBe('1h 1m');
+ });
+
+ it('should format duration with minutes and seconds when less than an hour', () => {
+ const result = VODCardUtils.formatDuration(125); // 2m 5s
+ expect(result).toBe('2m 5s');
+ });
+
+ it('should format duration with only minutes when seconds are zero', () => {
+ const result = VODCardUtils.formatDuration(120); // 2m 0s
+ expect(result).toBe('2m 0s');
+ });
+
+ it('should format duration with only seconds when less than a minute', () => {
+ const result = VODCardUtils.formatDuration(45);
+ expect(result).toBe('0m 45s');
+ });
+
+ it('should handle multiple hours correctly', () => {
+ const result = VODCardUtils.formatDuration(7325); // 2h 2m 5s
+ expect(result).toBe('2h 2m');
+ });
+
+ it('should return empty string for zero seconds', () => {
+ const result = VODCardUtils.formatDuration(0);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string for null', () => {
+ const result = VODCardUtils.formatDuration(null);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string for undefined', () => {
+ const result = VODCardUtils.formatDuration(undefined);
+ expect(result).toBe('');
+ });
+ });
+
+ describe('getSeasonLabel', () => {
+ it('should format season and episode numbers with padding', () => {
+ const vod = { season_number: 1, episode_number: 5 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('S01E05');
+ });
+
+ it('should format double-digit season and episode numbers', () => {
+ const vod = { season_number: 12, episode_number: 23 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('S12E23');
+ });
+
+ it('should return empty string when season_number is missing', () => {
+ const vod = { episode_number: 5 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when episode_number is missing', () => {
+ const vod = { season_number: 1 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when both are missing', () => {
+ const vod = {};
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should handle season_number of zero', () => {
+ const vod = { season_number: 0, episode_number: 1 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should handle episode_number of zero', () => {
+ const vod = { season_number: 1, episode_number: 0 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
new file mode 100644
index 00000000..9765daf3
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
@@ -0,0 +1,323 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as VodConnectionCardUtils from '../VodConnectionCardUtils';
+import * as dateTimeUtils from '../../dateTimeUtils.js';
+
+vi.mock('../../dateTimeUtils.js');
+
+describe('VodConnectionCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('formatDuration', () => {
+ it('should format duration with hours and minutes when hours > 0', () => {
+ const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s
+ expect(result).toBe('1h 1m');
+ });
+
+ it('should format duration with only minutes when less than an hour', () => {
+ const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s
+ expect(result).toBe('2m');
+ });
+
+ it('should format duration with 0 minutes when less than 60 seconds', () => {
+ const result = VodConnectionCardUtils.formatDuration(45);
+ expect(result).toBe('0m');
+ });
+
+ it('should handle multiple hours correctly', () => {
+ const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s
+ expect(result).toBe('2h 2m');
+ });
+
+ it('should return Unknown for zero seconds', () => {
+ const result = VodConnectionCardUtils.formatDuration(0);
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown for null', () => {
+ const result = VodConnectionCardUtils.formatDuration(null);
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown for undefined', () => {
+ const result = VodConnectionCardUtils.formatDuration(undefined);
+ expect(result).toBe('Unknown');
+ });
+ });
+
+ describe('formatTime', () => {
+ it('should format time with hours when hours > 0', () => {
+ const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05
+ expect(result).toBe('1:01:05');
+ });
+
+ it('should format time without hours when less than an hour', () => {
+ const result = VodConnectionCardUtils.formatTime(125); // 2:05
+ expect(result).toBe('2:05');
+ });
+
+ it('should pad minutes and seconds with zeros', () => {
+ const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05
+ expect(result).toBe('1:00:05');
+ });
+
+ it('should handle only seconds', () => {
+ const result = VodConnectionCardUtils.formatTime(45); // 0:45
+ expect(result).toBe('0:45');
+ });
+
+ it('should return 0:00 for zero seconds', () => {
+ const result = VodConnectionCardUtils.formatTime(0);
+ expect(result).toBe('0:00');
+ });
+
+ it('should return 0:00 for null', () => {
+ const result = VodConnectionCardUtils.formatTime(null);
+ expect(result).toBe('0:00');
+ });
+
+ it('should return 0:00 for undefined', () => {
+ const result = VodConnectionCardUtils.formatTime(undefined);
+ expect(result).toBe('0:00');
+ });
+ });
+
+ describe('getMovieDisplayTitle', () => {
+ it('should return content_name from vodContent', () => {
+ const vodContent = { content_name: 'The Matrix' };
+ const result = VodConnectionCardUtils.getMovieDisplayTitle(vodContent);
+ expect(result).toBe('The Matrix');
+ });
+ });
+
+ describe('getEpisodeDisplayTitle', () => {
+ it('should format title with season and episode numbers', () => {
+ const metadata = {
+ series_name: 'Breaking Bad',
+ season_number: 1,
+ episode_number: 5
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Breaking Bad - S01E05');
+ });
+
+ it('should pad single-digit season and episode numbers', () => {
+ const metadata = {
+ series_name: 'The Office',
+ season_number: 3,
+ episode_number: 9
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('The Office - S03E09');
+ });
+
+ it('should use S?? when season_number is missing', () => {
+ const metadata = {
+ series_name: 'Lost',
+ episode_number: 5
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Lost - S??E05');
+ });
+
+ it('should use E?? when episode_number is missing', () => {
+ const metadata = {
+ series_name: 'Friends',
+ season_number: 2
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Friends - S02E??');
+ });
+ });
+
+ describe('getMovieSubtitle', () => {
+ it('should return array with genre when present', () => {
+ const metadata = { genre: 'Action' };
+ const result = VodConnectionCardUtils.getMovieSubtitle(metadata);
+ expect(result).toEqual(['Action']);
+ });
+
+ it('should return empty array when genre is missing', () => {
+ const metadata = {};
+ const result = VodConnectionCardUtils.getMovieSubtitle(metadata);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getEpisodeSubtitle', () => {
+ it('should return array with episode_name when present', () => {
+ const metadata = { episode_name: 'Pilot' };
+ const result = VodConnectionCardUtils.getEpisodeSubtitle(metadata);
+ expect(result).toEqual(['Pilot']);
+ });
+
+ it('should return array with Episode when episode_name is missing', () => {
+ const metadata = {};
+ const result = VodConnectionCardUtils.getEpisodeSubtitle(metadata);
+ expect(result).toEqual(['Episode']);
+ });
+ });
+
+ describe('calculateProgress', () => {
+ beforeEach(() => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000); // 1000 seconds
+ });
+
+ it('should calculate progress from last_seek_percentage', () => {
+ const connection = {
+ last_seek_percentage: 50,
+ last_seek_timestamp: 990 // 10 seconds ago
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(110); // 50% of 200 = 100, plus 10 elapsed
+ expect(result.percentage).toBeCloseTo(55);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should cap currentTime at duration when seeking', () => {
+ const connection = {
+ last_seek_percentage: 95,
+ last_seek_timestamp: 900 // 100 seconds ago
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(200); // Capped at duration
+ expect(result.percentage).toBe(100);
+ });
+
+ it('should fallback to position_seconds when seek data unavailable', () => {
+ const connection = {
+ position_seconds: 75
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(75);
+ expect(result.percentage).toBe(37.5);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should return zero progress when no connection data', () => {
+ const result = VodConnectionCardUtils.calculateProgress(null, 200);
+
+ expect(result.currentTime).toBe(0);
+ expect(result.percentage).toBe(0);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should return zero progress when duration is missing', () => {
+ const connection = { position_seconds: 50 };
+ const result = VodConnectionCardUtils.calculateProgress(connection, null);
+
+ expect(result.currentTime).toBe(0);
+ expect(result.percentage).toBe(0);
+ expect(result.totalTime).toBe(0);
+ });
+
+ it('should ensure currentTime is not negative', () => {
+ const connection = {
+ last_seek_percentage: 10,
+ last_seek_timestamp: 2000 // In the future somehow
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('calculateConnectionDuration', () => {
+ it('should use duration from connection when available', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m');
+ const connection = { duration: 5400 };
+
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds');
+ expect(result).toBe('1h 30m');
+ });
+
+ it('should calculate duration from client_id timestamp when duration missing', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
+
+ const connection = { client_id: 'vod_900000_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds');
+ expect(result).toBe('45m');
+ });
+
+ it('should return Unknown duration when no data available', () => {
+ const connection = {};
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(result).toBe('Unknown duration');
+ });
+
+ it('should return Unknown duration when client_id is invalid format', () => {
+ const connection = { client_id: 'invalid_format' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(result).toBe('Unknown duration');
+ });
+
+ it('should handle parsing errors gracefully', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
+
+ const connection = { client_id: 'vod_invalid_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ // If parseInt fails, the code should still handle it
+ expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation
+ });
+ });
+
+ describe('calculateConnectionStartTime', () => {
+ it('should format connected_at timestamp when available', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
+
+ const connection = { connected_at: 1705329000 };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/15/2024 14:30:00');
+ });
+
+ it('should calculate start time from client_id when connected_at missing', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
+
+ const connection = { client_id: 'vod_1705323600000_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ 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');
+
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown when client_id is invalid format', () => {
+ const connection = { client_id: 'invalid_format' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(result).toBe('Unknown');
+ });
+
+ it('should handle parsing errors gracefully', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
+
+ const connection = { client_id: 'vod_notanumber_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ // If parseInt succeeds on any number, format will be called
+ expect(result).toBe('01/15/2024 13:00:00'); // or 'Unknown' depending on implementation
+ });
+
+ });
+});
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
new file mode 100644
index 00000000..53f9912c
--- /dev/null
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -0,0 +1,267 @@
+import { useCallback, useEffect } from 'react';
+import dayjs from 'dayjs';
+import duration from 'dayjs/plugin/duration';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import useSettingsStore from '../store/settings';
+import useLocalStorage from '../hooks/useLocalStorage';
+
+dayjs.extend(duration);
+dayjs.extend(relativeTime);
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
+
+export const convertToSec = (dateTime) => dayjs(dateTime).unix();
+
+export const initializeTime = (dateTime) => dayjs(dateTime);
+
+export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
+
+export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2);
+
+export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2);
+
+export const isSame = (date1, date2, unit = 'day') =>
+ dayjs(date1).isSame(date2, unit);
+
+export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit);
+
+export const subtract = (dateTime, value, unit) =>
+ dayjs(dateTime).subtract(value, unit);
+
+export const diff = (date1, date2, unit = 'millisecond') =>
+ dayjs(date1).diff(date2, unit);
+
+export const format = (dateTime, formatStr) =>
+ dayjs(dateTime).format(formatStr);
+
+export const getNow = () => dayjs();
+
+export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize();
+
+export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
+
+export const getNowMs = () => Date.now();
+
+export const roundToNearest = (dateTime, minutes) => {
+ const current = initializeTime(dateTime);
+ const minute = current.minute();
+ const snappedMinute = Math.round(minute / minutes) * minutes;
+
+ return snappedMinute === 60
+ ? current.add(1, 'hour').minute(0)
+ : current.minute(snappedMinute);
+};
+
+export const useUserTimeZone = () => {
+ const settings = useSettingsStore((s) => s.settings);
+ const [timeZone, setTimeZone] = useLocalStorage(
+ 'time-zone',
+ dayjs.tz?.guess
+ ? dayjs.tz.guess()
+ : Intl.DateTimeFormat().resolvedOptions().timeZone
+ );
+
+ useEffect(() => {
+ const tz = settings?.['system_settings']?.value?.time_zone;
+ if (tz && tz !== timeZone) {
+ setTimeZone(tz);
+ }
+ }, [settings, timeZone, setTimeZone]);
+
+ return timeZone;
+};
+
+export const useTimeHelpers = () => {
+ const timeZone = useUserTimeZone();
+
+ const toUserTime = useCallback(
+ (value) => {
+ if (!value) return dayjs.invalid();
+ try {
+ return initializeTime(value).tz(timeZone);
+ } catch (error) {
+ return initializeTime(value);
+ }
+ },
+ [timeZone]
+ );
+
+ const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]);
+
+ return { timeZone, toUserTime, userNow };
+};
+
+export const RECURRING_DAY_OPTIONS = [
+ { value: 6, label: 'Sun' },
+ { value: 0, label: 'Mon' },
+ { value: 1, label: 'Tue' },
+ { value: 2, label: 'Wed' },
+ { value: 3, label: 'Thu' },
+ { value: 4, label: 'Fri' },
+ { value: 5, label: 'Sat' },
+];
+
+export const useDateTimeFormat = () => {
+ const [timeFormatSetting] = useLocalStorage('time-format', '12h');
+ const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
+ // Use user preference for time format
+ const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
+ const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
+
+ return [timeFormat, dateFormat];
+};
+
+export const toTimeString = (value) => {
+ if (!value) return '00:00';
+ if (typeof value === 'string') {
+ const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true);
+ if (parsed.isValid()) return parsed.format('HH:mm');
+ return value;
+ }
+ const parsed = initializeTime(value);
+ return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
+};
+
+export const parseDate = (value) => {
+ if (!value) return null;
+ const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
+ return parsed.isValid() ? parsed.toDate() : null;
+};
+
+const TIMEZONE_FALLBACKS = [
+ 'UTC',
+ 'America/New_York',
+ 'America/Chicago',
+ 'America/Denver',
+ 'America/Los_Angeles',
+ 'America/Phoenix',
+ 'America/Anchorage',
+ 'Pacific/Honolulu',
+ 'Europe/London',
+ 'Europe/Paris',
+ 'Europe/Berlin',
+ 'Europe/Madrid',
+ 'Europe/Warsaw',
+ 'Europe/Moscow',
+ 'Asia/Dubai',
+ 'Asia/Kolkata',
+ 'Asia/Shanghai',
+ 'Asia/Tokyo',
+ 'Asia/Seoul',
+ 'Australia/Sydney',
+];
+
+const getSupportedTimeZones = () => {
+ try {
+ if (typeof Intl.supportedValuesOf === 'function') {
+ return Intl.supportedValuesOf('timeZone');
+ }
+ } catch (error) {
+ console.warn('Unable to enumerate supported time zones:', error);
+ }
+ return TIMEZONE_FALLBACKS;
+};
+
+const getTimeZoneOffsetMinutes = (date, timeZone) => {
+ try {
+ const dtf = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ });
+ const parts = dtf.formatToParts(date).reduce((acc, part) => {
+ if (part.type !== 'literal') acc[part.type] = part.value;
+ return acc;
+ }, {});
+ const asUTC = Date.UTC(
+ Number(parts.year),
+ Number(parts.month) - 1,
+ Number(parts.day),
+ Number(parts.hour),
+ Number(parts.minute),
+ Number(parts.second)
+ );
+ return (asUTC - date.getTime()) / 60000;
+ } catch (error) {
+ console.warn(`Failed to compute offset for ${timeZone}:`, error);
+ return 0;
+ }
+};
+
+const formatOffset = (minutes) => {
+ const rounded = Math.round(minutes);
+ const sign = rounded < 0 ? '-' : '+';
+ const absolute = Math.abs(rounded);
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
+ const mins = String(absolute % 60).padStart(2, '0');
+ return `UTC${sign}${hours}:${mins}`;
+};
+
+export const buildTimeZoneOptions = (preferredZone) => {
+ const zones = getSupportedTimeZones();
+ const referenceYear = new Date().getUTCFullYear();
+ const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
+ const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
+
+ const options = zones
+ .map((zone) => {
+ const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
+ const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
+ const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
+ const minOffset = Math.min(janOffset, julOffset);
+ const maxOffset = Math.max(janOffset, julOffset);
+ const usesDst = minOffset !== maxOffset;
+ const labelParts = [`now ${formatOffset(currentOffset)}`];
+ if (usesDst) {
+ labelParts.push(
+ `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
+ );
+ }
+ return {
+ value: zone,
+ label: `${zone} (${labelParts.join(' | ')})`,
+ numericOffset: minOffset,
+ };
+ })
+ .sort((a, b) => {
+ if (a.numericOffset !== b.numericOffset) {
+ return a.numericOffset - b.numericOffset;
+ }
+ return a.value.localeCompare(b.value);
+ });
+ if (
+ preferredZone &&
+ !options.some((option) => option.value === preferredZone)
+ ) {
+ const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
+ options.push({
+ value: preferredZone,
+ label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
+ numericOffset: currentOffset,
+ });
+ options.sort((a, b) => {
+ if (a.numericOffset !== b.numericOffset) {
+ return a.numericOffset - b.numericOffset;
+ }
+ return a.value.localeCompare(b.value);
+ });
+ }
+ return options;
+};
+
+export const getDefaultTimeZone = () => {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ } catch (error) {
+ return 'UTC';
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
new file mode 100644
index 00000000..805bc006
--- /dev/null
+++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
@@ -0,0 +1,87 @@
+export const getStatRows = (stats) => {
+ return [
+ ['Video Codec', stats.video_codec],
+ [
+ 'Resolution',
+ stats.resolution ||
+ (stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
+ ],
+ ['FPS', stats.source_fps],
+ ['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
+ ['Audio Codec', stats.audio_codec],
+ ['Audio Channels', stats.audio_channels],
+ ['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
+ ['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
+ ].filter(([, v]) => v !== null && v !== undefined && v !== '');
+};
+
+export const getRating = (customProps, program) => {
+ return (
+ customProps.rating ||
+ customProps.rating_value ||
+ (program && program.custom_properties && program.custom_properties.rating)
+ );
+};
+
+const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
+ return arr.filter((r) => {
+ const cp = r.custom_properties || {};
+ const pr = cp.program || {};
+
+ if ((pr.tvg_id || '') !== tvid) return false;
+ if ((pr.title || '').toLowerCase() !== titleKey) return false;
+ const st = toUserTime(r.start_time);
+ return st.isAfter(userNow());
+ });
+}
+
+const dedupeByProgram = (filtered) => {
+ // Deduplicate by program.id if present, else by time+title
+ const seen = new Set();
+ const deduped = [];
+
+ for (const r of filtered) {
+ const cp = r.custom_properties || {};
+ const pr = cp.program || {};
+ // Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
+ const season = cp.season ?? pr?.custom_properties?.season;
+ const episode = cp.episode ?? pr?.custom_properties?.episode;
+ const onscreen =
+ cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
+
+ let key = null;
+ if (season != null && episode != null) key = `se:${season}:${episode}`;
+ else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
+ else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
+ else if (pr.id != null) key = `id:${pr.id}`;
+ else
+ key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
+
+ if (seen.has(key)) continue;
+ seen.add(key);
+ deduped.push(r);
+ }
+ return deduped;
+}
+
+export const getUpcomingEpisodes = (
+ isSeriesGroup,
+ allRecordings,
+ program,
+ toUserTime,
+ userNow
+) => {
+ if (!isSeriesGroup) return [];
+
+ const arr = Array.isArray(allRecordings)
+ ? allRecordings
+ : Object.values(allRecordings || {});
+ const tvid = program.tvg_id || '';
+ const titleKey = (program.title || '').toLowerCase();
+
+ const filtered = filterByUpcoming(arr, tvid, titleKey, toUserTime, userNow);
+
+ return dedupeByProgram(filtered).sort(
+ (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
+ );
+};
diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js
new file mode 100644
index 00000000..1eb9194a
--- /dev/null
+++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js
@@ -0,0 +1,66 @@
+import API from '../../api.js';
+import { toTimeString } from '../dateTimeUtils.js';
+import dayjs from 'dayjs';
+
+export const getChannelOptions = (channels) => {
+ return Object.values(channels || {})
+ .sort((a, b) => {
+ const aNum = Number(a.channel_number) || 0;
+ const bNum = Number(b.channel_number) || 0;
+ if (aNum === bNum) {
+ return (a.name || '').localeCompare(b.name || '');
+ }
+ return aNum - bNum;
+ })
+ .map((item) => ({
+ value: `${item.id}`,
+ label: item.name || `Channel ${item.id}`,
+ }));
+};
+
+export const getUpcomingOccurrences = (
+ recordings,
+ userNow,
+ ruleId,
+ toUserTime
+) => {
+ const list = Array.isArray(recordings)
+ ? recordings
+ : Object.values(recordings || {});
+ const now = userNow();
+ return list
+ .filter(
+ (rec) =>
+ rec?.custom_properties?.rule?.id === ruleId &&
+ toUserTime(rec.start_time).isAfter(now)
+ )
+ .sort(
+ (a, b) =>
+ toUserTime(a.start_time).valueOf() - toUserTime(b.start_time).valueOf()
+ );
+};
+
+export const updateRecurringRule = async (ruleId, values) => {
+ await API.updateRecurringRule(ruleId, {
+ channel: values.channel_id,
+ days_of_week: (values.days_of_week || []).map((d) => Number(d)),
+ start_time: toTimeString(values.start_time),
+ end_time: toTimeString(values.end_time),
+ start_date: values.start_date
+ ? dayjs(values.start_date).format('YYYY-MM-DD')
+ : null,
+ end_date: values.end_date
+ ? dayjs(values.end_date).format('YYYY-MM-DD')
+ : null,
+ name: values.rule_name?.trim() || '',
+ enabled: Boolean(values.enabled),
+ });
+};
+
+export const deleteRecurringRuleById = async (ruleId) => {
+ await API.deleteRecurringRule(ruleId);
+};
+
+export const updateRecurringRuleEnabled = async (ruleId, checked) => {
+ await API.updateRecurringRule(ruleId, { enabled: checked });
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
new file mode 100644
index 00000000..af85dce4
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
@@ -0,0 +1,633 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils';
+import dayjs from 'dayjs';
+
+describe('RecordingDetailsModalUtils', () => {
+ describe('getStatRows', () => {
+ it('should return all stats when all values are present', () => {
+ const stats = {
+ video_codec: 'H.264',
+ resolution: '1920x1080',
+ width: 1920,
+ height: 1080,
+ source_fps: 30,
+ video_bitrate: 5000,
+ audio_codec: 'AAC',
+ audio_channels: 2,
+ sample_rate: 48000,
+ audio_bitrate: 128
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['Resolution', '1920x1080'],
+ ['FPS', 30],
+ ['Video Bitrate', '5000 kb/s'],
+ ['Audio Codec', 'AAC'],
+ ['Audio Channels', 2],
+ ['Sample Rate', '48000 Hz'],
+ ['Audio Bitrate', '128 kb/s']
+ ]);
+ });
+
+ it('should use width x height when resolution is not present', () => {
+ const stats = {
+ width: 1280,
+ height: 720
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Resolution', '1280x720']
+ ]);
+ });
+
+ it('should prefer resolution over width/height', () => {
+ const stats = {
+ resolution: '1920x1080',
+ width: 1280,
+ height: 720
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Resolution', '1920x1080']
+ ]);
+ });
+
+ it('should filter out null values', () => {
+ const stats = {
+ video_codec: 'H.264',
+ resolution: null,
+ source_fps: 30
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['FPS', 30]
+ ]);
+ });
+
+ it('should filter out undefined values', () => {
+ const stats = {
+ video_codec: 'H.264',
+ source_fps: undefined,
+ audio_codec: 'AAC'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['Audio Codec', 'AAC']
+ ]);
+ });
+
+ it('should filter out empty strings', () => {
+ const stats = {
+ video_codec: '',
+ audio_codec: 'AAC'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Audio Codec', 'AAC']
+ ]);
+ });
+
+ it('should handle missing width or height gracefully', () => {
+ const stats = {
+ width: 1920,
+ video_codec: 'H.264'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264']
+ ]);
+ });
+
+ it('should format bitrates correctly', () => {
+ const stats = {
+ video_bitrate: 2500,
+ audio_bitrate: 192
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Bitrate', '2500 kb/s'],
+ ['Audio Bitrate', '192 kb/s']
+ ]);
+ });
+
+ it('should format sample rate correctly', () => {
+ const stats = {
+ sample_rate: 44100
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Sample Rate', '44100 Hz']
+ ]);
+ });
+
+ it('should return empty array when no valid stats', () => {
+ const stats = {
+ video_codec: null,
+ resolution: undefined,
+ source_fps: ''
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle empty stats object', () => {
+ const stats = {};
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getRating', () => {
+ it('should return rating from customProps', () => {
+ const customProps = { rating: 'TV-MA' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should return rating_value when rating is not present', () => {
+ const customProps = { rating_value: 'PG-13' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('PG-13');
+ });
+
+ it('should prefer rating over rating_value', () => {
+ const customProps = { rating: 'TV-MA', rating_value: 'PG-13' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should return rating from program custom_properties', () => {
+ const customProps = {};
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-14');
+ });
+
+ it('should prefer customProps rating over program rating', () => {
+ const customProps = { rating: 'TV-MA' };
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should prefer rating_value over program rating', () => {
+ const customProps = { rating_value: 'PG-13' };
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('PG-13');
+ });
+
+ it('should return undefined when no rating is available', () => {
+ const customProps = {};
+ const program = { custom_properties: {} };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should handle null program', () => {
+ const customProps = {};
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeNull();
+ });
+
+ it('should handle program without custom_properties', () => {
+ const customProps = {};
+ const program = { title: 'Test' };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getUpcomingEpisodes', () => {
+ let toUserTime;
+ let userNow;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ userNow = vi.fn(() => baseTime);
+ });
+
+ it('should return empty array when not a series group', () => {
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ false,
+ [],
+ {},
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array when allRecordings is empty', () => {
+ const program = { tvg_id: 'test', title: 'Test Show' };
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ [],
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should filter recordings by tvg_id and title', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show2', title: 'Other Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].custom_properties.program.tvg_id).toBe('show1');
+ });
+
+ it('should filter out past recordings', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2023-12-31T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ });
+
+ it('should deduplicate by season and episode', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ season: 1,
+ episode: 5,
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ season: 1,
+ episode: 5,
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by onscreen episode', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ onscreen_episode: 'S01E05',
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ onscreen_episode: 's01e05',
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by program sub_title', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ sub_title: 'The Beginning'
+ }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ sub_title: 'The Beginning'
+ }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by program id', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should sort by start time ascending', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-03T12:00:00',
+ end_time: '2024-01-03T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 3 }
+ }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ end_time: '2024-01-02T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
+ }
+ },
+ {
+ start_time: '2024-01-04T12:00:00',
+ end_time: '2024-01-04T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 4 }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ expect(result[1].start_time).toBe('2024-01-03T12:00:00');
+ expect(result[2].start_time).toBe('2024-01-04T12:00:00');
+ });
+
+ it('should handle allRecordings as object', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = {
+ rec1: {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
+ }
+ }
+ };
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should handle case-insensitive title matching', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'test show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should prefer season/episode from program custom_properties', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ custom_properties: { season: 2, episode: 3 }
+ }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ custom_properties: { season: 2, episode: 3 }
+ }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should handle missing custom_properties', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1'
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
new file mode 100644
index 00000000..e2cb95fd
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
@@ -0,0 +1,533 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as RecurringRuleModalUtils from '../RecurringRuleModalUtils';
+import API from '../../../api.js';
+import dayjs from 'dayjs';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateRecurringRule: vi.fn(),
+ deleteRecurringRule: vi.fn()
+ }
+}));
+
+describe('RecurringRuleModalUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getChannelOptions', () => {
+ it('should return sorted channel options by channel number', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10', name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' },
+ ch3: { id: 3, channel_number: '15', name: 'CBS' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'NBC' },
+ { value: '1', label: 'ABC' },
+ { value: '3', label: 'CBS' }
+ ]);
+ });
+
+ it('should sort alphabetically by name when channel numbers are equal', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10', name: 'ZBC' },
+ ch2: { id: 2, channel_number: '10', name: 'ABC' },
+ ch3: { id: 3, channel_number: '10', name: 'MBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'ABC' },
+ { value: '3', label: 'MBC' },
+ { value: '1', label: 'ZBC' }
+ ]);
+ });
+
+ it('should handle missing channel numbers', () => {
+ const channels = {
+ ch1: { id: 1, name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '1', label: 'ABC' },
+ { value: '2', label: 'NBC' }
+ ]);
+ });
+
+ it('should use fallback label when name is missing', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10' },
+ ch2: { id: 2, channel_number: '5', name: '' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'Channel 2' },
+ { value: '1', label: 'Channel 1' }
+ ]);
+ });
+
+ it('should handle empty channels object', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions({});
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null channels', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions(null);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle undefined channels', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions(undefined);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should convert channel id to string value', () => {
+ const channels = {
+ ch1: { id: 123, channel_number: '10', name: 'ABC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result[0].value).toBe('123');
+ expect(typeof result[0].value).toBe('string');
+ });
+
+ it('should handle non-numeric channel numbers', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ describe('getUpcomingOccurrences', () => {
+ let toUserTime;
+ let userNow;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ userNow = vi.fn(() => baseTime);
+ });
+
+ it('should filter recordings by rule id and future start time', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-04T12:00:00',
+ custom_properties: { rule: { id: 2 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].custom_properties.rule.id).toBe(1);
+ expect(result[1].custom_properties.rule.id).toBe(1);
+ });
+
+ it('should exclude past recordings', () => {
+ const recordings = [
+ {
+ start_time: '2023-12-31T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ });
+
+ it('should sort by start time ascending', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-04T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ expect(result[1].start_time).toBe('2024-01-03T12:00:00');
+ expect(result[2].start_time).toBe('2024-01-04T12:00:00');
+ });
+
+ it('should handle recordings as object', () => {
+ const recordings = {
+ rec1: {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ rec2: {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ };
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(2);
+ });
+
+ it('should handle empty recordings array', () => {
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ [],
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null recordings', () => {
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ null,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings without custom_properties', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00'
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings without rule', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: {}
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings with null rule', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: null }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('updateRecurringRule', () => {
+ it('should call API with formatted values', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: ['1', '3', '5'],
+ start_time: '14:30',
+ end_time: '16:00',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31',
+ rule_name: 'My Rule',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [1, 3, 5],
+ start_time: '14:30',
+ end_time: '16:00',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31',
+ name: 'My Rule',
+ enabled: true
+ });
+ });
+
+ it('should convert days_of_week to numbers', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: ['0', '6'],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: false
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [0, 6],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: false
+ });
+ });
+
+ it('should handle empty days_of_week', async () => {
+ const values = {
+ channel_id: '5',
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should format dates correctly', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: dayjs('2024-06-15'),
+ end_date: dayjs('2024-12-25'),
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: '2024-06-15',
+ end_date: '2024-12-25',
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should handle null dates', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should trim rule name', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ rule_name: ' Trimmed Name ',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: 'Trimmed Name',
+ enabled: true
+ });
+ });
+
+ it('should handle missing rule_name', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should convert enabled to boolean', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: 'true'
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+ });
+
+ describe('deleteRecurringRuleById', () => {
+ it('should call API deleteRecurringRule with rule id', async () => {
+ await RecurringRuleModalUtils.deleteRecurringRuleById(123);
+
+ expect(API.deleteRecurringRule).toHaveBeenCalledWith(123);
+ expect(API.deleteRecurringRule).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle string rule id', async () => {
+ await RecurringRuleModalUtils.deleteRecurringRuleById('456');
+
+ expect(API.deleteRecurringRule).toHaveBeenCalledWith('456');
+ });
+ });
+
+ describe('updateRecurringRuleEnabled', () => {
+ it('should call API updateRecurringRule with enabled true', async () => {
+ await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ enabled: true
+ });
+ });
+
+ it('should call API updateRecurringRule with enabled false', async () => {
+ await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ enabled: false
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
new file mode 100644
index 00000000..bbb1085a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
@@ -0,0 +1,22 @@
+import API from '../../../api.js';
+
+export const getComskipConfig = async () => {
+ return await API.getComskipConfig();
+};
+
+export const uploadComskipIni = async (file) => {
+ return await API.uploadComskipIni(file);
+};
+
+export const getDvrSettingsFormInitialValues = () => {
+ return {
+ 'tv_template': '',
+ 'movie_template': '',
+ 'tv_fallback_template': '',
+ 'movie_fallback_template': '',
+ 'comskip_enabled': false,
+ 'comskip_custom_path': '',
+ 'pre_offset_minutes': 0,
+ 'post_offset_minutes': 0,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
new file mode 100644
index 00000000..fe1eea8a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
@@ -0,0 +1,29 @@
+import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
+import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
+
+export const getNetworkAccessFormInitialValues = () => {
+ return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = '0.0.0.0/0,::/0';
+ return acc;
+ }, {});
+};
+
+export const getNetworkAccessFormValidation = () => {
+ return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = (value) => {
+ if (
+ value
+ .split(',')
+ .some(
+ (cidr) =>
+ !(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX))
+ )
+ ) {
+ return 'Invalid CIDR range';
+ }
+
+ return null;
+ };
+ return acc;
+ }, {});
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
new file mode 100644
index 00000000..864dd9b1
--- /dev/null
+++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
@@ -0,0 +1,18 @@
+import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js';
+
+export const getProxySettingsFormInitialValues = () => {
+ return Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => {
+ acc[key] = '';
+ return acc;
+ }, {});
+};
+
+export const getProxySettingDefaults = () => {
+ return {
+ buffering_timeout: 15,
+ buffering_speed: 1.0,
+ redis_chunk_ttl: 60,
+ channel_shutdown_delay: 0,
+ channel_init_grace_period: 5,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
new file mode 100644
index 00000000..db91480c
--- /dev/null
+++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
@@ -0,0 +1,19 @@
+import { isNotEmpty } from '@mantine/form';
+
+export const getStreamSettingsFormInitialValues = () => {
+ return {
+ default_user_agent: '',
+ default_stream_profile: '',
+ preferred_region: '',
+ auto_import_mapped_files: true,
+ m3u_hash_key: [],
+ };
+};
+
+export const getStreamSettingsFormValidation = () => {
+ return {
+ default_user_agent: isNotEmpty('Select a user agent'),
+ default_stream_profile: isNotEmpty('Select a stream profile'),
+ preferred_region: isNotEmpty('Select a region'),
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js
new file mode 100644
index 00000000..2d67fb75
--- /dev/null
+++ b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js
@@ -0,0 +1,5 @@
+export const getSystemSettingsFormInitialValues = () => {
+ return {
+ max_system_events: 100,
+ };
+};
diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
new file mode 100644
index 00000000..9d67039e
--- /dev/null
+++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
@@ -0,0 +1,17 @@
+import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
+
+export const saveTimeZoneSetting = async (tzValue, settings) => {
+ const existing = settings['system_settings'];
+ const currentValue = existing?.value || {};
+ const newValue = { ...currentValue, time_zone: tzValue };
+
+ if (existing?.id) {
+ await updateSetting({ ...existing, value: newValue });
+ } else {
+ await createSetting({
+ key: 'system_settings',
+ name: 'System Settings',
+ value: newValue,
+ });
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
new file mode 100644
index 00000000..49a43eb1
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as DvrSettingsFormUtils from '../DvrSettingsFormUtils';
+import API from '../../../../api.js';
+
+vi.mock('../../../../api.js');
+
+describe('DvrSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getComskipConfig', () => {
+ it('should call API.getComskipConfig and return result', async () => {
+ const mockConfig = {
+ enabled: true,
+ custom_path: '/path/to/comskip'
+ };
+ API.getComskipConfig.mockResolvedValue(mockConfig);
+
+ const result = await DvrSettingsFormUtils.getComskipConfig();
+
+ expect(API.getComskipConfig).toHaveBeenCalledWith();
+ expect(result).toEqual(mockConfig);
+ });
+
+ it('should handle API errors', async () => {
+ const error = new Error('API Error');
+ API.getComskipConfig.mockRejectedValue(error);
+
+ await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API Error');
+ });
+ });
+
+ describe('uploadComskipIni', () => {
+ it('should call API.uploadComskipIni with file and return result', async () => {
+ const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const mockResponse = { success: true };
+ API.uploadComskipIni.mockResolvedValue(mockResponse);
+
+ const result = await DvrSettingsFormUtils.uploadComskipIni(mockFile);
+
+ expect(API.uploadComskipIni).toHaveBeenCalledWith(mockFile);
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle API errors', async () => {
+ const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const error = new Error('Upload failed');
+ API.uploadComskipIni.mockRejectedValue(error);
+
+ await expect(DvrSettingsFormUtils.uploadComskipIni(mockFile)).rejects.toThrow('Upload failed');
+ });
+ });
+
+ describe('getDvrSettingsFormInitialValues', () => {
+ it('should return initial values with all DVR settings', () => {
+ const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'tv_template': '',
+ 'movie_template': '',
+ 'tv_fallback_template': '',
+ 'movie_fallback_template': '',
+ 'comskip_enabled': false,
+ 'comskip_custom_path': '',
+ 'pre_offset_minutes': 0,
+ 'post_offset_minutes': 0,
+ });
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+ const result2 = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have correct default types', () => {
+ const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(typeof result['tv_template']).toBe('string');
+ expect(typeof result['movie_template']).toBe('string');
+ expect(typeof result['tv_fallback_template']).toBe('string');
+ expect(typeof result['movie_fallback_template']).toBe('string');
+ expect(typeof result['comskip_enabled']).toBe('boolean');
+ expect(typeof result['comskip_custom_path']).toBe('string');
+ expect(typeof result['pre_offset_minutes']).toBe('number');
+ expect(typeof result['post_offset_minutes']).toBe('number');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
new file mode 100644
index 00000000..d924b430
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils';
+import * as constants from '../../../../constants.js';
+
+vi.mock('../../../../constants.js', () => ({
+ NETWORK_ACCESS_OPTIONS: {}
+}));
+
+vi.mock('../../../networkUtils.js', () => ({
+ IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
+ IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/
+}));
+
+describe('NetworkAccessFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getNetworkAccessFormInitialValues', () => {
+ it('should return initial values for all network access options', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access',
+ 'network-access-api': 'API Access',
+ 'network-access-streaming': 'Streaming Access'
+ };
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result).toEqual({
+ 'network-access-admin': '0.0.0.0/0,::/0',
+ 'network-access-api': '0.0.0.0/0,::/0',
+ 'network-access-streaming': '0.0.0.0/0,::/0'
+ });
+ });
+
+ it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result).toEqual({});
+ });
+
+ it('should return a new object each time', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access'
+ };
+
+ const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+ const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ describe('getNetworkAccessFormValidation', () => {
+ beforeEach(() => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access',
+ 'network-access-api': 'API Access'
+ };
+ });
+
+ it('should return validation functions for all network access options', () => {
+ const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+
+ expect(Object.keys(result)).toEqual(['network-access-admin', 'network-access-api']);
+ expect(typeof result['network-access-admin']).toBe('function');
+ expect(typeof result['network-access-api']).toBe('function');
+ });
+
+ it('should validate valid IPv4 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24')).toBeNull();
+ expect(validator('10.0.0.0/8')).toBeNull();
+ expect(validator('0.0.0.0/0')).toBeNull();
+ });
+
+ it('should validate valid IPv6 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('2001:db8::/32')).toBeNull();
+ expect(validator('::/0')).toBeNull();
+ });
+
+ it('should validate multiple CIDR ranges separated by commas', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
+ expect(validator('0.0.0.0/0,::/0')).toBeNull();
+ expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull();
+ });
+
+ it('should return error for invalid IPv4 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
+ expect(validator('invalid')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range');
+ });
+
+ it('should return error when any CIDR in comma-separated list is invalid', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
+ expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe('Invalid CIDR range');
+ });
+
+ it('should handle empty strings', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('')).toBe('Invalid CIDR range');
+ });
+
+ it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+
+ expect(result).toEqual({});
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
new file mode 100644
index 00000000..d6fe3008
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as ProxySettingsFormUtils from '../ProxySettingsFormUtils';
+import * as constants from '../../../../constants.js';
+
+vi.mock('../../../../constants.js', () => ({
+ PROXY_SETTINGS_OPTIONS: {}
+}));
+
+describe('ProxySettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getProxySettingsFormInitialValues', () => {
+ it('should return initial values for all proxy settings options', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
+ 'proxy-buffering-timeout': 'Buffering Timeout',
+ 'proxy-buffering-speed': 'Buffering Speed',
+ 'proxy-redis-chunk-ttl': 'Redis Chunk TTL'
+ };
+
+ const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'proxy-buffering-timeout': '',
+ 'proxy-buffering-speed': '',
+ 'proxy-redis-chunk-ttl': ''
+ });
+ });
+
+ it('should return empty object when PROXY_SETTINGS_OPTIONS is empty', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {};
+
+ const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result).toEqual({});
+ });
+
+ it('should return a new object each time', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
+ 'proxy-setting': 'Proxy Setting'
+ };
+
+ const result1 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+ const result2 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ describe('getProxySettingDefaults', () => {
+ it('should return default proxy settings', () => {
+ const result = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(result).toEqual({
+ buffering_timeout: 15,
+ buffering_speed: 1.0,
+ redis_chunk_ttl: 60,
+ channel_shutdown_delay: 0,
+ channel_init_grace_period: 5,
+ });
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = ProxySettingsFormUtils.getProxySettingDefaults();
+ const result2 = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have correct default types', () => {
+ const result = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(typeof result.buffering_timeout).toBe('number');
+ expect(typeof result.buffering_speed).toBe('number');
+ expect(typeof result.redis_chunk_ttl).toBe('number');
+ expect(typeof result.channel_shutdown_delay).toBe('number');
+ expect(typeof result.channel_init_grace_period).toBe('number');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
new file mode 100644
index 00000000..9cf87c9a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamSettingsFormUtils from '../StreamSettingsFormUtils';
+import { isNotEmpty } from '@mantine/form';
+
+vi.mock('@mantine/form', () => ({
+ isNotEmpty: vi.fn((message) => message)
+}));
+
+describe('StreamSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getStreamSettingsFormInitialValues', () => {
+ it('should return initial values with correct defaults', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'default_user_agent': '',
+ 'default_stream_profile': '',
+ 'preferred_region': '',
+ 'auto_import_mapped_files': true,
+ 'm3u_hash_key': []
+ });
+ });
+
+ it('should return boolean true for auto-import-mapped-files', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result['auto_import_mapped_files']).toBe(true);
+ expect(typeof result['auto_import_mapped_files']).toBe('boolean');
+ });
+
+ it('should return empty array for m3u-hash-key', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result['m3u_hash_key']).toEqual([]);
+ expect(Array.isArray(result['m3u_hash_key'])).toBe(true);
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should return a new array instance for m3u-hash-key each time', () => {
+ const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']);
+ });
+ });
+
+ describe('getStreamSettingsFormValidation', () => {
+ it('should return validation functions for required fields', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(Object.keys(result)).toEqual([
+ 'default_user_agent',
+ 'default_stream_profile',
+ 'preferred_region'
+ ]);
+ });
+
+ it('should use isNotEmpty validator for default_user_agent', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a user agent');
+ });
+
+ it('should use isNotEmpty validator for default_stream_profile', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
+ });
+
+ it('should use isNotEmpty validator for preferred_region', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
+ });
+
+ it('should not include validation for auto-import-mapped-files', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result).not.toHaveProperty('auto_import_mapped_files');
+ });
+
+ it('should not include validation for m3u-hash-key', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result).not.toHaveProperty('m3u_hash_key');
+ });
+
+ it('should return correct validation error messages', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result['default_user_agent']).toBe('Select a user agent');
+ expect(result['default_stream_profile']).toBe('Select a stream profile');
+ expect(result['preferred_region']).toBe('Select a region');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
new file mode 100644
index 00000000..1bed3529
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import * as SystemSettingsFormUtils from '../SystemSettingsFormUtils';
+
+describe('SystemSettingsFormUtils', () => {
+ describe('getSystemSettingsFormInitialValues', () => {
+ it('should return initial values with correct defaults', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'max_system_events': 100
+ });
+ });
+
+ it('should return number value for max-system-events', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result['max_system_events']).toBe(100);
+ expect(typeof result['max_system_events']).toBe('number');
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result2 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have max-system-events property', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result).toHaveProperty('max_system_events');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
new file mode 100644
index 00000000..c5471edc
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
@@ -0,0 +1,147 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as UiSettingsFormUtils from '../UiSettingsFormUtils';
+import * as SettingsUtils from '../../../pages/SettingsUtils.js';
+
+vi.mock('../../../pages/SettingsUtils.js', () => ({
+ createSetting: vi.fn(),
+ updateSetting: vi.fn()
+}));
+
+describe('UiSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('saveTimeZoneSetting', () => {
+ it('should update existing setting when id is present', async () => {
+ const tzValue = 'America/New_York';
+ const settings = {
+ 'system-time-zone': {
+ id: 123,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 123,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'America/New_York'
+ });
+ expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when existing setting has no id', async () => {
+ const tzValue = 'Europe/London';
+ const settings = {
+ 'system-time-zone': {
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Europe/London'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when system-time-zone does not exist', async () => {
+ const tzValue = 'Asia/Tokyo';
+ const settings = {};
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Asia/Tokyo'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when system-time-zone is null', async () => {
+ const tzValue = 'Pacific/Auckland';
+ const settings = {
+ 'system-time-zone': null
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Pacific/Auckland'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when id is undefined', async () => {
+ const tzValue = 'America/Los_Angeles';
+ const settings = {
+ 'system-time-zone': {
+ id: undefined,
+ key: 'system-time-zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should preserve existing properties when updating', async () => {
+ const tzValue = 'UTC';
+ const settings = {
+ 'system-time-zone': {
+ id: 456,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'America/New_York',
+ extraProp: 'should be preserved'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 456,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC',
+ extraProp: 'should be preserved'
+ });
+ });
+
+ it('should handle empty string timezone value', async () => {
+ const tzValue = '';
+ const settings = {
+ 'system-time-zone': {
+ id: 789
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 789,
+ value: ''
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js
new file mode 100644
index 00000000..8efd2254
--- /dev/null
+++ b/frontend/src/utils/networkUtils.js
@@ -0,0 +1,24 @@
+// IPv4 CIDR regex - validates IP address and prefix length (0-32)
+export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;
+
+// IPv6 CIDR regex - validates IPv6 address and prefix length (0-128)
+export const IPV6_CIDR_REGEX =
+ /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
+
+export function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
+}
+
+export function formatSpeed(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
+}
\ No newline at end of file
diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js
new file mode 100644
index 00000000..ba965343
--- /dev/null
+++ b/frontend/src/utils/notificationUtils.js
@@ -0,0 +1,9 @@
+import { notifications } from '@mantine/notifications';
+
+export function showNotification(notificationObject) {
+ return notifications.show(notificationObject);
+}
+
+export function updateNotification(notificationId, notificationObject) {
+ return notifications.update(notificationId, notificationObject);
+}
\ No newline at end of file
diff --git a/frontend/src/utils/pages/DVRUtils.js b/frontend/src/utils/pages/DVRUtils.js
new file mode 100644
index 00000000..139988d2
--- /dev/null
+++ b/frontend/src/utils/pages/DVRUtils.js
@@ -0,0 +1,90 @@
+// Deduplicate in-progress and upcoming by program id or channel+slot
+const dedupeByProgramOrSlot = (arr) => {
+ const out = [];
+ const sigs = new Set();
+
+ for (const r of arr) {
+ const cp = r.custom_properties || {};
+ const pr = cp.program || {};
+ const sig =
+ pr?.id != null
+ ? `id:${pr.id}`
+ : `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
+
+ if (sigs.has(sig)) continue;
+ sigs.add(sig);
+ out.push(r);
+ }
+ return out;
+};
+
+const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
+ // ID-based dedupe guard in case store returns duplicates
+ const seenIds = new Set();
+ for (const rec of list) {
+ if (rec && rec.id != null) {
+ const k = String(rec.id);
+ if (seenIds.has(k)) continue;
+ seenIds.add(k);
+ }
+
+ const s = toUserTime(rec.start_time);
+ const e = toUserTime(rec.end_time);
+ const status = rec.custom_properties?.status;
+
+ if (status === 'interrupted' || status === 'completed') {
+ completed.push(rec);
+ } else {
+ if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
+ else if (now.isBefore(s)) upcoming.push(rec);
+ else completed.push(rec);
+ }
+ }
+}
+
+export const categorizeRecordings = (recordings, toUserTime, now) => {
+ const inProgress = [];
+ const upcoming = [];
+ const completed = [];
+ const list = Array.isArray(recordings)
+ ? recordings
+ : Object.values(recordings || {});
+
+ dedupeById(list, toUserTime, completed, now, inProgress, upcoming);
+
+ const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort(
+ (a, b) => toUserTime(b.start_time) - toUserTime(a.start_time)
+ );
+
+ // Group upcoming by series title+tvg_id (keep only next episode)
+ const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort(
+ (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
+ );
+ const grouped = new Map();
+
+ for (const rec of upcomingDedup) {
+ const cp = rec.custom_properties || {};
+ const prog = cp.program || {};
+ const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
+ if (!grouped.has(key)) {
+ grouped.set(key, { rec, count: 1 });
+ } else {
+ const entry = grouped.get(key);
+ entry.count += 1;
+ }
+ }
+
+ const upcomingGrouped = Array.from(grouped.values()).map((e) => {
+ const item = { ...e.rec };
+ item._group_count = e.count;
+ return item;
+ });
+
+ completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time));
+
+ return {
+ inProgress: inProgressDedup,
+ upcoming: upcomingGrouped,
+ completed,
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js
new file mode 100644
index 00000000..bae98e93
--- /dev/null
+++ b/frontend/src/utils/pages/PluginsUtils.js
@@ -0,0 +1,17 @@
+import API from '../../api.js';
+
+export const updatePluginSettings = async (key, settings) => {
+ return await API.updatePluginSettings(key, settings);
+};
+export const runPluginAction = async (key, actionId) => {
+ return await API.runPluginAction(key, actionId);
+};
+export const setPluginEnabled = async (key, next) => {
+ return await API.setPluginEnabled(key, next);
+};
+export const importPlugin = async (importFile) => {
+ return await API.importPlugin(importFile);
+};
+export const deletePluginByKey = (key) => {
+ return API.deletePlugin(key);
+};
\ No newline at end of file
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
new file mode 100644
index 00000000..6ee12f60
--- /dev/null
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -0,0 +1,218 @@
+import API from '../../api.js';
+
+export const checkSetting = async (values) => {
+ return await API.checkSetting(values);
+};
+
+export const updateSetting = async (values) => {
+ return await API.updateSetting(values);
+};
+
+export const createSetting = async (values) => {
+ return await API.createSetting(values);
+};
+
+export const rehashStreams = async () => {
+ return await API.rehashStreams();
+};
+
+export const saveChangedSettings = async (settings, changedSettings) => {
+ // Group changes by their setting group based on field name prefixes
+ const groupedChanges = {
+ stream_settings: {},
+ dvr_settings: {},
+ backup_settings: {},
+ system_settings: {},
+ };
+
+ // Map of field prefixes to their groups
+ const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
+ const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
+ 'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
+ const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
+ 'retention_count', 'schedule_cron_expression'];
+ const systemFields = ['time_zone', 'max_system_events'];
+
+ for (const formKey in changedSettings) {
+ let value = changedSettings[formKey];
+
+ // Handle special grouped settings (proxy_settings and network_access)
+ if (formKey === 'proxy_settings') {
+ const existing = settings['proxy_settings'];
+ if (existing?.id) {
+ await updateSetting({ ...existing, value });
+ } else {
+ await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value });
+ }
+ continue;
+ }
+
+ if (formKey === 'network_access') {
+ const existing = settings['network_access'];
+ if (existing?.id) {
+ await updateSetting({ ...existing, value });
+ } else {
+ await createSetting({ key: 'network_access', name: 'Network Access', value });
+ }
+ continue;
+ }
+
+ // Type conversions for proper storage
+ if (formKey === 'm3u_hash_key' && Array.isArray(value)) {
+ value = value.join(',');
+ }
+
+ if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) {
+ value = parseInt(value, 10);
+ }
+
+ const numericFields = ['pre_offset_minutes', 'post_offset_minutes', 'retention_count', 'schedule_day_of_week', 'max_system_events'];
+ if (numericFields.includes(formKey) && value != null) {
+ value = typeof value === 'number' ? value : parseInt(value, 10);
+ }
+
+ const booleanFields = ['comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files'];
+ if (booleanFields.includes(formKey) && value != null) {
+ value = typeof value === 'boolean' ? value : Boolean(value);
+ }
+
+ // Route to appropriate group
+ if (streamFields.includes(formKey)) {
+ groupedChanges.stream_settings[formKey] = value;
+ } else if (dvrFields.includes(formKey)) {
+ groupedChanges.dvr_settings[formKey] = value;
+ } else if (backupFields.includes(formKey)) {
+ groupedChanges.backup_settings[formKey] = value;
+ } else if (systemFields.includes(formKey)) {
+ groupedChanges.system_settings[formKey] = value;
+ }
+ }
+
+ // Update each group that has changes
+ for (const [groupKey, changes] of Object.entries(groupedChanges)) {
+ if (Object.keys(changes).length === 0) continue;
+
+ const existing = settings[groupKey];
+ const currentValue = existing?.value || {};
+ const newValue = { ...currentValue, ...changes };
+
+ if (existing?.id) {
+ const result = await updateSetting({ ...existing, value: newValue });
+ if (!result) {
+ throw new Error(`Failed to update ${groupKey}`);
+ }
+ } else {
+ const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+ const result = await createSetting({ key: groupKey, name: name, value: newValue });
+ if (!result) {
+ throw new Error(`Failed to create ${groupKey}`);
+ }
+ }
+ }
+};
+
+export const getChangedSettings = (values, settings) => {
+ const changedSettings = {};
+
+ for (const settingKey in values) {
+ // Skip grouped settings that are handled by their own dedicated forms
+ if (settingKey === 'proxy_settings' || settingKey === 'network_access') {
+ continue;
+ }
+
+ // Only compare against existing value if the setting exists
+ const existing = settings[settingKey];
+
+ // Convert array values (like m3u_hash_key) to comma-separated strings for comparison
+ let compareValue;
+ let actualValue = values[settingKey];
+
+ if (Array.isArray(actualValue)) {
+ actualValue = actualValue.join(',');
+ compareValue = actualValue;
+ } else {
+ compareValue = String(actualValue);
+ }
+
+ // Skip empty values to avoid validation errors
+ if (!compareValue) {
+ continue;
+ }
+
+ if (!existing) {
+ // Create new setting on save - preserve original type
+ changedSettings[settingKey] = actualValue;
+ } else if (compareValue !== String(existing.value)) {
+ // If the user changed the setting's value from what's in the DB - preserve original type
+ changedSettings[settingKey] = actualValue;
+ }
+ }
+ return changedSettings;
+};
+
+export const parseSettings = (settings) => {
+ const parsed = {};
+
+ // Stream settings - direct mapping with underscore keys
+ const streamSettings = settings['stream_settings']?.value;
+ if (streamSettings && typeof streamSettings === 'object') {
+ // IDs must be strings for Select components
+ parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null;
+ parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null;
+ parsed.preferred_region = streamSettings.preferred_region;
+ parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
+
+ // m3u_hash_key should be array
+ const hashKey = streamSettings.m3u_hash_key;
+ if (typeof hashKey === 'string') {
+ parsed.m3u_hash_key = hashKey ? hashKey.split(',').filter((v) => v) : [];
+ } else if (Array.isArray(hashKey)) {
+ parsed.m3u_hash_key = hashKey;
+ } else {
+ parsed.m3u_hash_key = [];
+ }
+ }
+
+ // DVR settings - direct mapping with underscore keys
+ const dvrSettings = settings['dvr_settings']?.value;
+ if (dvrSettings && typeof dvrSettings === 'object') {
+ parsed.tv_template = dvrSettings.tv_template;
+ parsed.movie_template = dvrSettings.movie_template;
+ parsed.tv_fallback_dir = dvrSettings.tv_fallback_dir;
+ parsed.tv_fallback_template = dvrSettings.tv_fallback_template;
+ parsed.movie_fallback_template = dvrSettings.movie_fallback_template;
+ parsed.comskip_enabled = typeof dvrSettings.comskip_enabled === 'boolean' ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled);
+ parsed.comskip_custom_path = dvrSettings.comskip_custom_path;
+ parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes : parseInt(dvrSettings.pre_offset_minutes, 10) || 0;
+ parsed.post_offset_minutes = typeof dvrSettings.post_offset_minutes === 'number' ? dvrSettings.post_offset_minutes : parseInt(dvrSettings.post_offset_minutes, 10) || 0;
+ parsed.series_rules = dvrSettings.series_rules;
+ }
+
+ // Backup settings - direct mapping with underscore keys
+ const backupSettings = settings['backup_settings']?.value;
+ if (backupSettings && typeof backupSettings === 'object') {
+ parsed.schedule_enabled = typeof backupSettings.schedule_enabled === 'boolean' ? backupSettings.schedule_enabled : Boolean(backupSettings.schedule_enabled);
+ parsed.schedule_frequency = String(backupSettings.schedule_frequency || '');
+ parsed.schedule_time = String(backupSettings.schedule_time || '');
+ parsed.schedule_day_of_week = typeof backupSettings.schedule_day_of_week === 'number' ? backupSettings.schedule_day_of_week : parseInt(backupSettings.schedule_day_of_week, 10) || 0;
+ parsed.retention_count = typeof backupSettings.retention_count === 'number' ? backupSettings.retention_count : parseInt(backupSettings.retention_count, 10) || 0;
+ parsed.schedule_cron_expression = String(backupSettings.schedule_cron_expression || '');
+ }
+
+ // System settings - direct mapping with underscore keys
+ const systemSettings = settings['system_settings']?.value;
+ if (systemSettings && typeof systemSettings === 'object') {
+ parsed.time_zone = String(systemSettings.time_zone || '');
+ parsed.max_system_events = typeof systemSettings.max_system_events === 'number' ? systemSettings.max_system_events : parseInt(systemSettings.max_system_events, 10) || 100;
+ }
+
+ // Proxy and network access are already grouped objects
+ if (settings['proxy_settings']?.value) {
+ parsed.proxy_settings = settings['proxy_settings'].value;
+ }
+ if (settings['network_access']?.value) {
+ parsed.network_access = settings['network_access'].value;
+ }
+
+ return parsed;
+};
\ No newline at end of file
diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js
new file mode 100644
index 00000000..a25e33f0
--- /dev/null
+++ b/frontend/src/utils/pages/StatsUtils.js
@@ -0,0 +1,133 @@
+import API from '../../api.js';
+
+export const stopChannel = async (id) => {
+ await API.stopChannel(id);
+};
+
+export const stopClient = async (channelId, clientId) => {
+ await API.stopClient(channelId, clientId);
+};
+
+export const stopVODClient = async (clientId) => {
+ await API.stopVODClient(clientId);
+};
+
+export const fetchActiveChannelStats = async () => {
+ return await API.fetchActiveChannelStats();
+};
+
+export const getVODStats = async () => {
+ return await API.getVODStats();
+};
+
+export const getCombinedConnections = (channelHistory, vodConnections) => {
+ const activeStreams = Object.values(channelHistory).map((channel) => ({
+ type: 'stream',
+ data: channel,
+ id: channel.channel_id,
+ sortKey: channel.uptime || 0, // Use uptime for sorting streams
+ }));
+
+ // Flatten VOD connections so each individual client gets its own card
+ const vodItems = vodConnections.flatMap((vodContent) => {
+ return (vodContent.connections || []).map((connection, index) => ({
+ type: 'vod',
+ data: {
+ ...vodContent,
+ // Override the connections array to contain only this specific connection
+ connections: [connection],
+ connection_count: 1, // Each card now represents a single connection
+ // Add individual connection details at the top level for easier access
+ individual_connection: connection,
+ },
+ id: `${vodContent.content_type}-${vodContent.content_uuid}-${connection.client_id}-${index}`,
+ sortKey: connection.connected_at || Date.now() / 1000, // Use connection time for sorting
+ }));
+ });
+
+ // Combine and sort by newest connections first (higher sortKey = more recent)
+ return [...activeStreams, ...vodItems].sort((a, b) => b.sortKey - a.sortKey);
+};
+
+const getChannelWithMetadata = (
+ prevChannelHistory,
+ ch,
+ channelsByUUID,
+ channels,
+ streamProfiles
+) => {
+ let bitrates = [];
+ if (prevChannelHistory[ch.channel_id]) {
+ bitrates = [...(prevChannelHistory[ch.channel_id].bitrates || [])];
+ const bitrate =
+ ch.total_bytes - prevChannelHistory[ch.channel_id].total_bytes;
+ if (bitrate > 0) {
+ bitrates.push(bitrate);
+ }
+
+ if (bitrates.length > 15) {
+ bitrates = bitrates.slice(1);
+ }
+ }
+
+ // Find corresponding channel data
+ const channelData =
+ channelsByUUID && ch.channel_id
+ ? channels[channelsByUUID[ch.channel_id]]
+ : null;
+
+ // Find stream profile
+ const streamProfile = streamProfiles.find(
+ (profile) => profile.id == parseInt(ch.stream_profile)
+ );
+
+ return {
+ ...ch,
+ ...(channelData || {}), // Safely merge channel data if available
+ bitrates,
+ stream_profile: streamProfile || { name: 'Unknown' },
+ // Make sure stream_id is set from the active stream info
+ stream_id: ch.stream_id || null,
+ };
+};
+
+export const getClientStats = (stats) => {
+ return Object.values(stats).reduce((acc, ch) => {
+ if (ch.clients && Array.isArray(ch.clients)) {
+ return acc.concat(
+ ch.clients.map((client) => ({
+ ...client,
+ channel: ch,
+ }))
+ );
+ }
+ return acc;
+ }, []);
+};
+
+export const getStatsByChannelId = (
+ channelStats,
+ prevChannelHistory,
+ channelsByUUID,
+ channels,
+ streamProfiles
+) => {
+ const stats = {};
+
+ channelStats.channels.forEach((ch) => {
+ // Make sure we have a valid channel_id
+ if (!ch.channel_id) {
+ console.warn('Found channel without channel_id:', ch);
+ return;
+ }
+
+ stats[ch.channel_id] = getChannelWithMetadata(
+ prevChannelHistory,
+ ch,
+ channelsByUUID,
+ channels,
+ streamProfiles
+ );
+ });
+ return stats;
+};
diff --git a/frontend/src/utils/pages/VODsUtils.js b/frontend/src/utils/pages/VODsUtils.js
new file mode 100644
index 00000000..2e9455ea
--- /dev/null
+++ b/frontend/src/utils/pages/VODsUtils.js
@@ -0,0 +1,28 @@
+export const getCategoryOptions = (categories, filters) => {
+ return [
+ { value: '', label: 'All Categories' },
+ ...Object.values(categories)
+ .filter((cat) => {
+ if (filters.type === 'movies') return cat.category_type === 'movie';
+ if (filters.type === 'series') return cat.category_type === 'series';
+ return true; // 'all' shows all
+ })
+ .map((cat) => ({
+ value: `${cat.name}|${cat.category_type}`,
+ label: `${cat.name} (${cat.category_type})`,
+ })),
+ ];
+};
+
+export const filterCategoriesToEnabled = (allCategories) => {
+ return Object.keys(allCategories).reduce((acc, key) => {
+ const enabled = allCategories[key].m3u_accounts.find(
+ (account) => account.enabled === true
+ );
+ if (enabled) {
+ acc[key] = allCategories[key];
+ }
+
+ return acc;
+ }, {});
+};
diff --git a/frontend/src/utils/pages/__tests__/DVRUtils.test.js b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
new file mode 100644
index 00000000..9c5bb15f
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
@@ -0,0 +1,539 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as DVRUtils from '../DVRUtils';
+import dayjs from 'dayjs';
+
+describe('DVRUtils', () => {
+ describe('categorizeRecordings', () => {
+ let toUserTime;
+ let now;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ now = baseTime;
+ });
+
+ it('should categorize in-progress recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ expect(result.inProgress[0].id).toBe(1);
+ expect(result.upcoming).toHaveLength(0);
+ expect(result.completed).toHaveLength(0);
+ });
+
+ it('should categorize upcoming recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0].id).toBe(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.completed).toHaveLength(0);
+ });
+
+ it('should categorize completed recordings by status', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T11:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'completed' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.completed[0].id).toBe(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should categorize interrupted recordings as completed', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'interrupted' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should categorize past recordings without status as completed', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T09:00:00',
+ end_time: '2024-01-01T10:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should deduplicate in-progress by program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 100 }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { id: 100 }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should deduplicate in-progress by channel+slot when no program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should not deduplicate different channels', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(2);
+ });
+
+ it('should sort in-progress by start_time descending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: { program: { id: 1 } }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:30:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: { program: { id: 2 } }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch3',
+ custom_properties: { program: { id: 3 } }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress[0].id).toBe(2);
+ expect(result.inProgress[1].id).toBe(3);
+ expect(result.inProgress[2].id).toBe(1);
+ });
+
+ it('should group upcoming by series and keep first episode', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T16:00:00',
+ end_time: '2024-01-01T17:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0].id).toBe(1);
+ expect(result.upcoming[0]._group_count).toBe(3);
+ });
+
+ it('should group upcoming case-insensitively by title', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'show a' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(2);
+ });
+
+ it('should not group upcoming with different tvg_id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show2', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(2);
+ expect(result.upcoming[0]._group_count).toBe(1);
+ expect(result.upcoming[1]._group_count).toBe(1);
+ });
+
+ it('should sort upcoming by start_time ascending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T16:00:00',
+ end_time: '2024-01-01T17:00:00',
+ channel: 'ch1',
+ custom_properties: { program: { id: 1, tvg_id: 'show1', title: 'Show A' } }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch2',
+ custom_properties: { program: { id: 2, tvg_id: 'show2', title: 'Show B' } }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch3',
+ custom_properties: { program: { id: 3, tvg_id: 'show3', title: 'Show C' } }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming[0].id).toBe(2);
+ expect(result.upcoming[1].id).toBe(3);
+ expect(result.upcoming[2].id).toBe(1);
+ });
+
+
+ it('should sort completed by end_time descending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T08:00:00',
+ end_time: '2024-01-01T09:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'completed' }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T11:00:00',
+ channel: 'ch2',
+ custom_properties: { status: 'completed' }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T09:00:00',
+ end_time: '2024-01-01T10:00:00',
+ channel: 'ch3',
+ custom_properties: { status: 'completed' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed[0].id).toBe(2);
+ expect(result.completed[1].id).toBe(3);
+ expect(result.completed[2].id).toBe(1);
+ });
+
+ it('should handle recordings as object', () => {
+ const recordings = {
+ rec1: {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ };
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should handle empty recordings array', () => {
+ const result = DVRUtils.categorizeRecordings([], toUserTime, now);
+
+ expect(result.inProgress).toEqual([]);
+ expect(result.upcoming).toEqual([]);
+ expect(result.completed).toEqual([]);
+ });
+
+ it('should handle null recordings', () => {
+ const result = DVRUtils.categorizeRecordings(null, toUserTime, now);
+
+ expect(result.inProgress).toEqual([]);
+ expect(result.upcoming).toEqual([]);
+ expect(result.completed).toEqual([]);
+ });
+
+ it('should deduplicate by recording id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ },
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should handle recordings without custom_properties', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1'
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should handle recordings without program', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(1);
+ });
+
+ it('should handle recording without id', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should deduplicate upcoming by program id before grouping', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 101, tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(2);
+ });
+
+ it('should preserve _group_count property on grouped recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming[0]._group_count).toBe(1);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
new file mode 100644
index 00000000..5d305290
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as PluginsUtils from '../PluginsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updatePluginSettings: vi.fn(),
+ runPluginAction: vi.fn(),
+ setPluginEnabled: vi.fn(),
+ importPlugin: vi.fn(),
+ deletePlugin: vi.fn()
+ }
+}));
+
+describe('PluginsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('updatePluginSettings', () => {
+ it('should call API updatePluginSettings with key and settings', async () => {
+ const key = 'test-plugin';
+ const settings = { option1: 'value1', option2: true };
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, settings);
+ expect(API.updatePluginSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const settings = { enabled: true };
+ const mockResponse = { success: true };
+
+ API.updatePluginSettings.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle empty settings object', async () => {
+ const key = 'test-plugin';
+ const settings = {};
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, {});
+ });
+
+ it('should handle null settings', async () => {
+ const key = 'test-plugin';
+ const settings = null;
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, null);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const settings = { enabled: true };
+ const error = new Error('API error');
+
+ API.updatePluginSettings.mockRejectedValue(error);
+
+ await expect(PluginsUtils.updatePluginSettings(key, settings)).rejects.toThrow('API error');
+ });
+ });
+
+ describe('runPluginAction', () => {
+ it('should call API runPluginAction with key and actionId', async () => {
+ const key = 'test-plugin';
+ const actionId = 'refresh-data';
+
+ await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(API.runPluginAction).toHaveBeenCalledWith(key, actionId);
+ expect(API.runPluginAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const actionId = 'sync';
+ const mockResponse = { status: 'completed' };
+
+ API.runPluginAction.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle numeric actionId', async () => {
+ const key = 'test-plugin';
+ const actionId = 123;
+
+ await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(API.runPluginAction).toHaveBeenCalledWith(key, 123);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const actionId = 'invalid-action';
+ const error = new Error('Action not found');
+
+ API.runPluginAction.mockRejectedValue(error);
+
+ await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow('Action not found');
+ });
+ });
+
+ describe('setPluginEnabled', () => {
+ it('should call API setPluginEnabled with key and next value', async () => {
+ const key = 'test-plugin';
+ const next = true;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, true);
+ expect(API.setPluginEnabled).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle false value', async () => {
+ const key = 'test-plugin';
+ const next = false;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, false);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const next = true;
+ const mockResponse = { enabled: true };
+
+ API.setPluginEnabled.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle truthy values', async () => {
+ const key = 'test-plugin';
+ const next = 'yes';
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, 'yes');
+ });
+
+ it('should handle falsy values', async () => {
+ const key = 'test-plugin';
+ const next = 0;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, 0);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const next = true;
+ const error = new Error('Plugin not found');
+
+ API.setPluginEnabled.mockRejectedValue(error);
+
+ await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow('Plugin not found');
+ });
+ });
+
+ describe('importPlugin', () => {
+ it('should call API importPlugin with importFile', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+
+ await PluginsUtils.importPlugin(importFile);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(importFile);
+ expect(API.importPlugin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const mockResponse = { key: 'imported-plugin', success: true };
+
+ API.importPlugin.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.importPlugin(importFile);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle string file path', async () => {
+ const importFile = '/path/to/plugin.zip';
+
+ await PluginsUtils.importPlugin(importFile);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(importFile);
+ });
+
+ it('should handle FormData', async () => {
+ const formData = new FormData();
+ formData.append('file', new File(['content'], 'plugin.zip'));
+
+ await PluginsUtils.importPlugin(formData);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(formData);
+ });
+
+ it('should propagate API errors', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const error = new Error('Invalid plugin format');
+
+ API.importPlugin.mockRejectedValue(error);
+
+ await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow('Invalid plugin format');
+ });
+ });
+
+ describe('deletePluginByKey', () => {
+ it('should call API deletePlugin with key', () => {
+ const key = 'test-plugin';
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(key);
+ expect(API.deletePlugin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', () => {
+ const key = 'test-plugin';
+ const mockResponse = { success: true };
+
+ API.deletePlugin.mockReturnValue(mockResponse);
+
+ const result = PluginsUtils.deletePluginByKey(key);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle numeric key', () => {
+ const key = 123;
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(123);
+ });
+
+ it('should handle empty string key', () => {
+ const key = '';
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith('');
+ });
+
+ it('should handle null key', () => {
+ const key = null;
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(null);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
new file mode 100644
index 00000000..1611c7d3
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
@@ -0,0 +1,411 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as SettingsUtils from '../SettingsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ checkSetting: vi.fn(),
+ updateSetting: vi.fn(),
+ createSetting: vi.fn(),
+ rehashStreams: vi.fn()
+ }
+}));
+
+describe('SettingsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('checkSetting', () => {
+ it('should call API checkSetting with values', async () => {
+ const values = { key: 'test-setting', value: 'test-value' };
+ await SettingsUtils.checkSetting(values);
+ expect(API.checkSetting).toHaveBeenCalledWith(values);
+ expect(API.checkSetting).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('updateSetting', () => {
+ it('should call API updateSetting with values', async () => {
+ const values = { id: 1, key: 'test-setting', value: 'new-value' };
+ await SettingsUtils.updateSetting(values);
+ expect(API.updateSetting).toHaveBeenCalledWith(values);
+ expect(API.updateSetting).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('createSetting', () => {
+ it('should call API createSetting with values', async () => {
+ const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
+ await SettingsUtils.createSetting(values);
+ expect(API.createSetting).toHaveBeenCalledWith(values);
+ expect(API.createSetting).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('rehashStreams', () => {
+ it('should call API rehashStreams', async () => {
+ await SettingsUtils.rehashStreams();
+ expect(API.rehashStreams).toHaveBeenCalledWith();
+ expect(API.rehashStreams).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('saveChangedSettings', () => {
+ it('should group stream settings correctly and update', async () => {
+ const settings = {
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 5,
+ m3u_hash_key: 'channel_name'
+ }
+ }
+ };
+ const changedSettings = {
+ default_user_agent: 7,
+ preferred_region: 'UK'
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 7,
+ m3u_hash_key: 'channel_name',
+ preferred_region: 'UK'
+ }
+ });
+ });
+
+ it('should convert m3u_hash_key array to comma-separated string', async () => {
+ const settings = {
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {}
+ }
+ };
+ const changedSettings = {
+ m3u_hash_key: ['channel_name', 'channel_number']
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ m3u_hash_key: 'channel_name,channel_number'
+ }
+ });
+ });
+
+ it('should convert ID fields to integers', async () => {
+ const settings = {
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {}
+ }
+ };
+ const changedSettings = {
+ default_user_agent: '5',
+ default_stream_profile: '3'
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 5,
+ default_stream_profile: 3
+ }
+ });
+ });
+
+ it('should preserve boolean types', async () => {
+ const settings = {
+ dvr_settings: {
+ id: 2,
+ key: 'dvr_settings',
+ value: {}
+ },
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {}
+ }
+ };
+ const changedSettings = {
+ comskip_enabled: true,
+ auto_import_mapped_files: false
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle proxy_settings specially', async () => {
+ const settings = {
+ proxy_settings: {
+ id: 5,
+ key: 'proxy_settings',
+ value: {
+ buffering_speed: 1.0
+ }
+ }
+ };
+ const changedSettings = {
+ proxy_settings: {
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ }
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 5,
+ key: 'proxy_settings',
+ value: {
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ }
+ });
+ });
+
+ it('should create proxy_settings if it does not exist', async () => {
+ const settings = {};
+ const changedSettings = {
+ proxy_settings: {
+ buffering_speed: 2.5
+ }
+ };
+
+ API.createSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.createSetting).toHaveBeenCalledWith({
+ key: 'proxy_settings',
+ name: 'Proxy Settings',
+ value: {
+ buffering_speed: 2.5
+ }
+ });
+ });
+
+ it('should handle network_access specially', async () => {
+ const settings = {
+ network_access: {
+ id: 6,
+ key: 'network_access',
+ value: []
+ }
+ };
+ const changedSettings = {
+ network_access: ['192.168.1.0/24', '10.0.0.0/8']
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 6,
+ key: 'network_access',
+ value: ['192.168.1.0/24', '10.0.0.0/8']
+ });
+ });
+ });
+
+ describe('parseSettings', () => {
+ it('should parse grouped settings correctly', () => {
+ const mockSettings = {
+ 'stream_settings': {
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 5,
+ default_stream_profile: 3,
+ m3u_hash_key: 'channel_name,channel_number',
+ preferred_region: 'US',
+ auto_import_mapped_files: true
+ }
+ },
+ 'dvr_settings': {
+ id: 2,
+ key: 'dvr_settings',
+ value: {
+ tv_template: '/media/tv/{show}/{season}/',
+ comskip_enabled: false,
+ pre_offset_minutes: 2,
+ post_offset_minutes: 5
+ }
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+
+ // Check stream settings
+ expect(result.default_user_agent).toBe('5');
+ expect(result.default_stream_profile).toBe('3');
+ expect(result.m3u_hash_key).toEqual(['channel_name', 'channel_number']);
+ expect(result.preferred_region).toBe('US');
+ expect(result.auto_import_mapped_files).toBe(true);
+
+ // Check DVR settings
+ expect(result.tv_template).toBe('/media/tv/{show}/{season}/');
+ expect(result.comskip_enabled).toBe(false);
+ expect(result.pre_offset_minutes).toBe(2);
+ expect(result.post_offset_minutes).toBe(5);
+ });
+
+ it('should handle empty m3u_hash_key', () => {
+ const mockSettings = {
+ 'stream_settings': {
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ m3u_hash_key: ''
+ }
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+ expect(result.m3u_hash_key).toEqual([]);
+ });
+
+ it('should handle proxy_settings', () => {
+ const mockSettings = {
+ 'proxy_settings': {
+ id: 5,
+ key: 'proxy_settings',
+ value: {
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ }
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+ expect(result.proxy_settings).toEqual({
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ });
+ });
+
+ it('should handle network_access', () => {
+ const mockSettings = {
+ 'network_access': {
+ id: 6,
+ key: 'network_access',
+ value: ['192.168.1.0/24', '10.0.0.0/8']
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+ expect(result.network_access).toEqual(['192.168.1.0/24', '10.0.0.0/8']);
+ });
+ });
+
+ describe('getChangedSettings', () => {
+ it('should detect changes in primitive values', () => {
+ const values = {
+ time_zone: 'America/New_York',
+ max_system_events: 2000,
+ comskip_enabled: true
+ };
+ const settings = {
+ time_zone: { value: 'UTC' },
+ max_system_events: { value: 1000 },
+ comskip_enabled: { value: false }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(changes).toEqual({
+ time_zone: 'America/New_York',
+ max_system_events: 2000,
+ comskip_enabled: true
+ });
+ });
+
+ it('should not detect unchanged values', () => {
+ const values = {
+ time_zone: 'UTC',
+ max_system_events: 1000
+ };
+ const settings = {
+ time_zone: { value: 'UTC' },
+ max_system_events: { value: 1000 }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(changes).toEqual({});
+ });
+
+ it('should preserve type of numeric values', () => {
+ const values = {
+ max_system_events: 2000
+ };
+ const settings = {
+ max_system_events: { value: 1000 }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(typeof changes.max_system_events).toBe('number');
+ expect(changes.max_system_events).toBe(2000);
+ });
+
+ it('should detect changes in array values', () => {
+ const values = {
+ m3u_hash_key: ['channel_name', 'channel_number']
+ };
+ const settings = {
+ m3u_hash_key: { value: 'channel_name' }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ // Arrays are converted to comma-separated strings internally
+ expect(changes).toEqual({
+ m3u_hash_key: 'channel_name,channel_number'
+ });
+ });
+
+ it('should skip proxy_settings and network_access', () => {
+ const values = {
+ time_zone: 'America/New_York',
+ proxy_settings: {
+ buffering_speed: 2.5
+ },
+ network_access: ['192.168.1.0/24']
+ };
+ const settings = {
+ time_zone: { value: 'UTC' }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(changes.proxy_settings).toBeUndefined();
+ expect(changes.network_access).toBeUndefined();
+ expect(changes.time_zone).toBe('America/New_York');
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/StatsUtils.test.js b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
new file mode 100644
index 00000000..ccd422b1
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
@@ -0,0 +1,654 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StatsUtils from '../StatsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ stopChannel: vi.fn(),
+ stopClient: vi.fn(),
+ stopVODClient: vi.fn(),
+ fetchActiveChannelStats: vi.fn(),
+ getVODStats: vi.fn()
+ }
+}));
+
+describe('StatsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('stopChannel', () => {
+ it('should call API stopChannel with id', async () => {
+ const id = 'channel-123';
+
+ await StatsUtils.stopChannel(id);
+
+ expect(API.stopChannel).toHaveBeenCalledWith('channel-123');
+ expect(API.stopChannel).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric id', async () => {
+ const id = 123;
+
+ await StatsUtils.stopChannel(id);
+
+ expect(API.stopChannel).toHaveBeenCalledWith(123);
+ });
+
+ it('should propagate API errors', async () => {
+ const id = 'channel-123';
+ const error = new Error('Failed to stop channel');
+
+ API.stopChannel.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopChannel(id)).rejects.toThrow('Failed to stop channel');
+ });
+ });
+
+ describe('stopClient', () => {
+ it('should call API stopClient with channelId and clientId', async () => {
+ const channelId = 'channel-123';
+ const clientId = 'client-456';
+
+ await StatsUtils.stopClient(channelId, clientId);
+
+ expect(API.stopClient).toHaveBeenCalledWith('channel-123', 'client-456');
+ expect(API.stopClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric ids', async () => {
+ const channelId = 123;
+ const clientId = 456;
+
+ await StatsUtils.stopClient(channelId, clientId);
+
+ expect(API.stopClient).toHaveBeenCalledWith(123, 456);
+ });
+
+ it('should propagate API errors', async () => {
+ const channelId = 'channel-123';
+ const clientId = 'client-456';
+ const error = new Error('Failed to stop client');
+
+ API.stopClient.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow('Failed to stop client');
+ });
+ });
+
+ describe('stopVODClient', () => {
+ it('should call API stopVODClient with clientId', async () => {
+ const clientId = 'vod-client-123';
+
+ await StatsUtils.stopVODClient(clientId);
+
+ expect(API.stopVODClient).toHaveBeenCalledWith('vod-client-123');
+ expect(API.stopVODClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric clientId', async () => {
+ const clientId = 123;
+
+ await StatsUtils.stopVODClient(clientId);
+
+ expect(API.stopVODClient).toHaveBeenCalledWith(123);
+ });
+
+ it('should propagate API errors', async () => {
+ const clientId = 'vod-client-123';
+ const error = new Error('Failed to stop VOD client');
+
+ API.stopVODClient.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow('Failed to stop VOD client');
+ });
+ });
+
+ describe('fetchActiveChannelStats', () => {
+ it('should call API fetchActiveChannelStats', async () => {
+ const mockStats = { channels: [] };
+
+ API.fetchActiveChannelStats.mockResolvedValue(mockStats);
+
+ const result = await StatsUtils.fetchActiveChannelStats();
+
+ expect(API.fetchActiveChannelStats).toHaveBeenCalledWith();
+ expect(API.fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockStats);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Failed to fetch stats');
+
+ API.fetchActiveChannelStats.mockRejectedValue(error);
+
+ await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow('Failed to fetch stats');
+ });
+ });
+
+ describe('getVODStats', () => {
+ it('should call API getVODStats', async () => {
+ const mockStats = [{ content_type: 'movie', connections: [] }];
+
+ API.getVODStats.mockResolvedValue(mockStats);
+
+ const result = await StatsUtils.getVODStats();
+
+ expect(API.getVODStats).toHaveBeenCalledWith();
+ expect(API.getVODStats).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockStats);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Failed to fetch VOD stats');
+
+ API.getVODStats.mockRejectedValue(error);
+
+ await expect(StatsUtils.getVODStats()).rejects.toThrow('Failed to fetch VOD stats');
+ });
+ });
+
+ describe('getCombinedConnections', () => {
+ it('should combine channel history and VOD connections', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 100 }
+ };
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 50 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].type).toBe('stream');
+ expect(result[1].type).toBe('vod');
+ });
+
+ it('should sort by sortKey descending (newest first)', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 50 }
+ };
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+
+ expect(result[0].sortKey).toBe(100);
+ expect(result[1].sortKey).toBe(50);
+ });
+
+ it('should flatten VOD connections to individual cards', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 },
+ { client_id: 'client2', connected_at: 200 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].data.connections).toHaveLength(1);
+ expect(result[0].data.connection_count).toBe(1);
+ expect(result[0].data.individual_connection.client_id).toBe('client2');
+ expect(result[1].data.individual_connection.client_id).toBe('client1');
+ });
+
+ it('should create unique IDs for VOD items', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 },
+ { client_id: 'client2', connected_at: 200 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result[0].id).toBe('movie-uuid1-client2-1');
+ expect(result[1].id).toBe('movie-uuid1-client1-0');
+ });
+
+ it('should use uptime for stream sortKey', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 150 }
+ };
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, []);
+
+ expect(result[0].sortKey).toBe(150);
+ });
+
+ it('should default to 0 for missing uptime', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1' }
+ };
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, []);
+
+ expect(result[0].sortKey).toBe(0);
+ });
+
+ it('should use connected_at for VOD sortKey', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 250 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result[0].sortKey).toBe(250);
+ });
+
+ it('should handle empty connections array', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: []
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should handle empty inputs', () => {
+ const result = StatsUtils.getCombinedConnections({}, []);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null connections', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: null
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ describe('getClientStats', () => {
+ it('should extract clients from channel stats', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: [
+ { client_id: 'client1' },
+ { client_id: 'client2' }
+ ]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].client_id).toBe('client1');
+ expect(result[0].channel.channel_id).toBe('ch1');
+ });
+
+ it('should attach channel reference to each client', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ name: 'Channel 1',
+ clients: [
+ { client_id: 'client1' }
+ ]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result[0].channel).toEqual({
+ channel_id: 'ch1',
+ name: 'Channel 1',
+ clients: [{ client_id: 'client1' }]
+ });
+ });
+
+ it('should handle channels without clients array', () => {
+ const stats = {
+ 'ch1': { channel_id: 'ch1' },
+ 'ch2': { channel_id: 'ch2', clients: null }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle empty clients array', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: []
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should combine clients from multiple channels', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: [{ client_id: 'client1' }]
+ },
+ 'ch2': {
+ channel_id: 'ch2',
+ clients: [{ client_id: 'client2' }]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].channel.channel_id).toBe('ch1');
+ expect(result[1].channel.channel_id).toBe('ch2');
+ });
+
+ it('should handle empty stats object', () => {
+ const result = StatsUtils.getClientStats({});
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getStatsByChannelId', () => {
+ it('should create stats indexed by channel_id', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 1000 }
+ ]
+ };
+ const prevChannelHistory = {};
+ const channelsByUUID = {};
+ const channels = {};
+ const streamProfiles = [];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ channelsByUUID,
+ channels,
+ streamProfiles
+ );
+
+ expect(result).toHaveProperty('ch1');
+ expect(result.ch1.channel_id).toBe('ch1');
+ });
+
+ it('should calculate bitrates from previous history', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: [500]
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([500, 1000]);
+ });
+
+ it('should limit bitrates array to 15 entries', () => {
+ const prevBitrates = new Array(15).fill(100);
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: prevBitrates
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toHaveLength(15);
+ expect(result.ch1.bitrates[0]).toBe(100);
+ expect(result.ch1.bitrates[14]).toBe(1000);
+ });
+
+ it('should skip negative bitrates', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 500 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: []
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([]);
+ });
+
+ it('should merge channel data from channelsByUUID', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'uuid1', total_bytes: 1000 }
+ ]
+ };
+ const channelsByUUID = {
+ 'uuid1': 'channel-key-1'
+ };
+ const channels = {
+ 'channel-key-1': {
+ name: 'Channel 1',
+ logo: 'logo.png'
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ channelsByUUID,
+ channels,
+ []
+ );
+
+ expect(result.uuid1.name).toBe('Channel 1');
+ expect(result.uuid1.logo).toBe('logo.png');
+ });
+
+ it('should find and attach stream profile', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_profile: '1' }
+ ]
+ };
+ const streamProfiles = [
+ { id: 1, name: 'HD Profile' },
+ { id: 2, name: 'SD Profile' }
+ ];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ streamProfiles
+ );
+
+ expect(result.ch1.stream_profile.name).toBe('HD Profile');
+ });
+
+ it('should default to Unknown for missing stream profile', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_profile: '999' }
+ ]
+ };
+ const streamProfiles = [
+ { id: 1, name: 'HD Profile' }
+ ];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ streamProfiles
+ );
+
+ expect(result.ch1.stream_profile.name).toBe('Unknown');
+ });
+
+ it('should preserve stream_id from channel stats', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_id: 'stream-123' }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.stream_id).toBe('stream-123');
+ });
+
+ it('should set stream_id to null if missing', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1' }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.stream_id).toBeNull();
+ });
+
+ it('should skip channels without channel_id', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const channelStats = {
+ channels: [
+ { total_bytes: 1000 },
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result).not.toHaveProperty('undefined');
+ expect(result).toHaveProperty('ch1');
+ expect(consoleSpy).toHaveBeenCalledWith('Found channel without channel_id:', { total_bytes: 1000 });
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle empty channels array', () => {
+ const channelStats = { channels: [] };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result).toEqual({});
+ });
+
+ it('should initialize empty bitrates array for new channels', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 1000 }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/VODsUtils.test.js b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
new file mode 100644
index 00000000..e058ff0e
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
@@ -0,0 +1,272 @@
+import { describe, it, expect } from 'vitest';
+import * as VODsUtils from '../VODsUtils';
+
+describe('VODsUtils', () => {
+ describe('getCategoryOptions', () => {
+ it('should return all categories option plus formatted categories', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1]).toEqual({ value: 'Action|movie', label: 'Action (movie)' });
+ expect(result[2]).toEqual({ value: 'Drama|series', label: 'Drama (series)' });
+ });
+
+ it('should filter to only movies when type is movies', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Comedy', category_type: 'movie' }
+ };
+ const filters = { type: 'movies' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1].label).toContain('(movie)');
+ expect(result[2].label).toContain('(movie)');
+ });
+
+ it('should filter to only series when type is series', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Sitcom', category_type: 'series' }
+ };
+ const filters = { type: 'series' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1].label).toContain('(series)');
+ expect(result[2].label).toContain('(series)');
+ });
+
+ it('should show all categories when type is all', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should handle empty categories object', () => {
+ const categories = {};
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ });
+
+ it('should create value with name and category_type separated by pipe', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result[1].value).toBe('Action|movie');
+ });
+
+ it('should handle undefined type filter', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = {};
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should filter out categories that do not match type', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Comedy', category_type: 'movie' }
+ };
+ const filters = { type: 'series' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].value).toBe('Drama|series');
+ });
+ });
+
+ describe('filterCategoriesToEnabled', () => {
+ it('should return only categories with enabled m3u_accounts', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: true }
+ ]
+ },
+ 'cat2': {
+ name: 'Drama',
+ m3u_accounts: [
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).toHaveProperty('cat1');
+ expect(result).not.toHaveProperty('cat2');
+ });
+
+ it('should include category if any m3u_account is enabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: false },
+ { id: 2, enabled: true },
+ { id: 3, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).toHaveProperty('cat1');
+ });
+
+ it('should exclude category if all m3u_accounts are disabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: false },
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should exclude category with empty m3u_accounts array', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: []
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should preserve original category data', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ category_type: 'movie',
+ m3u_accounts: [
+ { id: 1, enabled: true }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result.cat1).toEqual(allCategories.cat1);
+ });
+
+ it('should handle empty allCategories object', () => {
+ const result = VODsUtils.filterCategoriesToEnabled({});
+
+ expect(result).toEqual({});
+ });
+
+ it('should filter multiple categories correctly', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [{ id: 1, enabled: true }]
+ },
+ 'cat2': {
+ name: 'Drama',
+ m3u_accounts: [{ id: 2, enabled: false }]
+ },
+ 'cat3': {
+ name: 'Comedy',
+ m3u_accounts: [{ id: 3, enabled: true }]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(Object.keys(result)).toHaveLength(2);
+ expect(result).toHaveProperty('cat1');
+ expect(result).toHaveProperty('cat3');
+ expect(result).not.toHaveProperty('cat2');
+ });
+
+ it('should handle category with null m3u_accounts', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: null
+ }
+ };
+
+ expect(() => {
+ VODsUtils.filterCategoriesToEnabled(allCategories);
+ }).toThrow();
+ });
+
+ it('should handle truthy enabled values', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: 1 },
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should only match strict true for enabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: 'true' }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+ });
+});
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 9ce8189b..1026e519 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -26,4 +26,10 @@ export default defineConfig({
// },
// },
},
+
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setupTests.js'],
+ globals: true,
+ },
});
diff --git a/requirements.txt b/requirements.txt
index 7d7117f4..3416804d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,30 +1,32 @@
-Django==5.1.6
-psycopg2-binary==2.9.10
-redis==4.5.5
-celery
-celery[redis]
-djangorestframework==3.15.2
-requests==2.32.3
-psutil==7.0.0
+Django==5.2.9
+psycopg2-binary==2.9.11
+celery[redis]==5.6.0
+djangorestframework==3.16.1
+requests==2.32.5
+psutil==7.1.3
pillow
-drf-yasg>=1.20.0
+drf-yasg>=1.21.11
streamlink
python-vlc
yt-dlp
-gevent==24.11.1
+gevent==25.9.1
daphne
uwsgi
django-cors-headers
djangorestframework-simplejwt
m3u8
-rapidfuzz==3.12.1
+rapidfuzz==3.14.3
+regex # Required by transformers but also used for advanced regex features
+tzlocal
+
# PyTorch dependencies (CPU only)
--extra-index-url https://download.pytorch.org/whl/cpu/
-torch==2.6.0+cpu
+torch==2.9.1+cpu
# ML/NLP dependencies
-sentence-transformers==3.4.1
+sentence-transformers==5.2.0
channels
-channels-redis
+channels-redis==4.3.0
django-filter
django-celery-beat
+lxml==6.0.2
diff --git a/scripts/debug_wrapper.py b/scripts/debug_wrapper.py
index 2339fe87..2e96e110 100644
--- a/scripts/debug_wrapper.py
+++ b/scripts/debug_wrapper.py
@@ -27,7 +27,7 @@ logger.info(f"Files in current directory: {os.listdir()}")
logger.info(f"Python path: {sys.path}")
# Default timeout in seconds
-DEBUG_TIMEOUT = int(os.environ.get('DEBUG_TIMEOUT', '30'))
+DEBUG_TIMEOUT = int(os.environ.get('DEBUG_TIMEOUT', '60')) # Increased default timeout
# Whether to wait for debugger to attach
WAIT_FOR_DEBUGGER = os.environ.get('WAIT_FOR_DEBUGGER', 'false').lower() == 'true'
@@ -40,7 +40,7 @@ try:
logger.info("Successfully imported debugpy")
# Critical: Configure debugpy to use regular Python for the adapter, not uwsgi
- python_path = '/usr/local/bin/python3'
+ python_path = '/dispatcharrpy/bin/python'
if os.path.exists(python_path):
logger.info(f"Setting debugpy adapter to use Python interpreter: {python_path}")
debugpy.configure(python=python_path)
@@ -50,21 +50,34 @@ try:
# Don't wait for connection, just set up the debugging session
logger.info("Initializing debugpy on 0.0.0.0:5678...")
try:
- # Use connect instead of listen to avoid the adapter process
+ # Configure debugpy to listen without socket timeout initially
debugpy.listen(("0.0.0.0", 5678))
logger.info("debugpy now listening on 0.0.0.0:5678")
if WAIT_FOR_DEBUGGER:
logger.info(f"Waiting for debugger to attach (timeout: {DEBUG_TIMEOUT}s)...")
start_time = time.time()
+
+ # Use a more reliable approach for checking connection
while not debugpy.is_client_connected() and (time.time() - start_time < DEBUG_TIMEOUT):
time.sleep(1)
- logger.info("Waiting for debugger connection...")
+ if (time.time() - start_time) % 5 == 0: # Log only every 5 seconds to reduce spam
+ logger.info(f"Still waiting for debugger connection... ({int(time.time() - start_time)}s)")
if debugpy.is_client_connected():
- logger.info("Debugger attached!")
+ logger.info("Debugger attached successfully!")
else:
- logger.info(f"Debugger not attached after {DEBUG_TIMEOUT}s, continuing anyway...")
+ logger.warning(f"Debugger not attached after {DEBUG_TIMEOUT}s, continuing anyway...")
+ except RuntimeError as re:
+ if "already in use" in str(re):
+ logger.warning(f"Port 5678 already in use. This might indicate another debugging session is active.")
+ logger.info("Continuing without debugging...")
+ elif "timed out waiting for adapter to connect" in str(re):
+ logger.warning(f"debugpy.listen timed out after {DEBUG_TIMEOUT}s. This is normal in some environments.")
+ logger.info("Continuing without debugging...")
+ else:
+ logger.error(f"RuntimeError with debugpy.listen: {re}", exc_info=True)
+ logger.info("Continuing without debugging...")
except Exception as e:
logger.error(f"Error with debugpy.listen: {e}", exc_info=True)
logger.info("Continuing without debugging...")
diff --git a/scripts/epg_match.py b/scripts/epg_match.py
deleted file mode 100644
index ed86d865..00000000
--- a/scripts/epg_match.py
+++ /dev/null
@@ -1,174 +0,0 @@
-# ml_model.py
-
-import sys
-import json
-import re
-import os
-import sys
-from rapidfuzz import fuzz
-from sentence_transformers import util
-from sentence_transformers import SentenceTransformer as st
-
-# Load the sentence-transformers model once at the module level
-SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
-MODEL_PATH = os.path.join("/app", "models", "all-MiniLM-L6-v2")
-
-# Thresholds
-BEST_FUZZY_THRESHOLD = 85
-LOWER_FUZZY_THRESHOLD = 40
-EMBED_SIM_THRESHOLD = 0.65
-
-def eprint(*args, **kwargs):
- print(*args, file=sys.stderr, **kwargs)
-
-def process_data(input_data):
- os.makedirs(MODEL_PATH, exist_ok=True)
-
- # If not present locally, download:
- if not os.path.exists(os.path.join(MODEL_PATH, "config.json")):
- eprint(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...")
- st_model = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH)
- else:
- eprint(f"Loading local model from {MODEL_PATH}")
- st_model = st(MODEL_PATH)
-
- channels = input_data["channels"]
- epg_data = input_data["epg_data"]
- region_code = input_data.get("region_code", None)
-
- epg_embeddings = None
- if any(row["norm_name"] for row in epg_data):
- epg_embeddings = st_model.encode(
- [row["norm_name"] for row in epg_data],
- convert_to_tensor=True
- )
-
- channels_to_update = []
- matched_channels = []
-
- for chan in channels:
- normalized_tvg_id = chan.get("tvg_id", "")
- fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"]
-
- # Exact TVG ID match (direct match)
- epg_by_tvg_id = next((epg for epg in epg_data if epg["tvg_id"] == normalized_tvg_id), None)
- if normalized_tvg_id and epg_by_tvg_id:
- chan["epg_data_id"] = epg_by_tvg_id["id"]
- channels_to_update.append(chan)
-
- # Add to matched_channels list so it's counted in the total
- matched_channels.append((chan['id'], fallback_name, epg_by_tvg_id["tvg_id"]))
-
- eprint(f"Channel {chan['id']} '{fallback_name}' => EPG found by tvg_id={epg_by_tvg_id['tvg_id']}")
- continue
-
- # If channel has a tvg_id that doesn't exist in EPGData, do direct check.
- # I don't THINK this should happen now that we assign EPG on channel creation.
- if chan["tvg_id"]:
- epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]]
- if epg_match:
- # Fix: Access the first element directly since epg_match contains the IDs themselves
- chan["epg_data_id"] = epg_match[0] # Directly use the integer ID
- eprint(f"Channel {chan['id']} '{chan['name']}' => EPG found by tvg_id={chan['tvg_id']}")
- channels_to_update.append(chan)
- continue
-
- # C) Perform name-based fuzzy matching
- if not chan["norm_chan"]:
- eprint(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping")
- continue
-
- best_score = 0
- best_epg = None
- for row in epg_data:
- if not row["norm_name"]:
- continue
-
- base_score = fuzz.ratio(chan["norm_chan"], row["norm_name"])
- bonus = 0
- # Region-based bonus/penalty
- combined_text = row["tvg_id"].lower() + " " + row["name"].lower()
- dot_regions = re.findall(r'\.([a-z]{2})', combined_text)
- if region_code:
- if dot_regions:
- if region_code in dot_regions:
- bonus = 30 # bigger bonus if .us or .ca matches
- else:
- bonus = -15
- elif region_code in combined_text:
- bonus = 15
- score = base_score + bonus
-
- eprint(
- f"Channel {chan['id']} '{fallback_name}' => EPG row {row['id']}: "
- f"name='{row['name']}', norm_name='{row['norm_name']}', "
- f"combined_text='{combined_text}', dot_regions={dot_regions}, "
- f"base_score={base_score}, bonus={bonus}, total_score={score}"
- )
-
- if score > best_score:
- best_score = score
- best_epg = row
-
- # If no best match was found, skip
- if not best_epg:
- eprint(f"Channel {chan['id']} '{fallback_name}' => no EPG match at all.")
- continue
-
- # If best_score is above BEST_FUZZY_THRESHOLD => direct accept
- if best_score >= BEST_FUZZY_THRESHOLD:
- chan["epg_data_id"] = best_epg["id"]
- channels_to_update.append(chan)
-
- matched_channels.append((chan['id'], fallback_name, best_epg["tvg_id"]))
- eprint(
- f"Channel {chan['id']} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} "
- f"(score={best_score})"
- )
-
- # If best_score is in the “middle range,” do embedding check
- elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None:
- chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True)
- sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0]
- top_index = int(sim_scores.argmax())
- top_value = float(sim_scores[top_index])
- if top_value >= EMBED_SIM_THRESHOLD:
- matched_epg = epg_data[top_index]
- chan["epg_data_id"] = matched_epg["id"]
- channels_to_update.append(chan)
-
- matched_channels.append((chan['id'], fallback_name, matched_epg["tvg_id"]))
- eprint(
- f"Channel {chan['id']} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} "
- f"(fuzzy={best_score}, cos-sim={top_value:.2f})"
- )
- else:
- eprint(
- f"Channel {chan['id']} '{fallback_name}' => fuzzy={best_score}, "
- f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping"
- )
- else:
- eprint(
- f"Channel {chan['id']} '{fallback_name}' => fuzzy={best_score} < "
- f"{LOWER_FUZZY_THRESHOLD}, skipping"
- )
-
- return {
- "channels_to_update": channels_to_update,
- "matched_channels": matched_channels,
- }
-
-def main():
- # Read input data from a file
- input_file_path = sys.argv[1]
- with open(input_file_path, 'r') as f:
- input_data = json.load(f)
-
- # Process data with the ML model (or your logic)
- result = process_data(input_data)
-
- # Output result to stdout
- print(json.dumps(result))
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py
new file mode 100644
index 00000000..1579a1f4
--- /dev/null
+++ b/scripts/update_changelog.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+"""
+Updates the CHANGELOG.md file for a new release.
+Renames [Unreleased] section to the new version with current date.
+Usage: python update_changelog.py
+"""
+import re
+import sys
+from datetime import datetime
+from pathlib import Path
+
+
+def update_changelog(version):
+ """Update CHANGELOG.md with new version and date."""
+ changelog_file = Path(__file__).parent.parent / "CHANGELOG.md"
+
+ if not changelog_file.exists():
+ print("CHANGELOG.md not found")
+ sys.exit(1)
+
+ content = changelog_file.read_text(encoding='utf-8')
+
+ # Check if there's an Unreleased section
+ if '## [Unreleased]' not in content:
+ print("No [Unreleased] section found in CHANGELOG.md")
+ sys.exit(1)
+
+ # Get current date in YYYY-MM-DD format
+ today = datetime.now().strftime('%Y-%m-%d')
+
+ # Replace [Unreleased] with new version and date, and add new [Unreleased] section
+ # This pattern preserves everything after [Unreleased] until the next version or end
+ new_content = re.sub(
+ r'## \[Unreleased\]',
+ f'## [Unreleased]\n\n## [{version}] - {today}',
+ content,
+ count=1
+ )
+
+ if new_content == content:
+ print("Failed to update CHANGELOG.md")
+ sys.exit(1)
+
+ changelog_file.write_text(new_content, encoding='utf-8')
+ print(f"CHANGELOG.md updated for version {version} ({today})")
+ return True
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python update_changelog.py ")
+ print("Example: python update_changelog.py 0.13.0")
+ sys.exit(1)
+
+ version = sys.argv[1]
+ # Remove 'v' prefix if present
+ version = version.lstrip('v')
+
+ update_changelog(version)
diff --git a/version.py b/version.py
index 25e27d60..1aae4039 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.4.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