@@ -990,6 +1022,7 @@ This action cannot be undone.`}
opened={confirmBatchUpdateOpen}
onClose={() => setConfirmBatchUpdateOpen(false)}
onConfirm={onSubmit}
+ loading={isSubmitting}
title="Confirm Batch Update"
message={
diff --git a/frontend/src/components/forms/Channels.jsx b/frontend/src/components/forms/Channels.jsx
deleted file mode 100644
index 97efea54..00000000
--- a/frontend/src/components/forms/Channels.jsx
+++ /dev/null
@@ -1,729 +0,0 @@
-import React, { useState, useEffect, useRef, useMemo } from 'react';
-import { useFormik } from 'formik';
-import * as Yup from 'yup';
-import useChannelsStore from '../../store/channels';
-import API from '../../api';
-import useStreamProfilesStore from '../../store/streamProfiles';
-import useStreamsStore from '../../store/streams';
-import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
-import LazyLogo from '../LazyLogo';
-import ChannelGroupForm from './ChannelGroup';
-import usePlaylistsStore from '../../store/playlists';
-import logo from '../../images/logo.png';
-import {
- Box,
- Button,
- Modal,
- TextInput,
- NativeSelect,
- Text,
- Group,
- ActionIcon,
- Center,
- Grid,
- Flex,
- Select,
- Divider,
- Stack,
- useMantineTheme,
- Popover,
- ScrollArea,
- Tooltip,
- NumberInput,
- Image,
- UnstyledButton,
-} from '@mantine/core';
-import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
-import useEPGsStore from '../../store/epgs';
-import { Dropzone } from '@mantine/dropzone';
-import { notifications } from '@mantine/notifications';
-import { FixedSizeList as List } from 'react-window';
-
-const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
- const theme = useMantineTheme();
-
- const listRef = useRef(null);
- const logoListRef = useRef(null);
- const groupListRef = useRef(null);
-
- const channelGroups = useChannelsStore((s) => s.channelGroups);
- const { logos, ensureLogosLoaded } = useChannelLogoSelection();
- const streams = useStreamsStore((state) => state.streams);
- const streamProfiles = useStreamProfilesStore((s) => s.profiles);
- const playlists = usePlaylistsStore((s) => s.playlists);
- const epgs = useEPGsStore((s) => s.epgs);
- const tvgs = useEPGsStore((s) => s.tvgs);
- const tvgsById = useEPGsStore((s) => s.tvgsById);
-
- const [logoPreview, setLogoPreview] = useState(null);
- const [channelStreams, setChannelStreams] = useState([]);
- const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
- const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
- const [logoPopoverOpened, setLogoPopoverOpened] = useState(false);
- const [selectedEPG, setSelectedEPG] = useState('');
- const [tvgFilter, setTvgFilter] = useState('');
- const [logoFilter, setLogoFilter] = useState('');
-
- const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
- const [groupFilter, setGroupFilter] = useState('');
- const groupOptions = Object.values(channelGroups);
-
- const addStream = (stream) => {
- const streamSet = new Set(channelStreams);
- streamSet.add(stream);
- setChannelStreams(Array.from(streamSet));
- };
-
- const removeStream = (stream) => {
- const streamSet = new Set(channelStreams);
- streamSet.delete(stream);
- setChannelStreams(Array.from(streamSet));
- };
-
- const handleLogoChange = async (files) => {
- if (files.length === 1) {
- const file = files[0];
-
- // Validate file size on frontend first
- if (file.size > 5 * 1024 * 1024) {
- // 5MB
- notifications.show({
- title: 'Error',
- message: 'File too large. Maximum size is 5MB.',
- color: 'red',
- });
- return;
- }
-
- try {
- const retval = await API.uploadLogo(file);
- // Note: API.uploadLogo already adds the logo to the store, no need to fetch
- setLogoPreview(retval.cache_url);
- formik.setFieldValue('logo_id', retval.id);
- } catch (error) {
- console.error('Logo upload failed:', error);
- // Error notification is already handled in API.uploadLogo
- }
- } else {
- setLogoPreview(null);
- }
- };
-
- const formik = useFormik({
- initialValues: {
- name: '',
- channel_number: '', // Change from 0 to empty string for consistency
- channel_group_id:
- Object.keys(channelGroups).length > 0
- ? Object.keys(channelGroups)[0]
- : '',
- stream_profile_id: '0',
- tvg_id: '',
- tvc_guide_stationid: '',
- epg_data_id: '',
- logo_id: '',
- },
- validationSchema: Yup.object({
- name: Yup.string().required('Name is required'),
- channel_group_id: Yup.string().required('Channel group is required'),
- }),
- onSubmit: async (values, { setSubmitting }) => {
- let response;
-
- try {
- const formattedValues = { ...values };
-
- // Convert empty or "0" stream_profile_id to null for the API
- if (
- !formattedValues.stream_profile_id ||
- formattedValues.stream_profile_id === '0'
- ) {
- formattedValues.stream_profile_id = null;
- }
-
- // Ensure tvg_id is properly included (no empty strings)
- formattedValues.tvg_id = formattedValues.tvg_id || null;
-
- // Ensure tvc_guide_stationid is properly included (no empty strings)
- formattedValues.tvc_guide_stationid =
- formattedValues.tvc_guide_stationid || null;
-
- if (channel) {
- // If there's an EPG to set, use our enhanced endpoint
- if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
- // Use the special endpoint to set EPG and trigger refresh
- const epgResponse = await API.setChannelEPG(
- channel.id,
- values.epg_data_id
- );
-
- // Remove epg_data_id from values since we've handled it separately
- const { epg_data_id, ...otherValues } = formattedValues;
-
- // Update other channel fields if needed
- if (Object.keys(otherValues).length > 0) {
- response = await API.updateChannel({
- id: channel.id,
- ...otherValues,
- streams: channelStreams.map((stream) => stream.id),
- });
- }
- } else {
- // No EPG change, regular update
- response = await API.updateChannel({
- id: channel.id,
- ...formattedValues,
- streams: channelStreams.map((stream) => stream.id),
- });
- }
- } else {
- // New channel creation - use the standard method
- response = await API.addChannel({
- ...formattedValues,
- streams: channelStreams.map((stream) => stream.id),
- });
- }
- } catch (error) {
- console.error('Error saving channel:', error);
- }
-
- formik.resetForm();
- API.requeryChannels();
-
- // Refresh channel profiles to update the membership information
- useChannelsStore.getState().fetchChannelProfiles();
-
- setSubmitting(false);
- setTvgFilter('');
- setLogoFilter('');
- onClose();
- },
- });
-
- useEffect(() => {
- if (channel) {
- if (channel.epg_data_id) {
- const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
- setSelectedEPG(epgSource ? `${epgSource.id}` : '');
- }
-
- formik.setValues({
- name: channel.name || '',
- channel_number:
- channel.channel_number !== null ? channel.channel_number : '',
- channel_group_id: channel.channel_group_id
- ? `${channel.channel_group_id}`
- : '',
- stream_profile_id: channel.stream_profile_id
- ? `${channel.stream_profile_id}`
- : '0',
- tvg_id: channel.tvg_id || '',
- tvc_guide_stationid: channel.tvc_guide_stationid || '',
- epg_data_id: channel.epg_data_id ?? '',
- logo_id: channel.logo_id ? `${channel.logo_id}` : '',
- });
-
- setChannelStreams(channel.streams || []);
- } else {
- formik.resetForm();
- setTvgFilter('');
- setLogoFilter('');
- }
- }, [channel, tvgsById, channelGroups]);
-
- // Memoize logo options to prevent infinite re-renders during background loading
- const logoOptions = useMemo(() => {
- return [{ id: '0', name: 'Default' }].concat(Object.values(logos));
- }, [logos]); // Only depend on logos object
-
- const renderLogoOption = ({ option, checked }) => {
- return (
-
-
-
- );
- };
-
- // Update the handler for when channel group modal is closed
- const handleChannelGroupModalClose = (newGroup) => {
- setChannelGroupModalOpen(false);
-
- // If a new group was created and returned, update the form with it
- if (newGroup && newGroup.id) {
- // Preserve all current form values while updating just the channel_group_id
- formik.setValues({
- ...formik.values,
- channel_group_id: `${newGroup.id}`,
- });
- }
- };
-
- if (!isOpen) {
- return <>>;
- }
-
- const filteredTvgs = tvgs
- .filter((tvg) => tvg.epg_source == selectedEPG)
- .filter(
- (tvg) =>
- tvg.name.toLowerCase().includes(tvgFilter.toLowerCase()) ||
- tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase())
- );
-
- const filteredLogos = logoOptions.filter((logo) =>
- logo.name.toLowerCase().includes(logoFilter.toLowerCase())
- );
-
- const filteredGroups = groupOptions.filter((group) =>
- group.name.toLowerCase().includes(groupFilter.toLowerCase())
- );
-
- return (
-
-
- Channels
-
- }
- styles={{ content: { '--mantine-color-body': '#27272A' } }}
- >
-
-
- );
-};
-
-export default ChannelsForm;
diff --git a/frontend/src/components/forms/DummyEPG.jsx b/frontend/src/components/forms/DummyEPG.jsx
index bb787722..9f9346da 100644
--- a/frontend/src/components/forms/DummyEPG.jsx
+++ b/frontend/src/components/forms/DummyEPG.jsx
@@ -728,6 +728,16 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
const handleSubmit = async (values) => {
try {
if (epg?.id) {
+ // Validate that we have a valid EPG object before updating
+ if (!epg || typeof epg !== 'object' || !epg.id) {
+ notifications.show({
+ title: 'Error',
+ message: 'Invalid EPG data. Please close and reopen this form.',
+ color: 'red',
+ });
+ return;
+ }
+
await API.updateEPG({ ...values, id: epg.id });
notifications.show({
title: 'Success',
diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx
index 8f8a3070..50c8553c 100644
--- a/frontend/src/components/forms/EPG.jsx
+++ b/frontend/src/components/forms/EPG.jsx
@@ -15,6 +15,7 @@ import {
Text,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
+import { notifications } from '@mantine/notifications';
const EPG = ({ epg = null, isOpen, onClose }) => {
const [sourceType, setSourceType] = useState('xmltv');
@@ -28,6 +29,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
api_key: '',
is_active: true,
refresh_interval: 24,
+ priority: 0,
},
validate: {
@@ -40,6 +42,16 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
const values = form.getValues();
if (epg?.id) {
+ // Validate that we have a valid EPG object before updating
+ if (!epg || typeof epg !== 'object' || !epg.id) {
+ notifications.show({
+ title: 'Error',
+ message: 'Invalid EPG data. Please close and reopen this form.',
+ color: 'red',
+ });
+ return;
+ }
+
await API.updateEPG({ id: epg.id, ...values });
} else {
await API.addEPG(values);
@@ -58,6 +70,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
api_key: epg.api_key,
is_active: epg.is_active,
refresh_interval: epg.refresh_interval,
+ priority: epg.priority ?? 0,
};
form.setValues(values);
setSourceType(epg.source_type);
@@ -137,14 +150,24 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
key={form.key('url')}
/>
-
+ )}
+
+
{/* Put checkbox at the same level as Refresh Interval */}
diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx
index adcd55d3..453d9494 100644
--- a/frontend/src/components/forms/GroupManager.jsx
+++ b/frontend/src/components/forms/GroupManager.jsx
@@ -183,6 +183,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null);
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
+ const [deletingGroup, setDeletingGroup] = useState(false);
// Memoize the channel groups array to prevent unnecessary re-renders
const channelGroupsArray = useMemo(
@@ -382,6 +383,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
const executeDeleteGroup = useCallback(
async (group) => {
+ setDeletingGroup(true);
try {
await API.deleteChannelGroup(group.id);
@@ -392,13 +394,14 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
});
await fetchGroupUsage(); // Refresh usage data
- setConfirmDeleteOpen(false);
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to delete group',
color: 'red',
});
+ } finally {
+ setDeletingGroup(false);
setConfirmDeleteOpen(false);
}
},
@@ -680,6 +683,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)}
+ loading={deletingGroup}
title="Confirm Group Deletion"
message={
groupToDelete ? (
@@ -706,6 +710,7 @@ This action cannot be undone.`}
opened={confirmCleanupOpen}
onClose={() => setConfirmCleanupOpen(false)}
onConfirm={executeCleanup}
+ loading={isCleaningUp}
title="Confirm Group Cleanup"
message={
diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx
index 71b412b4..68f4db8c 100644
--- a/frontend/src/components/forms/LiveGroupFilter.jsx
+++ b/frontend/src/components/forms/LiveGroupFilter.jsx
@@ -96,28 +96,30 @@ const LiveGroupFilter = ({
}
setGroupStates(
- playlist.channel_groups.map((group) => {
- // Parse custom_properties if present
- let customProps = {};
- if (group.custom_properties) {
- try {
- customProps =
- typeof group.custom_properties === 'string'
- ? JSON.parse(group.custom_properties)
- : group.custom_properties;
- } catch {
- customProps = {};
+ playlist.channel_groups
+ .filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist
+ .map((group) => {
+ // Parse custom_properties if present
+ let customProps = {};
+ if (group.custom_properties) {
+ try {
+ customProps =
+ typeof group.custom_properties === 'string'
+ ? JSON.parse(group.custom_properties)
+ : group.custom_properties;
+ } catch {
+ customProps = {};
+ }
}
- }
- return {
- ...group,
- name: channelGroups[group.channel_group].name,
- auto_channel_sync: group.auto_channel_sync || false,
- auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
- custom_properties: customProps,
- original_enabled: group.enabled,
- };
- })
+ return {
+ ...group,
+ name: channelGroups[group.channel_group].name,
+ auto_channel_sync: group.auto_channel_sync || false,
+ auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
+ custom_properties: customProps,
+ original_enabled: group.enabled,
+ };
+ })
);
}, [playlist, channelGroups]);
@@ -261,25 +263,42 @@ const LiveGroupFilter = ({
}}
>
{/* Group Enable/Disable Button */}
-
+
+
{/* Auto Sync Controls */}
@@ -367,7 +386,8 @@ const LiveGroupFilter = ({
if (
group.custom_properties?.custom_epg_id !==
undefined ||
- group.custom_properties?.force_dummy_epg
+ group.custom_properties?.force_dummy_epg ||
+ group.custom_properties?.force_epg_selected
) {
selectedValues.push('force_epg');
}
@@ -430,23 +450,20 @@ const LiveGroupFilter = ({
// Handle force_epg
if (selectedOptions.includes('force_epg')) {
- // Migrate from old force_dummy_epg if present
+ // Set default to force_dummy_epg if no EPG settings exist yet
if (
- newCustomProps.force_dummy_epg &&
- newCustomProps.custom_epg_id === undefined
+ newCustomProps.custom_epg_id ===
+ undefined &&
+ !newCustomProps.force_dummy_epg
) {
- // Migrate: force_dummy_epg=true becomes custom_epg_id=null
- newCustomProps.custom_epg_id = null;
- delete newCustomProps.force_dummy_epg;
- } else if (
- newCustomProps.custom_epg_id === undefined
- ) {
- // New configuration: initialize with null (no EPG/default dummy)
- newCustomProps.custom_epg_id = null;
+ // Default to "No EPG (Disabled)"
+ newCustomProps.force_dummy_epg = true;
}
} else {
- // Only remove custom_epg_id when deselected
+ // Remove all EPG settings when deselected
delete newCustomProps.custom_epg_id;
+ delete newCustomProps.force_dummy_epg;
+ delete newCustomProps.force_epg_selected;
}
// Handle group_override
@@ -1122,7 +1139,8 @@ const LiveGroupFilter = ({
{/* Show EPG selector when force_epg is selected */}
{(group.custom_properties?.custom_epg_id !== undefined ||
- group.custom_properties?.force_dummy_epg) && (
+ group.custom_properties?.force_dummy_epg ||
+ group.custom_properties?.force_epg_selected) && (
{
- // Handle migration from force_dummy_epg
+ // Show custom EPG if set
if (
group.custom_properties?.custom_epg_id !==
- undefined
+ undefined &&
+ group.custom_properties?.custom_epg_id !== null
) {
- // Convert to string, use '0' for null/no EPG
- return group.custom_properties.custom_epg_id ===
- null
- ? '0'
- : group.custom_properties.custom_epg_id.toString();
- } else if (
- group.custom_properties?.force_dummy_epg
- ) {
- // Show "No EPG" for old force_dummy_epg configs
+ return group.custom_properties.custom_epg_id.toString();
+ }
+ // Show "No EPG" if force_dummy_epg is set
+ if (group.custom_properties?.force_dummy_epg) {
return '0';
}
- return '0';
+ // Otherwise show empty/placeholder
+ return null;
})()}
onChange={(value) => {
- // Convert back: '0' means no EPG (null)
- const newValue =
- value === '0' ? null : parseInt(value);
- setGroupStates(
- groupStates.map((state) => {
- if (
- state.channel_group === group.channel_group
- ) {
- return {
- ...state,
- custom_properties: {
+ if (value === '0') {
+ // "No EPG (Disabled)" selected - use force_dummy_epg
+ setGroupStates(
+ groupStates.map((state) => {
+ if (
+ state.channel_group ===
+ group.channel_group
+ ) {
+ const newProps = {
...state.custom_properties,
- custom_epg_id: newValue,
- },
- };
- }
- return state;
- })
- );
+ };
+ delete newProps.custom_epg_id;
+ delete newProps.force_epg_selected;
+ newProps.force_dummy_epg = true;
+ return {
+ ...state,
+ custom_properties: newProps,
+ };
+ }
+ return state;
+ })
+ );
+ } else if (value) {
+ // Specific EPG source selected
+ const epgId = parseInt(value);
+ setGroupStates(
+ groupStates.map((state) => {
+ if (
+ state.channel_group ===
+ group.channel_group
+ ) {
+ const newProps = {
+ ...state.custom_properties,
+ };
+ newProps.custom_epg_id = epgId;
+ delete newProps.force_dummy_epg;
+ delete newProps.force_epg_selected;
+ return {
+ ...state,
+ custom_properties: newProps,
+ };
+ }
+ return state;
+ })
+ );
+ } else {
+ // Cleared - remove all EPG settings
+ setGroupStates(
+ groupStates.map((state) => {
+ if (
+ state.channel_group ===
+ group.channel_group
+ ) {
+ const newProps = {
+ ...state.custom_properties,
+ };
+ delete newProps.custom_epg_id;
+ delete newProps.force_dummy_epg;
+ delete newProps.force_epg_selected;
+ return {
+ ...state,
+ custom_properties: newProps,
+ };
+ }
+ return state;
+ })
+ );
+ }
}}
data={[
{ value: '0', label: 'No EPG (Disabled)' },
- ...epgSources.map((source) => ({
- value: source.id.toString(),
- label: `${source.name} (${
- source.source_type === 'dummy'
- ? 'Dummy'
- : source.source_type === 'xmltv'
- ? 'XMLTV'
- : source.source_type ===
- 'schedules_direct'
- ? 'Schedules Direct'
- : source.source_type
- })`,
- })),
+ ...[...epgSources]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((source) => ({
+ value: source.id.toString(),
+ label: `${source.name} (${
+ source.source_type === 'dummy'
+ ? 'Dummy'
+ : source.source_type === 'xmltv'
+ ? 'XMLTV'
+ : source.source_type ===
+ 'schedules_direct'
+ ? 'Schedules Direct'
+ : source.source_type
+ })`,
+ })),
]}
clearable
searchable
diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx
index 916a2c30..353cd50e 100644
--- a/frontend/src/components/forms/LoginForm.jsx
+++ b/frontend/src/components/forms/LoginForm.jsx
@@ -1,7 +1,24 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
-import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core';
+import API from '../../api';
+import {
+ Paper,
+ Title,
+ TextInput,
+ Button,
+ Center,
+ Stack,
+ Text,
+ Image,
+ Group,
+ Divider,
+ Modal,
+ Anchor,
+ Code,
+ Checkbox,
+} from '@mantine/core';
+import logo from '../../assets/logo.png';
const LoginForm = () => {
const login = useAuthStore((s) => s.login);
@@ -11,12 +28,69 @@ const LoginForm = () => {
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
+ const [rememberMe, setRememberMe] = useState(false);
+ const [savePassword, setSavePassword] = useState(false);
+ const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false);
+ const [version, setVersion] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
- // useEffect(() => {
- // if (isAuthenticated) {
- // navigate('/channels');
- // }
- // }, [isAuthenticated, navigate]);
+ // Simple base64 encoding/decoding for localStorage
+ // Note: This is obfuscation, not encryption. Use browser's password manager for real security.
+ const encodePassword = (password) => {
+ try {
+ return btoa(password);
+ } catch (error) {
+ console.error('Encoding error:', error);
+ return null;
+ }
+ };
+
+ const decodePassword = (encoded) => {
+ try {
+ return atob(encoded);
+ } catch (error) {
+ console.error('Decoding error:', error);
+ return '';
+ }
+ };
+
+ useEffect(() => {
+ // Fetch version info
+ API.getVersion().then((data) => {
+ setVersion(data?.version);
+ });
+ }, []);
+
+ useEffect(() => {
+ // Load saved username if it exists
+ const savedUsername = localStorage.getItem(
+ 'dispatcharr_remembered_username'
+ );
+ const savedPassword = localStorage.getItem('dispatcharr_saved_password');
+
+ if (savedUsername) {
+ setFormData((prev) => ({ ...prev, username: savedUsername }));
+ setRememberMe(true);
+
+ if (savedPassword) {
+ try {
+ const decrypted = decodePassword(savedPassword);
+ if (decrypted) {
+ setFormData((prev) => ({ ...prev, password: decrypted }));
+ setSavePassword(true);
+ }
+ } catch {
+ // If decoding fails, just skip
+ }
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ navigate('/channels');
+ }
+ }, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
@@ -27,13 +101,38 @@ const LoginForm = () => {
const handleSubmit = async (e) => {
e.preventDefault();
- await login(formData);
+ setIsLoading(true);
try {
+ await login(formData);
+
+ // Save username if remember me is checked
+ if (rememberMe) {
+ localStorage.setItem(
+ 'dispatcharr_remembered_username',
+ formData.username
+ );
+
+ // Save password if save password is checked
+ if (savePassword) {
+ const encoded = encodePassword(formData.password);
+ if (encoded) {
+ localStorage.setItem('dispatcharr_saved_password', encoded);
+ }
+ } else {
+ localStorage.removeItem('dispatcharr_saved_password');
+ }
+ } else {
+ localStorage.removeItem('dispatcharr_remembered_username');
+ localStorage.removeItem('dispatcharr_saved_password');
+ }
+
await initData();
- navigate('/channels');
+ // Navigation will happen automatically via the useEffect or route protection
} catch (e) {
console.log(`Failed to login: ${e}`);
+ await logout();
+ setIsLoading(false);
}
};
@@ -45,11 +144,29 @@ const LoginForm = () => {
>
-
- Login
-
+
+
+
+ Dispatcharr
+
+
+ Welcome back! Please log in to continue.
+
+
+
+
+ {version && (
+
+ v{version}
+
+ )}
+
+ setForgotPasswordOpened(false)}
+ title="Reset Your Password"
+ centered
+ >
+
+
+ To reset your password, your administrator needs to run a Django
+ management command:
+
+
+
+ If running with Docker:
+
+
+ docker exec <container_name> python manage.py changepassword
+ <username>
+
+
+
+
+ If running locally:
+
+ python manage.py changepassword <username>
+
+
+ The command will prompt for a new password. Replace
+ <container_name> with your Docker container name
+ and <username> with the account username.
+
+
+ Please contact your system administrator to perform a password
+ reset.
+
+
+
);
};
diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx
index 8362b891..c6e63ba6 100644
--- a/frontend/src/components/forms/Logo.jsx
+++ b/frontend/src/components/forms/Logo.jsx
@@ -1,5 +1,6 @@
-import React, { useState, useEffect } from 'react';
-import { useFormik } from 'formik';
+import React, { useState, useEffect, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import {
Modal,
@@ -18,143 +19,148 @@ import { Upload, FileImage, X } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import API from '../../api';
+const schema = Yup.object({
+ name: Yup.string().required('Name is required'),
+ url: Yup.string()
+ .required('URL is required')
+ .test(
+ 'valid-url-or-path',
+ 'Must be a valid URL or local file path',
+ (value) => {
+ if (!value) return false;
+ // Allow local file paths starting with /data/logos/
+ if (value.startsWith('/data/logos/')) return true;
+ // Allow valid URLs
+ try {
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ ),
+});
+
const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const [logoPreview, setLogoPreview] = useState(null);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); // Store selected file
- const formik = useFormik({
- initialValues: {
- name: '',
- url: '',
- },
- validationSchema: Yup.object({
- name: Yup.string().required('Name is required'),
- url: Yup.string()
- .required('URL is required')
- .test(
- 'valid-url-or-path',
- 'Must be a valid URL or local file path',
- (value) => {
- if (!value) return false;
- // Allow local file paths starting with /data/logos/
- if (value.startsWith('/data/logos/')) return true;
- // Allow valid URLs
- try {
- new URL(value);
- return true;
- } catch {
- return false;
- }
- }
- ),
+ const defaultValues = useMemo(
+ () => ({
+ name: logo?.name || '',
+ url: logo?.url || '',
}),
- onSubmit: async (values, { setSubmitting }) => {
- try {
- setUploading(true);
- let uploadResponse = null; // Store upload response for later use
+ [logo]
+ );
- // If we have a selected file, upload it first
- if (selectedFile) {
- try {
- uploadResponse = await API.uploadLogo(selectedFile, values.name);
- // Use the uploaded file data instead of form values
- values.name = uploadResponse.name;
- values.url = uploadResponse.url;
- } catch (uploadError) {
- let errorMessage = 'Failed to upload logo file';
-
- if (
- uploadError.code === 'NETWORK_ERROR' ||
- uploadError.message?.includes('timeout')
- ) {
- errorMessage = 'Upload timed out. Please try again.';
- } else if (uploadError.status === 413) {
- errorMessage = 'File too large. Please choose a smaller file.';
- } else if (uploadError.body?.error) {
- errorMessage = uploadError.body.error;
- }
-
- notifications.show({
- title: 'Upload Error',
- message: errorMessage,
- color: 'red',
- });
- return; // Don't proceed with creation if upload fails
- }
- }
-
- // Now create or update the logo with the final values
- // Only proceed if we don't already have a logo from file upload
- if (logo) {
- const updatedLogo = await API.updateLogo(logo.id, values);
- notifications.show({
- title: 'Success',
- message: 'Logo updated successfully',
- color: 'green',
- });
- onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
- } else if (!selectedFile) {
- // Only create a new logo entry if we're not uploading a file
- // (file upload already created the logo entry)
- const newLogo = await API.createLogo(values);
- notifications.show({
- title: 'Success',
- message: 'Logo created successfully',
- color: 'green',
- });
- onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
- } else {
- // File was uploaded and logo was already created
- notifications.show({
- title: 'Success',
- message: 'Logo uploaded successfully',
- color: 'green',
- });
- onSuccess?.({ type: 'create', logo: uploadResponse });
- }
- onClose();
- } catch (error) {
- let errorMessage = logo
- ? 'Failed to update logo'
- : 'Failed to create logo';
-
- // Handle specific timeout errors
- if (
- error.code === 'NETWORK_ERROR' ||
- error.message?.includes('timeout')
- ) {
- errorMessage = 'Request timed out. Please try again.';
- } else if (error.response?.data?.error) {
- errorMessage = error.response.data.error;
- }
-
- notifications.show({
- title: 'Error',
- message: errorMessage,
- color: 'red',
- });
- } finally {
- setSubmitting(false);
- setUploading(false);
- }
- },
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ reset,
+ setValue,
+ watch,
+ } = useForm({
+ defaultValues,
+ resolver: yupResolver(schema),
});
- useEffect(() => {
- if (logo) {
- formik.setValues({
- name: logo.name || '',
- url: logo.url || '',
+ const onSubmit = async (values) => {
+ try {
+ setUploading(true);
+ let uploadResponse = null; // Store upload response for later use
+
+ // If we have a selected file, upload it first
+ if (selectedFile) {
+ try {
+ uploadResponse = await API.uploadLogo(selectedFile, values.name);
+ // Use the uploaded file data instead of form values
+ values.name = uploadResponse.name;
+ values.url = uploadResponse.url;
+ } catch (uploadError) {
+ let errorMessage = 'Failed to upload logo file';
+
+ if (
+ uploadError.code === 'NETWORK_ERROR' ||
+ uploadError.message?.includes('timeout')
+ ) {
+ errorMessage = 'Upload timed out. Please try again.';
+ } else if (uploadError.status === 413) {
+ errorMessage = 'File too large. Please choose a smaller file.';
+ } else if (uploadError.body?.error) {
+ errorMessage = uploadError.body.error;
+ }
+
+ notifications.show({
+ title: 'Upload Error',
+ message: errorMessage,
+ color: 'red',
+ });
+ return; // Don't proceed with creation if upload fails
+ }
+ }
+
+ // Now create or update the logo with the final values
+ // Only proceed if we don't already have a logo from file upload
+ if (logo) {
+ const updatedLogo = await API.updateLogo(logo.id, values);
+ notifications.show({
+ title: 'Success',
+ message: 'Logo updated successfully',
+ color: 'green',
+ });
+ onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
+ } else if (!selectedFile) {
+ // Only create a new logo entry if we're not uploading a file
+ // (file upload already created the logo entry)
+ const newLogo = await API.createLogo(values);
+ notifications.show({
+ title: 'Success',
+ message: 'Logo created successfully',
+ color: 'green',
+ });
+ onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
+ } else {
+ // File was uploaded and logo was already created
+ notifications.show({
+ title: 'Success',
+ message: 'Logo uploaded successfully',
+ color: 'green',
+ });
+ onSuccess?.({ type: 'create', logo: uploadResponse });
+ }
+ onClose();
+ } catch (error) {
+ let errorMessage = logo
+ ? 'Failed to update logo'
+ : 'Failed to create logo';
+
+ // Handle specific timeout errors
+ if (
+ error.code === 'NETWORK_ERROR' ||
+ error.message?.includes('timeout')
+ ) {
+ errorMessage = 'Request timed out. Please try again.';
+ } else if (error.response?.data?.error) {
+ errorMessage = error.response.data.error;
+ }
+
+ notifications.show({
+ title: 'Error',
+ message: errorMessage,
+ color: 'red',
});
- setLogoPreview(logo.cache_url);
- } else {
- formik.resetForm();
- setLogoPreview(null);
+ } finally {
+ setUploading(false);
}
- // Clear any selected file when logo changes
+ };
+
+ useEffect(() => {
+ reset(defaultValues);
+ setLogoPreview(logo?.cache_url || null);
setSelectedFile(null);
- }, [logo, isOpen]);
+ }, [defaultValues, logo, reset]);
const handleFileSelect = (files) => {
if (files.length === 0) return;
@@ -180,18 +186,19 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
setLogoPreview(previewUrl);
// Auto-fill the name field if empty
- if (!formik.values.name) {
+ const currentName = watch('name');
+ if (!currentName) {
const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
- formik.setFieldValue('name', nameWithoutExtension);
+ setValue('name', nameWithoutExtension);
}
// Set a placeholder URL (will be replaced after upload)
- formik.setFieldValue('url', 'file://pending-upload');
+ setValue('url', 'file://pending-upload');
};
const handleUrlChange = (event) => {
const url = event.target.value;
- formik.setFieldValue('url', url);
+ setValue('url', url);
// Clear any selected file when manually entering URL
if (selectedFile) {
@@ -219,7 +226,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '');
if (nameWithoutExtension) {
- formik.setFieldValue('name', nameWithoutExtension);
+ setValue('name', nameWithoutExtension);
}
} catch (error) {
// If the URL is invalid, do nothing.
@@ -244,7 +251,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
title={logo ? 'Edit Logo' : 'Add Logo'}
size="md"
>
-
) : epgObj ? (
{epgObj.name}
+ ) : isEpgDataPending ? (
+
) : (
Not Assigned
)}
@@ -887,8 +969,10 @@ const ChannelsTable = ({}) => {
// columns from being recreated during drag operations (which causes infinite loops).
// The column.size values are only used for INITIAL sizing - TanStack Table manages
// the actual sizes through its own state after initialization.
+ // Note: logos is intentionally excluded - LazyLogo components handle their own logo data
+ // from the store, so we don't need to recreate columns when logos load.
// eslint-disable-next-line react-hooks/exhaustive-deps
- [selectedProfileId, channelGroups, logos, theme]
+ [selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
);
const renderHeaderCell = (header) => {
@@ -949,6 +1033,7 @@ const ChannelsTable = ({}) => {
size="xs"
variant="unstyled"
className="table-input-header"
+ leftSection={}
/>
{React.createElement(sortingIcon, {
@@ -1324,16 +1409,21 @@ const ChannelsTable = ({}) => {
deleteChannels={deleteChannels}
selectedTableIds={table.selectedTableIds}
table={table}
+ showDisabled={showDisabled}
+ setShowDisabled={setShowDisabled}
+ showOnlyStreamlessChannels={showOnlyStreamlessChannels}
+ setShowOnlyStreamlessChannels={setShowOnlyStreamlessChannels}
/>
{/* Table or ghost empty state inside Paper */}
- {Object.keys(channels).length === 0 && (
-
- )}
+ {channelsTableLength === 0 &&
+ Object.keys(channels).length === 0 && (
+
+ )}
- {Object.keys(channels).length > 0 && (
+ {(channelsTableLength > 0 || Object.keys(channels).length > 0) && (
{
? executeDeleteChannels()
: executeDeleteChannel(deleteTarget)
}
+ loading={deleting}
title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Channel Deletion`}
message={
isBulkDelete ? (
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
index b7e04d7d..2263806f 100644
--- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
+++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
@@ -12,20 +12,22 @@ import {
Text,
TextInput,
Tooltip,
- UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import {
ArrowDown01,
Binary,
- Check,
CircleCheck,
- Ellipsis,
EllipsisVertical,
SquareMinus,
SquarePen,
SquarePlus,
Settings,
+ Eye,
+ EyeOff,
+ Filter,
+ Square,
+ SquareCheck,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
@@ -36,6 +38,7 @@ import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
import GroupManager from '../../forms/GroupManager';
import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
+import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
@@ -102,6 +105,10 @@ const ChannelTableHeader = ({
editChannel,
deleteChannels,
selectedTableIds,
+ showDisabled,
+ setShowDisabled,
+ showOnlyStreamlessChannels,
+ setShowOnlyStreamlessChannels,
}) => {
const theme = useMantineTheme();
@@ -111,6 +118,12 @@ const ChannelTableHeader = ({
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] =
useState(false);
const [profileToDelete, setProfileToDelete] = useState(null);
+ const [deletingProfile, setDeletingProfile] = useState(false);
+ const [profileModalState, setProfileModalState] = useState({
+ opened: false,
+ mode: null,
+ profileId: null,
+ });
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
@@ -122,6 +135,15 @@ const ChannelTableHeader = ({
setAssignNumbersModalOpen(false);
};
+ const closeProfileModal = () => {
+ setProfileModalState({ opened: false, mode: null, profileId: null });
+ };
+
+ const openProfileModal = (mode, profileId) => {
+ if (!profiles[profileId]) return;
+ setProfileModalState({ opened: true, mode, profileId });
+ };
+
const deleteProfile = async (id) => {
// Get profile details for the confirmation dialog
const profileObj = profiles[id];
@@ -136,8 +158,13 @@ const ChannelTableHeader = ({
};
const executeDeleteProfile = async (id) => {
- await API.deleteChannelProfile(id);
- setConfirmDeleteProfileOpen(false);
+ setDeletingProfile(true);
+ try {
+ await API.deleteChannelProfile(id);
+ } finally {
+ setDeletingProfile(false);
+ setConfirmDeleteProfileOpen(false);
+ }
};
const matchEpg = async () => {
@@ -186,26 +213,20 @@ const ChannelTableHeader = ({
}
};
- const renderProfileOption = ({ option, checked }) => {
- return (
-
- {option.label}
- {option.value != '0' && (
- {
- e.stopPropagation();
- deleteProfile(option.value);
- }}
- disabled={authUser.user_level != USER_LEVELS.ADMIN}
- >
-
-
- )}
-
- );
+ const renderModalOption = renderProfileOption(
+ theme,
+ profiles,
+ openProfileModal,
+ deleteProfile,
+ authUser
+ );
+
+ const toggleShowDisabled = () => {
+ setShowDisabled(!showDisabled);
+ };
+
+ const toggleShowOnlyStreamlessChannels = () => {
+ setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels);
};
return (
@@ -220,7 +241,8 @@ const ChannelTableHeader = ({
label: profile.name,
value: `${profile.id}`,
}))}
- renderOption={renderProfileOption}
+ renderOption={renderModalOption}
+ style={{ minWidth: 190 }}
/>
@@ -236,6 +258,41 @@ const ChannelTableHeader = ({
}}
>
+
+
}
variant="default"
@@ -324,6 +381,18 @@ const ChannelTableHeader = ({
+
+
setConfirmDeleteProfileOpen(false)}
onConfirm={() => executeDeleteProfile(profileToDelete?.id)}
+ loading={deletingProfile}
title="Confirm Profile Deletion"
message={
profileToDelete ? (
diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
index 92643fc9..004687dd 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
@@ -105,6 +105,7 @@ const CustomTableHeader = ({
...(header.column.columnDef.style &&
header.column.columnDef.style),
height: '100%',
+ width: '100%',
paddingRight: header.column.getCanResize() ? '8px' : '0px', // Add padding for resize handle
}}
>
diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx
index 53f9a72c..f6952542 100644
--- a/frontend/src/components/tables/EPGsTable.jsx
+++ b/frontend/src/components/tables/EPGsTable.jsx
@@ -110,6 +110,7 @@ const EPGsTable = () => {
const [deleteTarget, setDeleteTarget] = useState(null);
const [epgToDelete, setEpgToDelete] = useState(null);
const [data, setData] = useState([]);
+ const [deleting, setDeleting] = useState(false);
const epgs = useEPGsStore((s) => s.epgs);
const refreshProgress = useEPGsStore((s) => s.refreshProgress);
@@ -131,6 +132,12 @@ const EPGsTable = () => {
const toggleActive = async (epg) => {
try {
+ // Validate that epg is a valid object with an id
+ if (!epg || typeof epg !== 'object' || !epg.id) {
+ console.error('toggleActive called with invalid epg:', epg);
+ return;
+ }
+
// Send only the is_active field to trigger our special handling
await API.updateEPG(
{
@@ -154,6 +161,9 @@ const EPGsTable = () => {
case 'downloading':
label = 'Downloading';
break;
+ case 'extracting':
+ label = 'Extracting';
+ break;
case 'parsing_channels':
label = 'Parsing Channels';
break;
@@ -164,6 +174,22 @@ const EPGsTable = () => {
return null;
}
+ // Build additional info string from progress data
+ let additionalInfo = '';
+ if (progress.message) {
+ additionalInfo = progress.message;
+ } else if (
+ progress.processed !== undefined &&
+ progress.channels !== undefined
+ ) {
+ additionalInfo = `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`;
+ } else if (
+ progress.processed !== undefined &&
+ progress.total !== undefined
+ ) {
+ additionalInfo = `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`;
+ }
+
return (
@@ -175,14 +201,19 @@ const EPGsTable = () => {
style={{ margin: '2px 0' }}
/>
{progress.speed && (
- Speed: {parseInt(progress.speed)} KB/s
+
+ Speed: {parseInt(progress.speed)} KB/s
+
+ )}
+ {additionalInfo && (
+
+ {additionalInfo}
+
)}
);
};
- console.log(epgs);
-
const columns = useMemo(
//column definitions...
() => [
@@ -282,14 +313,35 @@ const EPGsTable = () => {
// Show success message for successful sources
if (data.status === 'success') {
+ const successMessage =
+ data.last_message || 'EPG data refreshed successfully';
return (
-
- EPG data refreshed successfully
-
+
+
+ {successMessage}
+
+
+ );
+ }
+
+ // Show last_message for idle sources (from previous refresh)
+ if (data.status === 'idle' && data.last_message) {
+ return (
+
+
+ {data.last_message}
+
+
);
}
@@ -380,10 +432,13 @@ const EPGsTable = () => {
};
const executeDeleteEPG = async (id) => {
- setIsLoading(true);
- await API.deleteEPG(id);
- setIsLoading(false);
- setConfirmDeleteOpen(false);
+ setDeleting(true);
+ try {
+ await API.deleteEPG(id);
+ } finally {
+ setDeleting(false);
+ setConfirmDeleteOpen(false);
+ }
};
const refreshEPG = async (id) => {
@@ -637,6 +692,7 @@ const EPGsTable = () => {
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => executeDeleteEPG(deleteTarget)}
+ loading={deleting}
title="Confirm EPG Source Deletion"
message={
epgToDelete ? (
diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx
index 0c4f32b4..7c718590 100644
--- a/frontend/src/components/tables/LogosTable.jsx
+++ b/frontend/src/components/tables/LogosTable.jsx
@@ -115,6 +115,7 @@ const LogosTable = () => {
pageSize: pageSize,
});
const [paginationString, setPaginationString] = useState('');
+ const tableRef = React.useRef(null);
// Debounce the name filter
useEffect(() => {
@@ -162,6 +163,14 @@ const LogosTable = () => {
/**
* Functions
*/
+ const clearSelections = useCallback(() => {
+ setSelectedRows(new Set());
+ // Clear table's internal selection state if table is initialized
+ if (tableRef.current?.setSelectedTableIds) {
+ tableRef.current.setSelectedTableIds([]);
+ }
+ }, []);
+
const executeDeleteLogo = useCallback(
async (id, deleteFile = false) => {
setIsLoading(true);
@@ -180,15 +189,15 @@ const LogosTable = () => {
color: 'red',
});
} finally {
- setIsLoading(false);
setConfirmDeleteOpen(false);
setDeleteTarget(null);
setLogoToDelete(null);
setIsBulkDelete(false);
- setSelectedRows(new Set()); // Clear selections
+ clearSelections(); // Clear selections
+ setIsLoading(false);
}
},
- [fetchAllLogos]
+ [fetchAllLogos, clearSelections]
);
const executeBulkDelete = useCallback(
@@ -212,13 +221,13 @@ const LogosTable = () => {
color: 'red',
});
} finally {
- setIsLoading(false);
setConfirmDeleteOpen(false);
setIsBulkDelete(false);
- setSelectedRows(new Set()); // Clear selections
+ clearSelections(); // Clear selections
+ setIsLoading(false);
}
},
- [selectedRows, fetchAllLogos]
+ [selectedRows, fetchAllLogos, clearSelections]
);
const executeCleanupUnused = useCallback(
@@ -226,7 +235,6 @@ const LogosTable = () => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedLogos(deleteFiles);
- await fetchAllLogos(); // Refresh all logos to maintain full view
let message = `Successfully deleted ${result.deleted_count} unused logos`;
if (result.local_files_deleted > 0) {
@@ -238,6 +246,9 @@ const LogosTable = () => {
message: message,
color: 'green',
});
+
+ // Force refresh all logos after cleanup to maintain full view
+ await fetchAllLogos(true);
} catch (error) {
notifications.show({
title: 'Cleanup Failed',
@@ -247,10 +258,10 @@ const LogosTable = () => {
} finally {
setIsCleaningUp(false);
setConfirmCleanupOpen(false);
- setSelectedRows(new Set()); // Clear selections after cleanup
+ clearSelections(); // Clear selections after cleanup
}
},
- [fetchAllLogos]
+ [fetchAllLogos, clearSelections]
);
const editLogo = useCallback(async (logo = null) => {
@@ -287,10 +298,10 @@ const LogosTable = () => {
if (checked) {
setSelectedRows(new Set(data.map((logo) => logo.id)));
} else {
- setSelectedRows(new Set());
+ clearSelections();
}
},
- [data]
+ [data, clearSelections]
);
const deleteBulkLogos = useCallback(() => {
@@ -308,8 +319,8 @@ const LogosTable = () => {
// Clear selections when logos data changes (e.g., after filtering)
useEffect(() => {
- setSelectedRows(new Set());
- }, [data.length]);
+ clearSelections();
+ }, [data.length, clearSelections]);
// Update pagination when pageSize changes
useEffect(() => {
@@ -614,6 +625,11 @@ const LogosTable = () => {
},
});
+ // Store table reference for clearing selections
+ React.useEffect(() => {
+ tableRef.current = table;
+ }, [table]);
+
return (
<>
{
}}
>
-
-
- Logos
-
-
- ({data.length} logo{data.length !== 1 ? 's' : ''})
-
-
-
{
setConfirmDeleteOpen(false)}
+ loading={isLoading}
onConfirm={(deleteFiles) => {
if (isBulkDelete) {
executeBulkDelete(deleteFiles);
@@ -870,6 +868,7 @@ const LogosTable = () => {
setConfirmCleanupOpen(false)}
+ loading={isCleaningUp}
onConfirm={executeCleanupUnused}
title="Cleanup Unused Logos"
message={
diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx
index 64b401d8..6e88af56 100644
--- a/frontend/src/components/tables/M3UsTable.jsx
+++ b/frontend/src/components/tables/M3UsTable.jsx
@@ -140,6 +140,7 @@ const M3UTable = () => {
const [playlistToDelete, setPlaylistToDelete] = useState(null);
const [data, setData] = useState([]);
const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
+ const [deleting, setDeleting] = useState(false);
const playlists = usePlaylistsStore((s) => s.playlists);
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
@@ -400,9 +401,14 @@ const M3UTable = () => {
const executeDeletePlaylist = async (id) => {
setIsLoading(true);
- await API.deletePlaylist(id);
- setIsLoading(false);
- setConfirmDeleteOpen(false);
+ setDeleting(true);
+ try {
+ await API.deletePlaylist(id);
+ } finally {
+ setDeleting(false);
+ setIsLoading(false);
+ setConfirmDeleteOpen(false);
+ }
};
const toggleActive = async (playlist) => {
@@ -893,6 +899,7 @@ const M3UTable = () => {
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => executeDeletePlaylist(deleteTarget)}
+ loading={deleting}
title="Confirm M3U Account Deletion"
message={
playlistToDelete ? (
diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx
index da2acd30..d34d58ea 100644
--- a/frontend/src/components/tables/StreamProfilesTable.jsx
+++ b/frontend/src/components/tables/StreamProfilesTable.jsx
@@ -155,7 +155,7 @@ const StreamProfiles = () => {
};
const deleteStreamProfile = async (id) => {
- if (id == settings['default-stream-profile'].value) {
+ if (id == settings.default_stream_profile) {
notifications.show({
title: 'Cannot delete default stream-profile',
color: 'red.5',
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index d309552c..21b13baf 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -1,4 +1,10 @@
-import React, { useEffect, useMemo, useCallback, useState } from 'react';
+import React, {
+ useEffect,
+ useMemo,
+ useCallback,
+ useState,
+ useRef,
+} from 'react';
import API from '../../api';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
@@ -13,6 +19,7 @@ import {
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
+ Search,
} from 'lucide-react';
import {
TextInput,
@@ -51,6 +58,7 @@ import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../ConfirmationDialog';
+import CreateChannelModal from '../modals/CreateChannelModal';
const StreamRowActions = ({
theme,
@@ -166,8 +174,9 @@ const StreamRowActions = ({
);
};
-const StreamsTable = () => {
+const StreamsTable = ({ onReady }) => {
const theme = useMantineTheme();
+ const hasSignaledReady = useRef(false);
/**
* useState
@@ -182,28 +191,31 @@ const StreamsTable = () => {
const [pageCount, setPageCount] = useState(0);
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
- const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
+ const [sorting, setSorting] = useState([{ id: 'name', desc: false }]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
- // Channel numbering modal state
+ // Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
useState(false);
const [numberingMode, setNumberingMode] = useState('provider'); // 'provider', 'auto', or 'custom'
const [customStartNumber, setCustomStartNumber] = useState(1);
const [rememberChoice, setRememberChoice] = useState(false);
+ const [bulkSelectedProfileIds, setBulkSelectedProfileIds] = useState([]);
- // Single channel numbering modal state
+ // Channel creation modal state (single)
const [singleChannelModalOpen, setSingleChannelModalOpen] = useState(false);
const [singleChannelMode, setSingleChannelMode] = useState('provider'); // 'provider', 'auto', or 'specific'
const [specificChannelNumber, setSpecificChannelNumber] = useState(1);
const [rememberSingleChoice, setRememberSingleChoice] = useState(false);
const [currentStreamForChannel, setCurrentStreamForChannel] = useState(null);
+ const [singleSelectedProfileIds, setSingleSelectedProfileIds] = useState([]);
// Confirmation dialog state
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [streamToDelete, setStreamToDelete] = useState(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
+ const [deleting, setDeleting] = useState(false);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
@@ -252,6 +264,8 @@ const StreamsTable = () => {
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
);
+ const channelProfiles = useChannelsStore((s) => s.profiles);
+ const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const [tableSize, _] = useLocalStorage('table-size', 'default');
@@ -298,6 +312,7 @@ const StreamsTable = () => {
),
},
{
+ header: 'Group',
id: 'group',
accessorFn: (row) =>
channelGroups[row.channel_group]
@@ -319,6 +334,7 @@ const StreamsTable = () => {
),
},
{
+ header: 'M3U',
id: 'm3u',
size: columnSizing.m3u || 150,
accessorFn: (row) =>
@@ -385,7 +401,14 @@ const StreamsTable = () => {
// Apply sorting
if (sorting.length > 0) {
- const sortField = sorting[0].id;
+ const columnId = sorting[0].id;
+ // Map frontend column IDs to backend field names
+ const fieldMapping = {
+ name: 'name',
+ group: 'channel_group__name',
+ m3u: 'm3u_account__name',
+ };
+ const sortField = fieldMapping[columnId] || columnId;
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
@@ -420,6 +443,12 @@ const StreamsTable = () => {
// Generate the string
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
+
+ // Signal that initial data load is complete
+ if (!hasSignaledReady.current && onReady) {
+ hasSignaledReady.current = true;
+ onReady();
+ }
} catch (error) {
console.error('Error fetching data:', error);
}
@@ -432,12 +461,18 @@ const StreamsTable = () => {
groupsLoaded,
channelGroups,
fetchChannelGroups,
+ onReady,
]);
// Bulk creation: create channels from selected streams asynchronously
const createChannelsFromStreams = async () => {
if (selectedStreamIds.length === 0) return;
+ // Set default profile selection based on current profile filter
+ const defaultProfileIds =
+ selectedProfileId === '0' ? ['all'] : [selectedProfileId];
+ setBulkSelectedProfileIds(defaultProfileIds);
+
// Check if user has suppressed the channel numbering dialog
const actionKey = 'channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
@@ -454,7 +489,10 @@ const StreamsTable = () => {
? 0
: Number(savedStartNumber);
- await executeChannelCreation(startingChannelNumberValue);
+ await executeChannelCreation(
+ startingChannelNumberValue,
+ defaultProfileIds
+ );
} else {
// Show the modal to let user choose
setChannelNumberingModalOpen(true);
@@ -462,15 +500,32 @@ const StreamsTable = () => {
};
// Separate function to actually execute the channel creation
- const executeChannelCreation = async (startingChannelNumberValue) => {
+ const executeChannelCreation = async (
+ startingChannelNumberValue,
+ profileIds = null
+ ) => {
try {
- const selectedChannelProfileId =
- useChannelsStore.getState().selectedProfileId;
+ // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
+ let channelProfileIds;
+ if (profileIds) {
+ if (profileIds.includes('none')) {
+ channelProfileIds = [];
+ } else if (profileIds.includes('all')) {
+ channelProfileIds = null;
+ } else {
+ channelProfileIds = profileIds
+ .filter((id) => id !== 'all' && id !== 'none')
+ .map((id) => parseInt(id));
+ }
+ } else {
+ channelProfileIds =
+ selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
+ }
// Use the async API for all bulk operations
const response = await API.createChannelsFromStreamsAsync(
selectedStreamIds,
- selectedChannelProfileId !== '0' ? [selectedChannelProfileId] : null,
+ channelProfileIds,
startingChannelNumberValue
);
@@ -509,7 +564,10 @@ const StreamsTable = () => {
: Number(customStartNumber);
setChannelNumberingModalOpen(false);
- await executeChannelCreation(startingChannelNumberValue);
+ await executeChannelCreation(
+ startingChannelNumberValue,
+ bulkSelectedProfileIds
+ );
};
const editStream = async (stream = null) => {
@@ -533,12 +591,17 @@ const StreamsTable = () => {
};
const executeDeleteStream = async (id) => {
- await API.deleteStream(id);
- fetchData();
- // Clear the selection for the deleted stream
- setSelectedStreamIds([]);
- table.setSelectedTableIds([]);
- setConfirmDeleteOpen(false);
+ setDeleting(true);
+ try {
+ await API.deleteStream(id);
+ fetchData();
+ // Clear the selection for the deleted stream
+ setSelectedStreamIds([]);
+ table.setSelectedTableIds([]);
+ } finally {
+ setDeleting(false);
+ setConfirmDeleteOpen(false);
+ }
};
const deleteStreams = async () => {
@@ -555,12 +618,17 @@ const StreamsTable = () => {
const executeDeleteStreams = async () => {
setIsLoading(true);
- await API.deleteStreams(selectedStreamIds);
- setIsLoading(false);
- fetchData();
- setSelectedStreamIds([]);
- table.setSelectedTableIds([]);
- setConfirmDeleteOpen(false);
+ setDeleting(true);
+ try {
+ await API.deleteStreams(selectedStreamIds);
+ fetchData();
+ setSelectedStreamIds([]);
+ table.setSelectedTableIds([]);
+ } finally {
+ setDeleting(false);
+ setIsLoading(false);
+ setConfirmDeleteOpen(false);
+ }
};
const closeStreamForm = () => {
@@ -571,6 +639,11 @@ const StreamsTable = () => {
// Single channel creation functions
const createChannelFromStream = async (stream) => {
+ // Set default profile selection based on current profile filter
+ const defaultProfileIds =
+ selectedProfileId === '0' ? ['all'] : [selectedProfileId];
+ setSingleSelectedProfileIds(defaultProfileIds);
+
// Check if user has suppressed the single channel numbering dialog
const actionKey = 'single-channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
@@ -587,7 +660,11 @@ const StreamsTable = () => {
? 0
: Number(savedChannelNumber);
- await executeSingleChannelCreation(stream, channelNumberValue);
+ await executeSingleChannelCreation(
+ stream,
+ channelNumberValue,
+ defaultProfileIds
+ );
} else {
// Show the modal to let user choose
setCurrentStreamForChannel(stream);
@@ -596,18 +673,33 @@ const StreamsTable = () => {
};
// Separate function to actually execute single channel creation
- const executeSingleChannelCreation = async (stream, channelNumber = null) => {
- const selectedChannelProfileId =
- useChannelsStore.getState().selectedProfileId;
+ const executeSingleChannelCreation = async (
+ stream,
+ channelNumber = null,
+ profileIds = null
+ ) => {
+ // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
+ let channelProfileIds;
+ if (profileIds) {
+ if (profileIds.includes('none')) {
+ channelProfileIds = [];
+ } else if (profileIds.includes('all')) {
+ channelProfileIds = null;
+ } else {
+ channelProfileIds = profileIds
+ .filter((id) => id !== 'all' && id !== 'none')
+ .map((id) => parseInt(id));
+ }
+ } else {
+ channelProfileIds =
+ selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
+ }
await API.createChannelFromStream({
name: stream.name,
channel_number: channelNumber,
stream_id: stream.id,
- // Only pass channel_profile_ids if a specific profile is selected (not "All")
- ...(selectedChannelProfileId !== '0' && {
- channel_profile_ids: selectedChannelProfileId,
- }),
+ channel_profile_ids: channelProfileIds,
});
await API.requeryChannels();
const fetchLogos = useChannelsStore.getState().fetchLogos;
@@ -639,7 +731,8 @@ const StreamsTable = () => {
setSingleChannelModalOpen(false);
await executeSingleChannelCreation(
currentStreamForChannel,
- channelNumberValue
+ channelNumberValue,
+ singleSelectedProfileIds
);
};
@@ -691,8 +784,8 @@ const StreamsTable = () => {
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
- if (sortField == column) {
- if (sortDirection == false) {
+ if (sortField === column) {
+ if (sortDirection === false) {
setSorting([
{
id: column,
@@ -700,7 +793,8 @@ const StreamsTable = () => {
},
]);
} else {
- setSorting([]);
+ // Reset to default sort (name ascending) instead of clearing
+ setSorting([{ id: 'name', desc: false }]);
}
} else {
setSorting([
@@ -725,7 +819,7 @@ const StreamsTable = () => {
switch (header.id) {
case 'name':
return (
-
+
{
size="xs"
variant="unstyled"
className="table-input-header"
- />
-
- {React.createElement(sortingIcon, {
- onClick: () => onSortingChange('name'),
+ leftSection={}
+ style={{ flex: 1, minWidth: 0 }}
+ rightSectionPointerEvents="auto"
+ rightSection={React.createElement(sortingIcon, {
+ onClick: (e) => {
+ e.stopPropagation();
+ onSortingChange('name');
+ },
size: 14,
+ style: { cursor: 'pointer' },
})}
-
+ />
);
case 'group':
return (
-
+
{
variant="unstyled"
className="table-input-header custom-multiselect"
clearable
+ style={{ flex: 1, minWidth: 0 }}
+ rightSectionPointerEvents="auto"
+ rightSection={React.createElement(sortingIcon, {
+ onClick: (e) => {
+ e.stopPropagation();
+ onSortingChange('group');
+ },
+ size: 14,
+ style: { cursor: 'pointer' },
+ })}
/>
-
+
);
case 'm3u':
return (
-
+
+
);
}
};
@@ -835,6 +954,14 @@ const StreamsTable = () => {
bodyCellRenderFns: {
actions: renderBodyCell,
},
+ getRowStyles: (row) => {
+ if (row.original.is_stale) {
+ return {
+ backgroundColor: 'rgba(239, 68, 68, 0.15)',
+ };
+ }
+ return {};
+ },
});
/**
@@ -1076,147 +1203,41 @@ const StreamsTable = () => {
onClose={closeStreamForm}
/>
- {/* Channel Numbering Modal */}
- setChannelNumberingModalOpen(false)}
- title="Channel Numbering Options"
- size="md"
- centered
- >
-
-
- Choose how to assign channel numbers to the{' '}
- {selectedStreamIds.length} selected streams:
-
+ mode={numberingMode}
+ onModeChange={setNumberingMode}
+ numberValue={customStartNumber}
+ onNumberValueChange={setCustomStartNumber}
+ rememberChoice={rememberChoice}
+ onRememberChoiceChange={setRememberChoice}
+ onConfirm={handleChannelNumberingConfirm}
+ isBulk={true}
+ streamCount={selectedStreamIds.length}
+ selectedProfileIds={bulkSelectedProfileIds}
+ onProfileIdsChange={setBulkSelectedProfileIds}
+ channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
+ />
-
-
-
-
-
-
-
-
- {numberingMode === 'custom' && (
-
- )}
-
- setRememberChoice(event.currentTarget.checked)}
- label="Remember this choice and don't ask again"
- />
-
-
-
-
-
-
-
-
- {/* Single Channel Numbering Modal */}
- setSingleChannelModalOpen(false)}
- title="Channel Number Assignment"
- size="md"
- centered
- >
-
-
- Choose how to assign the channel number for "
- {currentStreamForChannel?.name}":
-
-
-
-
-
-
-
-
-
-
- {singleChannelMode === 'specific' && (
-
- )}
-
-
- setRememberSingleChoice(event.currentTarget.checked)
- }
- label="Remember this choice and don't ask again"
- />
-
-
-
-
-
-
-
+ mode={singleChannelMode}
+ onModeChange={setSingleChannelMode}
+ numberValue={specificChannelNumber}
+ onNumberValueChange={setSpecificChannelNumber}
+ rememberChoice={rememberSingleChoice}
+ onRememberChoiceChange={setRememberSingleChoice}
+ onConfirm={handleSingleChannelNumberingConfirm}
+ isBulk={false}
+ streamName={currentStreamForChannel?.name}
+ selectedProfileIds={singleSelectedProfileIds}
+ onProfileIdsChange={setSingleSelectedProfileIds}
+ channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
+ />
>
diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx
index 48573926..ffd47719 100644
--- a/frontend/src/components/tables/UserAgentsTable.jsx
+++ b/frontend/src/components/tables/UserAgentsTable.jsx
@@ -127,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',
@@ -137,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',
diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx
index 3e9e4971..467423dc 100644
--- a/frontend/src/components/tables/UsersTable.jsx
+++ b/frontend/src/components/tables/UsersTable.jsx
@@ -96,6 +96,7 @@ const UsersTable = () => {
const [deleteTarget, setDeleteTarget] = useState(null);
const [userToDelete, setUserToDelete] = useState(null);
const [isLoading, setIsLoading] = useState(false);
+ const [deleting, setDeleting] = useState(false);
const [visiblePasswords, setVisiblePasswords] = useState({});
/**
@@ -110,9 +111,14 @@ const UsersTable = () => {
const executeDeleteUser = useCallback(async (id) => {
setIsLoading(true);
- await API.deleteUser(id);
- setIsLoading(false);
- setConfirmDeleteOpen(false);
+ setDeleting(true);
+ try {
+ await API.deleteUser(id);
+ } finally {
+ setDeleting(false);
+ setIsLoading(false);
+ setConfirmDeleteOpen(false);
+ }
}, []);
const editUser = useCallback(async (user = null) => {
@@ -406,6 +412,7 @@ const UsersTable = () => {
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => executeDeleteUser(deleteTarget)}
+ loading={deleting}
title="Confirm User Deletion"
message={
userToDelete ? (
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 }}
+ />
+
+
+
+ }
+ 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/constants.js b/frontend/src/constants.js
index 78f374d4..528c5f04 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -303,6 +303,7 @@ export const REGION_CHOICES = [
{ value: 'tz', label: 'TZ' },
{ value: 'ua', label: 'UA' },
{ value: 'ug', label: 'UG' },
+ { value: 'uk', label: 'UK' },
{ value: 'um', label: 'UM' },
{ value: 'us', label: 'US' },
{ value: 'uy', label: 'UY' },
diff --git a/frontend/src/hooks/useSmartLogos.jsx b/frontend/src/hooks/useSmartLogos.jsx
index 148aded0..83957e46 100644
--- a/frontend/src/hooks/useSmartLogos.jsx
+++ b/frontend/src/hooks/useSmartLogos.jsx
@@ -38,8 +38,7 @@ export const useLogoSelection = () => {
};
/**
- * Hook for channel forms that need only channel-assignable logos
- * (unused + channel-used, excluding VOD-only logos)
+ * Hook for channel forms that need channel logos
*/
export const useChannelLogoSelection = () => {
const [isInitialized, setIsInitialized] = useState(false);
@@ -65,7 +64,7 @@ export const useChannelLogoSelection = () => {
await fetchChannelAssignableLogos();
setIsInitialized(true);
} catch (error) {
- console.error('Failed to load channel-assignable logos:', error);
+ console.error('Failed to load channel logos:', error);
}
}, [
backgroundLoading,
diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx
index 7663276d..b7b87b17 100644
--- a/frontend/src/pages/Channels.jsx
+++ b/frontend/src/pages/Channels.jsx
@@ -1,19 +1,59 @@
-import React from 'react';
+import React, { useCallback, useRef } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Box } from '@mantine/core';
import { 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 ChannelsPage = () => {
+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);
};
@@ -22,46 +62,49 @@ const ChannelsPage = () => {
setAllotmentSizes(sizes);
};
- if (!authUser.id) {
- return <>>;
- }
+ 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 2966ba9e..c9eaaffc 100644
--- a/frontend/src/pages/ContentSources.jsx
+++ b/frontend/src/pages/ContentSources.jsx
@@ -1,20 +1,19 @@
-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 (
{
);
-};
+}
+
+const M3UPage = () => {
+ return (
+
+
+
+ );
+}
export default M3UPage;
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
index ae2fd4ca..7bd6e07f 100644
--- a/frontend/src/pages/DVR.jsx
+++ b/frontend/src/pages/DVR.jsx
@@ -1,1367 +1,44 @@
-import React, { useMemo, useState, useEffect, useCallback } from 'react';
+import React, { useMemo, useState, useEffect, lazy, Suspense } from 'react';
import {
- ActionIcon,
Box,
Button,
- Card,
- Center,
- Container,
- Flex,
Badge,
Group,
- Image,
- Modal,
SimpleGrid,
Stack,
Text,
Title,
- Tooltip,
- Switch,
- Select,
- MultiSelect,
- TextInput,
useMantineTheme,
} from '@mantine/core';
import {
- Gauge,
- HardDriveDownload,
- HardDriveUpload,
- AlertTriangle,
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 utc from 'dayjs/plugin/utc';
-import timezone from 'dayjs/plugin/timezone';
import useChannelsStore from '../store/channels';
import useSettingsStore from '../store/settings';
-import useLocalStorage from '../hooks/useLocalStorage';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
-import { notifications } from '@mantine/notifications';
-import API from '../api';
-import { DatePickerInput, TimeInput } from '@mantine/dates';
-import { useForm } from '@mantine/form';
+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);
-dayjs.extend(utc);
-dayjs.extend(timezone);
-
-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-time-zone']?.value;
- if (tz && tz !== timeZone) {
- setTimeZone(tz);
- }
- }, [settings, timeZone, setTimeZone]);
-
- return timeZone;
-};
-
-const useTimeHelpers = () => {
- const timeZone = useUserTimeZone();
-
- const toUserTime = useCallback(
- (value) => {
- if (!value) return dayjs.invalid();
- try {
- return dayjs(value).tz(timeZone);
- } catch (error) {
- return dayjs(value);
- }
- },
- [timeZone]
- );
-
- const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]);
-
- return { timeZone, toUserTime, userNow };
-};
-
-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' },
-];
-
-// Short preview that triggers the details modal when clicked
-const RecordingSynopsis = ({ description, onOpen }) => {
- const truncated = description?.length > 140;
- const preview = truncated
- ? `${description.slice(0, 140).trim()}...`
- : description;
- if (!description) return null;
- return (
-
onOpen?.()}
- style={{ cursor: 'pointer' }}
- >
- {preview}
-
- );
-};
-
-const RecordingDetailsModal = ({
- opened,
- onClose,
- recording,
- channel,
- posterUrl,
- onWatchLive,
- onWatchRecording,
- env_mode,
- onEdit,
-}) => {
- const allRecordings = useChannelsStore((s) => s.recordings);
- const channelMap = useChannelsStore((s) => s.channels);
- const { toUserTime, userNow } = useTimeHelpers();
- const [childOpen, setChildOpen] = React.useState(false);
- const [childRec, setChildRec] = React.useState(null);
-
- const safeRecording = recording || {};
- const customProps = safeRecording.custom_properties || {};
- const program = customProps.program || {};
- const recordingName = program.title || 'Custom Recording';
- const description = program.description || customProps.description || '';
- const start = toUserTime(safeRecording.start_time);
- const end = toUserTime(safeRecording.end_time);
- const stats = customProps.stream_info || {};
-
- const statRows = [
- ['Video Codec', stats.video_codec],
- [
- 'Resolution',
- stats.resolution ||
- (stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
- ],
- ['FPS', stats.source_fps],
- ['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
- ['Audio Codec', stats.audio_codec],
- ['Audio Channels', stats.audio_channels],
- ['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
- ['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
- ].filter(([, v]) => v !== null && v !== undefined && v !== '');
-
- // Rating (if available)
- const rating =
- customProps.rating ||
- customProps.rating_value ||
- (program && program.custom_properties && program.custom_properties.rating);
- const ratingSystem = customProps.rating_system || 'MPAA';
-
- const fileUrl = customProps.file_url || customProps.output_file_url;
- const canWatchRecording =
- (customProps.status === 'completed' ||
- customProps.status === 'interrupted') &&
- Boolean(fileUrl);
-
- // Prefix in dev (Vite) if needed
- let resolvedPosterUrl = posterUrl;
- if (
- typeof import.meta !== 'undefined' &&
- import.meta.env &&
- import.meta.env.DEV
- ) {
- if (resolvedPosterUrl && resolvedPosterUrl.startsWith('/')) {
- resolvedPosterUrl = `${window.location.protocol}//${window.location.hostname}:5656${resolvedPosterUrl}`;
- }
- }
-
- const isSeriesGroup = Boolean(
- safeRecording._group_count && safeRecording._group_count > 1
- );
- const upcomingEpisodes = React.useMemo(() => {
- if (!isSeriesGroup) return [];
- const arr = Array.isArray(allRecordings)
- ? allRecordings
- : Object.values(allRecordings || {});
- const tvid = program.tvg_id || '';
- const titleKey = (program.title || '').toLowerCase();
- const filtered = arr.filter((r) => {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- if ((pr.tvg_id || '') !== tvid) return false;
- if ((pr.title || '').toLowerCase() !== titleKey) return false;
- const st = toUserTime(r.start_time);
- return st.isAfter(userNow());
- });
- // Deduplicate by program.id if present, else by time+title
- const seen = new Set();
- const deduped = [];
- for (const r of filtered) {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- // Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
- const season = cp.season ?? pr?.custom_properties?.season;
- const episode = cp.episode ?? pr?.custom_properties?.episode;
- const onscreen =
- cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- let key = null;
- if (season != null && episode != null) key = `se:${season}:${episode}`;
- else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
- else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
- else if (pr.id != null) key = `id:${pr.id}`;
- else
- key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
- if (seen.has(key)) continue;
- seen.add(key);
- deduped.push(r);
- }
- return deduped.sort(
- (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
- );
- }, [
- allRecordings,
- isSeriesGroup,
- program.tvg_id,
- program.title,
- toUserTime,
- userNow,
- ]);
-
- if (!recording) return null;
-
- const EpisodeRow = ({ rec }) => {
- const cp = rec.custom_properties || {};
- const pr = cp.program || {};
- const start = toUserTime(rec.start_time);
- const end = toUserTime(rec.end_time);
- const season = cp.season ?? pr?.custom_properties?.season;
- const episode = cp.episode ?? pr?.custom_properties?.episode;
- const onscreen =
- cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- const se =
- season && episode
- ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
- : onscreen || null;
- const posterLogoId = cp.poster_logo_id;
- let purl = posterLogoId
- ? `/api/channels/logos/${posterLogoId}/cache/`
- : cp.poster_url || posterUrl || '/logo.png';
- if (
- typeof import.meta !== 'undefined' &&
- import.meta.env &&
- import.meta.env.DEV &&
- purl &&
- purl.startsWith('/')
- ) {
- purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
- }
- const onRemove = async (e) => {
- e?.stopPropagation?.();
- try {
- await API.deleteRecording(rec.id);
- } catch (error) {
- console.error('Failed to delete upcoming recording', error);
- }
- try {
- await useChannelsStore.getState().fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings after delete', error);
- }
- };
- return (
-
{
- setChildRec(rec);
- setChildOpen(true);
- }}
- >
-
-
-
-
-
- {pr.sub_title || pr.title}
-
- {se && (
-
- {se}
-
- )}
-
-
- {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')}
-
-
-
-
-
-
-
- );
- };
-
- return (
-
- {isSeriesGroup ? (
-
- {upcomingEpisodes.length === 0 && (
-
- No upcoming episodes found
-
- )}
- {upcomingEpisodes.map((ep) => (
-
- ))}
- {childOpen && childRec && (
- setChildOpen(false)}
- recording={childRec}
- channel={channelMap[childRec.channel]}
- posterUrl={
- (childRec.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
- : childRec.custom_properties?.poster_url ||
- channelMap[childRec.channel]?.logo?.cache_url) ||
- '/logo.png'
- }
- env_mode={env_mode}
- onWatchLive={() => {
- const rec = childRec;
- const now = userNow();
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
- const ch = channelMap[rec.channel];
- if (!ch) return;
- let url = `/proxy/ts/stream/${ch.uuid}`;
- if (env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- useVideoStore.getState().showVideo(url, 'live');
- }
- }}
- onWatchRecording={() => {
- let fileUrl =
- childRec.custom_properties?.file_url ||
- childRec.custom_properties?.output_file_url;
- if (!fileUrl) return;
- if (env_mode === 'dev' && fileUrl.startsWith('/')) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- useVideoStore.getState().showVideo(fileUrl, 'vod', {
- name:
- childRec.custom_properties?.program?.title || 'Recording',
- logo: {
- url:
- (childRec.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
- : channelMap[childRec.channel]?.logo?.cache_url) ||
- '/logo.png',
- },
- });
- }}
- />
- )}
-
- ) : (
-
-
-
-
-
- {channel ? `${channel.channel_number} • ${channel.name}` : '—'}
-
-
- {onWatchLive && (
-
- )}
- {onWatchRecording && (
-
- )}
- {onEdit && start.isAfter(userNow()) && (
-
- )}
- {customProps.status === 'completed' &&
- (!customProps?.comskip ||
- customProps?.comskip?.status !== 'completed') && (
-
- )}
-
-
-
- {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')}
-
- {rating && (
-
-
- {rating}
-
-
- )}
- {description && (
-
- {description}
-
- )}
- {statRows.length > 0 && (
-
-
- Stream Stats
-
- {statRows.map(([k, v]) => (
-
-
- {k}
-
- {v}
-
- ))}
-
- )}
-
-
- )}
-
- );
-};
-
-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 = dayjs(value);
- return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
-};
-
-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 RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
- const channels = useChannelsStore((s) => s.channels);
- const recurringRules = useChannelsStore((s) => s.recurringRules);
- const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
- const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
- const recordings = useChannelsStore((s) => s.recordings);
- const { toUserTime, userNow } = useTimeHelpers();
-
- const [saving, setSaving] = useState(false);
- const [deleting, setDeleting] = useState(false);
- const [busyOccurrence, setBusyOccurrence] = useState(null);
-
- const rule = recurringRules.find((r) => r.id === ruleId);
-
- const channelOptions = useMemo(() => {
- const list = Object.values(channels || {});
- list.sort((a, b) => {
- const aNum = Number(a.channel_number) || 0;
- const bNum = Number(b.channel_number) || 0;
- if (aNum === bNum) {
- return (a.name || '').localeCompare(b.name || '');
- }
- return aNum - bNum;
- });
- return list.map((item) => ({
- value: `${item.id}`,
- label: item.name || `Channel ${item.id}`,
- }));
- }, [channels]);
-
- const form = useForm({
- mode: 'controlled',
- initialValues: {
- channel_id: '',
- days_of_week: [],
- rule_name: '',
- start_time: dayjs().startOf('hour').format('HH:mm'),
- end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
- start_date: dayjs().toDate(),
- end_date: dayjs().toDate(),
- enabled: true,
- },
- validate: {
- channel_id: (value) => (value ? null : 'Select a channel'),
- days_of_week: (value) =>
- value && value.length ? null : 'Pick at least one day',
- end_time: (value, values) => {
- if (!value) return 'Select an end time';
- const startValue = dayjs(
- values.start_time,
- ['HH:mm', 'hh:mm A', 'h:mm A'],
- true
- );
- const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
- if (
- startValue.isValid() &&
- endValue.isValid() &&
- endValue.diff(startValue, 'minute') === 0
- ) {
- return 'End time must differ from start time';
- }
- return null;
- },
- end_date: (value, values) => {
- const endDate = dayjs(value);
- const startDate = dayjs(values.start_date);
- if (!value) return 'Select an end date';
- if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
- return 'End date cannot be before start date';
- }
- return null;
- },
- },
- });
-
- useEffect(() => {
- if (opened && rule) {
- form.setValues({
- channel_id: `${rule.channel}`,
- days_of_week: (rule.days_of_week || []).map((d) => String(d)),
- rule_name: rule.name || '',
- start_time: toTimeString(rule.start_time),
- end_time: toTimeString(rule.end_time),
- start_date: parseDate(rule.start_date) || dayjs().toDate(),
- end_date: parseDate(rule.end_date),
- enabled: Boolean(rule.enabled),
- });
- } else {
- form.reset();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [opened, ruleId, rule]);
-
- const upcomingOccurrences = useMemo(() => {
- const list = Array.isArray(recordings)
- ? recordings
- : Object.values(recordings || {});
- const now = userNow();
- return list
- .filter(
- (rec) =>
- rec?.custom_properties?.rule?.id === ruleId &&
- toUserTime(rec.start_time).isAfter(now)
- )
- .sort(
- (a, b) =>
- toUserTime(a.start_time).valueOf() -
- toUserTime(b.start_time).valueOf()
- );
- }, [recordings, ruleId, toUserTime, userNow]);
-
- const handleSave = async (values) => {
- if (!rule) return;
- setSaving(true);
- try {
- await API.updateRecurringRule(ruleId, {
- channel: values.channel_id,
- days_of_week: (values.days_of_week || []).map((d) => Number(d)),
- start_time: toTimeString(values.start_time),
- end_time: toTimeString(values.end_time),
- start_date: values.start_date
- ? dayjs(values.start_date).format('YYYY-MM-DD')
- : null,
- end_date: values.end_date
- ? dayjs(values.end_date).format('YYYY-MM-DD')
- : null,
- name: values.rule_name?.trim() || '',
- enabled: Boolean(values.enabled),
- });
- await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- notifications.show({
- title: 'Recurring rule updated',
- message: 'Schedule adjustments saved',
- color: 'green',
- autoClose: 2500,
- });
- onClose();
- } catch (error) {
- console.error('Failed to update recurring rule', error);
- } finally {
- setSaving(false);
- }
- };
-
- const handleDelete = async () => {
- if (!rule) return;
- setDeleting(true);
- try {
- await API.deleteRecurringRule(ruleId);
- await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- notifications.show({
- title: 'Recurring rule removed',
- message: 'All future occurrences were cancelled',
- color: 'red',
- autoClose: 2500,
- });
- onClose();
- } catch (error) {
- console.error('Failed to delete recurring rule', error);
- } finally {
- setDeleting(false);
- }
- };
-
- const handleToggleEnabled = async (checked) => {
- if (!rule) return;
- setSaving(true);
- try {
- await API.updateRecurringRule(ruleId, { enabled: checked });
- await Promise.all([fetchRecurringRules(), fetchRecordings()]);
- notifications.show({
- title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
- message: checked
- ? 'Future occurrences will resume'
- : 'Upcoming occurrences were removed',
- color: checked ? 'green' : 'yellow',
- autoClose: 2500,
- });
- } catch (error) {
- console.error('Failed to toggle recurring rule', error);
- form.setFieldValue('enabled', !checked);
- } finally {
- setSaving(false);
- }
- };
-
- const handleCancelOccurrence = async (occurrence) => {
- setBusyOccurrence(occurrence.id);
- try {
- await API.deleteRecording(occurrence.id);
- await fetchRecordings();
- notifications.show({
- title: 'Occurrence cancelled',
- message: 'The selected airing was removed',
- color: 'yellow',
- autoClose: 2000,
- });
- } catch (error) {
- console.error('Failed to cancel occurrence', error);
- } finally {
- setBusyOccurrence(null);
- }
- };
-
- if (!rule) {
- return (
-
- Recurring rule not found.
-
- );
- }
-
- return (
-
-
-
-
- {channels?.[rule.channel]?.name || `Channel ${rule.channel}`}
-
- {
- form.setFieldValue('enabled', event.currentTarget.checked);
- handleToggleEnabled(event.currentTarget.checked);
- }}
- label={form.values.enabled ? 'Enabled' : 'Paused'}
- disabled={saving}
- />
-
-
-
-
-
- Upcoming occurrences
-
- {upcomingOccurrences.length}
-
- {upcomingOccurrences.length === 0 ? (
-
- No future airings currently scheduled.
-
- ) : (
-
- {upcomingOccurrences.map((occ) => {
- const occStart = toUserTime(occ.start_time);
- const occEnd = toUserTime(occ.end_time);
- return (
-
-
-
-
- {occStart.format('MMM D, YYYY')}
-
-
- {occStart.format('h:mma')} – {occEnd.format('h:mma')}
-
-
-
-
-
-
-
-
- );
- })}
-
- )}
-
-
-
- );
-};
-
-const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
- const channels = useChannelsStore((s) => s.channels);
- const env_mode = useSettingsStore((s) => s.environment.env_mode);
- const showVideo = useVideoStore((s) => s.showVideo);
- const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
- const { toUserTime, userNow } = useTimeHelpers();
-
- const channel = channels?.[recording.channel];
-
- const deleteRecording = (id) => {
- // Optimistically remove immediately from UI
- try {
- useChannelsStore.getState().removeRecording(id);
- } catch (error) {
- console.error('Failed to optimistically remove recording', error);
- }
- // Fire-and-forget server delete; websocket will keep others in sync
- API.deleteRecording(id).catch(() => {
- // On failure, fallback to refetch to restore state
- try {
- useChannelsStore.getState().fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings after delete', error);
- }
- });
- };
-
- const customProps = recording.custom_properties || {};
- const program = customProps.program || {};
- const recordingName = program.title || 'Custom Recording';
- const subTitle = program.sub_title || '';
- const description = program.description || customProps.description || '';
- const isRecurringRule = customProps?.rule?.type === 'recurring';
-
- // Poster or channel logo
- const posterLogoId = customProps.poster_logo_id;
- let posterUrl = posterLogoId
- ? `/api/channels/logos/${posterLogoId}/cache/`
- : customProps.poster_url || channel?.logo?.cache_url || '/logo.png';
- // Prefix API host in dev if using a relative path
- if (env_mode === 'dev' && posterUrl && posterUrl.startsWith('/')) {
- posterUrl = `${window.location.protocol}//${window.location.hostname}:5656${posterUrl}`;
- }
-
- const start = toUserTime(recording.start_time);
- const end = toUserTime(recording.end_time);
- const now = userNow();
- const status = customProps.status;
- const isTimeActive = now.isAfter(start) && now.isBefore(end);
- const isInterrupted = status === 'interrupted';
- const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
- const isUpcoming = now.isBefore(start);
- const isSeriesGroup = Boolean(
- recording._group_count && recording._group_count > 1
- );
- // Season/Episode display if present
- const season = customProps.season ?? program?.custom_properties?.season;
- const episode = customProps.episode ?? program?.custom_properties?.episode;
- const onscreen =
- customProps.onscreen_episode ??
- program?.custom_properties?.onscreen_episode;
- const seLabel =
- season && episode
- ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
- : onscreen || null;
-
- const handleWatchLive = () => {
- if (!channel) return;
- let url = `/proxy/ts/stream/${channel.uuid}`;
- if (env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- showVideo(url, 'live');
- };
-
- const handleWatchRecording = () => {
- // Only enable if backend provides a playable file URL in custom properties
- let fileUrl = customProps.file_url || customProps.output_file_url;
- if (!fileUrl) return;
- if (env_mode === 'dev' && fileUrl.startsWith('/')) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- showVideo(fileUrl, 'vod', {
- name: recordingName,
- logo: { url: posterUrl },
- });
- };
-
- const handleRunComskip = async (e) => {
- e?.stopPropagation?.();
- try {
- await API.runComskip(recording.id);
- notifications.show({
- title: 'Removing commercials',
- message: 'Queued comskip for this recording',
- color: 'blue.5',
- autoClose: 2000,
- });
- } catch (error) {
- console.error('Failed to queue comskip for recording', error);
- }
- };
-
- // Cancel handling for series groups
- const [cancelOpen, setCancelOpen] = React.useState(false);
- const [busy, setBusy] = React.useState(false);
- const handleCancelClick = (e) => {
- e.stopPropagation();
- if (isRecurringRule) {
- onOpenRecurring?.(recording, true);
- return;
- }
- if (isSeriesGroup) {
- setCancelOpen(true);
- } else {
- deleteRecording(recording.id);
- }
- };
-
- const seriesInfo = (() => {
- const cp = customProps || {};
- const pr = cp.program || {};
- return { tvg_id: pr.tvg_id, title: pr.title };
- })();
-
- const removeUpcomingOnly = async () => {
- try {
- setBusy(true);
- await API.deleteRecording(recording.id);
- } finally {
- setBusy(false);
- setCancelOpen(false);
- try {
- await fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings', error);
- }
- }
- };
-
- const removeSeriesAndRule = async () => {
- try {
- setBusy(true);
- const { tvg_id, title } = seriesInfo;
- if (tvg_id) {
- try {
- await API.bulkRemoveSeriesRecordings({
- tvg_id,
- title,
- scope: 'title',
- });
- } catch (error) {
- console.error('Failed to remove series recordings', error);
- }
- try {
- await API.deleteSeriesRule(tvg_id);
- } catch (error) {
- console.error('Failed to delete series rule', error);
- }
- }
- } finally {
- setBusy(false);
- setCancelOpen(false);
- try {
- await fetchRecordings();
- } catch (error) {
- console.error(
- 'Failed to refresh recordings after series removal',
- error
- );
- }
- }
- };
-
- const MainCard = (
-
{
- if (isRecurringRule) {
- onOpenRecurring?.(recording, false);
- } else {
- onOpenDetails?.(recording);
- }
- }}
- >
-
-
-
- {isInterrupted
- ? 'Interrupted'
- : isInProgress
- ? 'Recording'
- : isUpcoming
- ? 'Scheduled'
- : 'Completed'}
-
- {isInterrupted && }
-
-
-
- {recordingName}
-
- {isSeriesGroup && (
-
- Series
-
- )}
- {isRecurringRule && (
-
- Recurring
-
- )}
- {seLabel && !isSeriesGroup && (
-
- {seLabel}
-
- )}
-
-
-
-
-
-
- e.stopPropagation()}
- onClick={handleCancelClick}
- >
-
-
-
-
-
-
-
-
-
- {!isSeriesGroup && subTitle && (
-
-
- Episode
-
-
- {subTitle}
-
-
- )}
-
-
- Channel
-
-
- {channel ? `${channel.channel_number} • ${channel.name}` : '—'}
-
-
-
-
-
- {isSeriesGroup ? 'Next recording' : 'Time'}
-
-
- {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')}
-
-
-
- {!isSeriesGroup && description && (
- onOpenDetails?.(recording)}
- />
- )}
-
- {isInterrupted && customProps.interrupted_reason && (
-
- {customProps.interrupted_reason}
-
- )}
-
-
- {isInProgress && (
-
- )}
-
- {!isUpcoming && (
-
-
-
- )}
- {!isUpcoming &&
- customProps?.status === 'completed' &&
- (!customProps?.comskip ||
- customProps?.comskip?.status !== 'completed') && (
-
- )}
-
-
-
- {/* If this card is a grouped upcoming series, show count */}
- {recording._group_count > 1 && (
-
- Next of {recording._group_count}
-
- )}
-
- );
- if (!isSeriesGroup) return MainCard;
-
- // Stacked look for series groups: render two shadow layers behind the main card
- return (
-
- setCancelOpen(false)}
- title="Cancel Series"
- centered
- size="md"
- zIndex={9999}
- >
-
- This is a series rule. What would you like to cancel?
-
-
-
-
-
-
-
-
- {MainCard}
-
- );
+const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
+ return list.map((rec) => (
+
+ ));
};
const DVRPage = () => {
@@ -1428,86 +105,42 @@ const DVRPage = () => {
// Categorize recordings
const { inProgress, upcoming, completed } = useMemo(() => {
- const inProgress = [];
- const upcoming = [];
- const completed = [];
- const list = Array.isArray(recordings)
- ? recordings
- : Object.values(recordings || {});
-
- // ID-based dedupe guard in case store returns duplicates
- const seenIds = new Set();
- for (const rec of list) {
- if (rec && rec.id != null) {
- const k = String(rec.id);
- if (seenIds.has(k)) continue;
- seenIds.add(k);
- }
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- const status = rec.custom_properties?.status;
- if (status === 'interrupted' || status === 'completed') {
- completed.push(rec);
- } else {
- if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
- else if (now.isBefore(s)) upcoming.push(rec);
- else completed.push(rec);
- }
- }
-
- // Deduplicate in-progress and upcoming by program id or channel+slot
- const dedupeByProgramOrSlot = (arr) => {
- const out = [];
- const sigs = new Set();
- for (const r of arr) {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- const sig =
- pr?.id != null
- ? `id:${pr.id}`
- : `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
- if (sigs.has(sig)) continue;
- sigs.add(sig);
- out.push(r);
- }
- return out;
- };
-
- const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort(
- (a, b) => toUserTime(b.start_time) - toUserTime(a.start_time)
- );
-
- // Group upcoming by series title+tvg_id (keep only next episode)
- const grouped = new Map();
- const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort(
- (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
- );
- for (const rec of upcomingDedup) {
- const cp = rec.custom_properties || {};
- const prog = cp.program || {};
- const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
- if (!grouped.has(key)) {
- grouped.set(key, { rec, count: 1 });
- } else {
- const entry = grouped.get(key);
- entry.count += 1;
- }
- }
- const upcomingGrouped = Array.from(grouped.values()).map((e) => {
- const item = { ...e.rec };
- item._group_count = e.count;
- return item;
- });
- completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time));
- return {
- inProgress: inProgressDedup,
- upcoming: upcomingGrouped,
- completed,
- };
+ return categorizeRecordings(recordings, toUserTime, now);
}, [recordings, now, toUserTime]);
+ const 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"
@@ -1523,7 +156,7 @@ const DVRPage = () => {
>
New Recording
-
+
Currently Recording
@@ -1537,14 +170,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {inProgress.map((rec) => (
-
- ))}
+ {}
{inProgress.length === 0 && (
Nothing recording right now.
@@ -1566,14 +196,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {upcoming.map((rec) => (
-
- ))}
+ {}
{upcoming.length === 0 && (
No upcoming recordings.
@@ -1595,14 +222,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {completed.map((rec) => (
-
- ))}
+ {}
{completed.length === 0 && (
No completed recordings yet.
@@ -1635,67 +259,31 @@ const DVRPage = () => {
{/* Details Modal */}
{detailsRecording && (
- {
- const rec = detailsRecording;
- const now = userNow();
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
- // call into child RecordingCard behavior by constructing a URL like there
- const channel = channels[rec.channel];
- if (!channel) return;
- let url = `/proxy/ts/stream/${channel.uuid}`;
- if (useSettingsStore.getState().environment.env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- useVideoStore.getState().showVideo(url, 'live');
- }
- }}
- onWatchRecording={() => {
- let fileUrl =
- detailsRecording.custom_properties?.file_url ||
- detailsRecording.custom_properties?.output_file_url;
- if (!fileUrl) return;
- if (
- useSettingsStore.getState().environment.env_mode === 'dev' &&
- fileUrl.startsWith('/')
- ) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- useVideoStore.getState().showVideo(fileUrl, 'vod', {
- name:
- detailsRecording.custom_properties?.program?.title ||
- 'Recording',
- logo: {
- url:
- (detailsRecording.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/`
- : channels[detailsRecording.channel]?.logo?.cache_url) ||
- '/logo.png',
- },
- });
- }}
- onEdit={(rec) => {
- setEditRecording(rec);
- closeDetails();
- }}
- />
+
+ 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 c3c0fb61..00000000
--- a/frontend/src/pages/Dashboard.jsx
+++ /dev/null
@@ -1,27 +0,0 @@
-// src/components/Dashboard.js
-import React, { useState } from 'react';
-
-const Dashboard = () => {
- const [newStream, setNewStream] = useState('');
-
- return (
-
-
Dashboard Page
-
setNewStream(e.target.value)}
- placeholder="Enter Stream"
- />
-
-
Streams:
-
- {state.streams.map((stream, index) => (
- - {stream}
- ))}
-
-
- );
-};
-
-export default Dashboard;
diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx
index 13315453..ac0fdf82 100644
--- a/frontend/src/pages/Guide.jsx
+++ b/frontend/src/pages/Guide.jsx
@@ -5,248 +5,94 @@ import React, {
useEffect,
useRef,
useCallback,
+ Suspense,
} from 'react';
-import dayjs from 'dayjs';
-import API from '../api';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
-import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
-import { notifications } from '@mantine/notifications';
import useSettingsStore from '../store/settings';
import {
- Title,
- Box,
- Flex,
- Button,
- Text,
- Paper,
- Group,
- TextInput,
- Select,
ActionIcon,
+ Box,
+ Button,
+ Flex,
+ Group,
+ LoadingOverlay,
+ Paper,
+ Select,
+ Text,
+ TextInput,
+ Title,
Tooltip,
- Transition,
- Modal,
- Stack,
} from '@mantine/core';
-import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react';
+import { Calendar, Clock, Search, Video, X } from 'lucide-react';
import './guide.css';
import useEPGsStore from '../store/epgs';
-import useLocalStorage from '../hooks/useLocalStorage';
import { useElementSize } from '@mantine/hooks';
import { VariableSizeList } from 'react-window';
import {
- PROGRAM_HEIGHT,
- EXPANDED_PROGRAM_HEIGHT,
buildChannelIdMap,
- mapProgramsByChannel,
+ calculateDesiredScrollPosition,
+ calculateEarliestProgramStart,
+ calculateEnd,
+ calculateHourTimeline,
+ calculateLatestProgramEnd,
+ calculateLeftScrollPosition,
+ calculateNowPosition,
+ calculateScrollPosition,
+ calculateScrollPositionByTimeClick,
+ calculateStart,
+ CHANNEL_WIDTH,
computeRowHeights,
+ createRecording,
+ createSeriesRule,
+ evaluateSeriesRule,
+ EXPANDED_PROGRAM_HEIGHT,
+ fetchPrograms,
+ fetchRules,
+ filterGuideChannels,
+ formatTime,
+ getGroupOptions,
+ getProfileOptions,
+ getRuleByProgram,
+ HOUR_WIDTH,
+ mapChannelsById,
+ mapProgramsByChannel,
+ mapRecordingsByProgramId,
+ matchChannelByTvgId,
+ MINUTE_BLOCK_WIDTH,
+ MINUTE_INCREMENT,
+ PROGRAM_HEIGHT,
+ sortChannels,
} from './guideUtils';
-
-/** Layout constants */
-const CHANNEL_WIDTH = 120; // Width of the channel/logo column
-const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
-const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
-const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
-
-const GuideRow = React.memo(({ index, style, data }) => {
- const {
- filteredChannels,
- programsByChannelId,
- expandedProgramId,
- rowHeights,
- logos,
- hoveredChannelId,
- setHoveredChannelId,
- renderProgram,
- handleLogoClick,
- contentWidth,
- } = data;
-
- const channel = filteredChannels[index];
- if (!channel) {
- return null;
- }
-
- const channelPrograms = programsByChannelId.get(channel.id) || [];
- const rowHeight =
- rowHeights[index] ??
- (channelPrograms.some((program) => program.id === expandedProgramId)
- ? EXPANDED_PROGRAM_HEIGHT
- : PROGRAM_HEIGHT);
-
- return (
-
-
- handleLogoClick(channel, event)}
- onMouseEnter={() => setHoveredChannelId(channel.id)}
- onMouseLeave={() => setHoveredChannelId(null)}
- >
- {hoveredChannelId === channel.id && (
-
-
-
- )}
-
-
-
-
-
-
-
- {channel.channel_number || '-'}
-
-
-
-
-
- {channelPrograms.length > 0 ? (
- channelPrograms.map((program) =>
- renderProgram(program, undefined, channel)
- )
- ) : (
- <>
- {Array.from({ length: Math.ceil(24 / 2) }).map(
- (_, placeholderIndex) => (
-
- No program data
-
- )
- )}
- >
- )}
-
-
-
- );
-});
+import {
+ getShowVideoUrl,
+} from '../utils/cards/RecordingCardUtils.js';
+import {
+ add,
+ convertToMs,
+ format,
+ getNow,
+ initializeTime,
+ startOfDay,
+ useDateTimeFormat,
+} from '../utils/dateTimeUtils.js';
+import GuideRow from '../components/GuideRow.jsx';
+import HourTimeline from '../components/HourTimeline';
+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 isLoading = useChannelsStore((s) => s.isLoading);
+ const [isProgramsLoading, setIsProgramsLoading] = useState(true);
const logos = useLogosStore((s) => s.logos);
const tvgsById = useEPGsStore((s) => s.tvgsById);
@@ -254,8 +100,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
- const [filteredChannels, setFilteredChannels] = useState([]);
- const [now, setNow] = useState(dayjs());
+ const [now, setNow] = useState(getNow());
const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
const [recordingForProgram, setRecordingForProgram] = useState(null);
const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
@@ -275,6 +120,7 @@ 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 {
@@ -289,81 +135,38 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Load program data once
useEffect(() => {
- if (!Object.keys(channels).length === 0) {
+ if (Object.keys(channels).length === 0) {
console.warn('No channels provided or empty channels array');
- notifications.show({ title: 'No channels available', color: 'red.5' });
+ 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);
- // Include ALL channels, sorted by channel number - don't filter by EPG data
- const sortedChannels = Object.values(channels).sort(
- (a, b) =>
- (a.channel_number || Infinity) - (b.channel_number || Infinity)
- );
-
- console.log(`Using all ${sortedChannels.length} available channels`);
-
- const processedPrograms = fetched.map((program) => {
- const start = dayjs(program.start_time);
- const end = dayjs(program.end_time);
- return {
- ...program,
- startMs: start.valueOf(),
- endMs: end.valueOf(),
- };
+ fetchPrograms()
+ .then((data) => {
+ setPrograms(data);
+ setIsProgramsLoading(false);
+ })
+ .catch((error) => {
+ console.error('Failed to fetch programs:', error);
+ setIsProgramsLoading(false);
});
-
- setGuideChannels(sortedChannels);
- setFilteredChannels(sortedChannels); // Initialize filtered channels
- setPrograms(processedPrograms);
- };
-
- fetchPrograms();
}, [channels]);
// Apply filters when search, group, or profile changes
- useEffect(() => {
- if (!guideChannels.length) return;
+ const filteredChannels = useMemo(() => {
+ if (!guideChannels.length) return [];
- let result = [...guideChannels];
-
- // Apply search filter
- if (searchQuery) {
- const query = searchQuery.toLowerCase();
- result = result.filter((channel) =>
- channel.name.toLowerCase().includes(query)
- );
- }
-
- // Apply channel group filter
- if (selectedGroupId !== 'all') {
- result = result.filter(
- (channel) => channel.channel_group_id === parseInt(selectedGroupId)
- );
- }
-
- // Apply profile filter
- if (selectedProfileId !== 'all') {
- // Get the profile's enabled channels
- const profileChannels = profiles[selectedProfileId]?.channels || [];
- // Check if channels is a Set (from the error message, it likely is)
- const enabledChannelIds = Array.isArray(profileChannels)
- ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
- : profiles[selectedProfileId]?.channels instanceof Set
- ? Array.from(profiles[selectedProfileId].channels)
- : [];
-
- result = result.filter((channel) =>
- enabledChannelIds.includes(channel.id)
- );
- }
-
- setFilteredChannels(result);
+ return filterGuideChannels(
+ guideChannels,
+ searchQuery,
+ selectedGroupId,
+ selectedProfileId,
+ profiles
+ );
}, [
searchQuery,
selectedGroupId,
@@ -373,61 +176,44 @@ export default function TVChannelGuide({ startDate, endDate }) {
]);
// Use start/end from props or default to "today at midnight" +24h
- const defaultStart = dayjs(startDate || dayjs().startOf('day'));
- const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
+ const defaultStart = initializeTime(startDate || startOfDay(getNow()));
+ const defaultEnd = endDate
+ ? initializeTime(endDate)
+ : add(defaultStart, 24, 'hour');
// Expand timeline if needed based on actual earliest/ latest program
- const earliestProgramStart = useMemo(() => {
- if (!programs.length) return defaultStart;
- return programs.reduce((acc, p) => {
- const s = dayjs(p.start_time);
- return s.isBefore(acc) ? s : acc;
- }, defaultStart);
- }, [programs, defaultStart]);
+ const earliestProgramStart = useMemo(
+ () => calculateEarliestProgramStart(programs, defaultStart),
+ [programs, defaultStart]
+ );
- const latestProgramEnd = useMemo(() => {
- if (!programs.length) return defaultEnd;
- return programs.reduce((acc, p) => {
- const e = dayjs(p.end_time);
- return e.isAfter(acc) ? e : acc;
- }, defaultEnd);
- }, [programs, defaultEnd]);
+ const latestProgramEnd = useMemo(
+ () => calculateLatestProgramEnd(programs, defaultEnd),
+ [programs, defaultEnd]
+ );
- const start = earliestProgramStart.isBefore(defaultStart)
- ? earliestProgramStart
- : defaultStart;
- const end = latestProgramEnd.isAfter(defaultEnd)
- ? latestProgramEnd
- : defaultEnd;
+ const start = calculateStart(earliestProgramStart, defaultStart);
+ const end = calculateEnd(latestProgramEnd, defaultEnd);
const channelIdByTvgId = useMemo(
() => buildChannelIdMap(guideChannels, tvgsById, epgs),
[guideChannels, tvgsById, epgs]
);
- const channelById = useMemo(() => {
- const map = new Map();
- guideChannels.forEach((channel) => {
- map.set(channel.id, channel);
- });
- return map;
- }, [guideChannels]);
+ const channelById = useMemo(
+ () => mapChannelsById(guideChannels),
+ [guideChannels]
+ );
const programsByChannelId = useMemo(
() => mapProgramsByChannel(programs, channelIdByTvgId),
[programs, channelIdByTvgId]
);
- const recordingsByProgramId = useMemo(() => {
- const map = new Map();
- (recordings || []).forEach((recording) => {
- const programId = recording?.custom_properties?.program?.id;
- if (programId != null) {
- map.set(programId, recording);
- }
- });
- return map;
- }, [recordings]);
+ const recordingsByProgramId = useMemo(
+ () => mapRecordingsByProgramId(recordings),
+ [recordings]
+ );
const rowHeights = useMemo(
() =>
@@ -444,112 +230,248 @@ export default function TVChannelGuide({ startDate, endDate }) {
[rowHeights]
);
- const [timeFormatSetting] = useLocalStorage('time-format', '12h');
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- // Use user preference for time format
- const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm';
- const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM';
+ const [timeFormat, dateFormat] = useDateTimeFormat();
// Format day label using relative terms when possible (Today, Tomorrow, etc)
const formatDayLabel = useCallback(
- (time) => {
- const today = dayjs().startOf('day');
- const tomorrow = today.add(1, 'day');
- const weekLater = today.add(7, 'day');
-
- const day = time.startOf('day');
-
- if (day.isSame(today, 'day')) {
- return 'Today';
- } else if (day.isSame(tomorrow, 'day')) {
- return 'Tomorrow';
- } else if (day.isBefore(weekLater)) {
- // Within a week, show day name
- return time.format('dddd');
- } else {
- // Beyond a week, show month and day
- return time.format(dateFormat);
- }
- },
+ (time) => formatTime(time, dateFormat),
[dateFormat]
);
// Hourly marks with day labels
- const hourTimeline = useMemo(() => {
- const hours = [];
- let current = start;
- let currentDay = null;
-
- while (current.isBefore(end)) {
- // Check if we're entering a new day
- const day = current.startOf('day');
- const isNewDay = !currentDay || !day.isSame(currentDay, 'day');
-
- if (isNewDay) {
- currentDay = day;
- }
-
- // Add day information to our hour object
- hours.push({
- time: current,
- isNewDay,
- dayLabel: formatDayLabel(current),
- });
-
- current = current.add(1, 'hour');
- }
- return hours;
- }, [start, end, formatDayLabel]);
+ const hourTimeline = useMemo(
+ () => calculateHourTimeline(start, end, formatDayLabel),
+ [start, end, formatDayLabel]
+ );
useEffect(() => {
const node = guideRef.current;
if (!node) return undefined;
const handleScroll = () => {
- const { scrollLeft } = node;
- if (scrollLeft === guideScrollLeftRef.current) {
- return;
- }
-
- guideScrollLeftRef.current = scrollLeft;
- setGuideScrollLeft(scrollLeft);
-
if (isSyncingScroll.current) {
return;
}
+ const { scrollLeft } = node;
+
+ // 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);
}
};
node.addEventListener('scroll', handleScroll, { passive: true });
+
return () => {
node.removeEventListener('scroll', handleScroll);
};
}, []);
- // 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]
+ );
+
+ useEffect(() => {
+ const tvGuide = tvGuideRef.current;
+
+ 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,
+ });
+
+ 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;
@@ -584,31 +506,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Scroll to the nearest half-hour mark ONLY on initial load
useEffect(() => {
if (programs.length > 0 && !initialScrollComplete) {
- const roundedNow =
- now.minute() < 30
- ? now.startOf('hour')
- : now.startOf('hour').add(30, 'minute');
- const nowOffset = roundedNow.diff(start, 'minute');
- const scrollPosition =
- (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
- MINUTE_BLOCK_WIDTH;
-
- const scrollPos = Math.max(scrollPosition, 0);
- syncScrollLeft(scrollPos);
+ syncScrollLeft(calculateScrollPosition(now, start));
setInitialScrollComplete(true);
}
}, [programs, start, now, initialScrollComplete, syncScrollLeft]);
const findChannelByTvgId = useCallback(
- (tvgId) => {
- const channelIds = channelIdByTvgId.get(String(tvgId));
- if (!channelIds || channelIds.length === 0) {
- return null;
- }
- // Return the first channel that matches this TVG ID
- return channelById.get(channelIds[0]) || null;
- },
+ (tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId),
[channelById, channelIdByTvgId]
);
@@ -617,19 +522,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
setRecordChoiceProgram(program);
setRecordChoiceOpen(true);
try {
- const rules = await API.listSeriesRules();
- const rule = (rules || []).find(
- (r) =>
- String(r.tvg_id) === String(program.tvg_id) &&
- (!r.title || r.title === program.title)
- );
+ const rules = await fetchRules();
+ const rule = getRuleByProgram(rules, program);
setExistingRuleMode(rule ? rule.mode : null);
} catch (error) {
console.warn('Failed to fetch series rules metadata', error);
}
- const existingRecording = recordingsByProgramId.get(program.id) || null;
- setRecordingForProgram(existingRecording);
+ setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
},
[recordingsByProgramId]
);
@@ -638,7 +538,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
async (program) => {
const channel = findChannelByTvgId(program.tvg_id);
if (!channel) {
- notifications.show({
+ showNotification({
title: 'Unable to schedule recording',
message: 'No channel found for this program.',
color: 'red.6',
@@ -646,24 +546,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
- await API.createRecording({
- channel: `${channel.id}`,
- start_time: program.start_time,
- end_time: program.end_time,
- custom_properties: { program },
- });
- notifications.show({ title: 'Recording scheduled' });
+ await createRecording(channel, program);
+ showNotification({ title: 'Recording scheduled' });
},
[findChannelByTvgId]
);
const saveSeriesRule = useCallback(async (program, mode) => {
- await API.createSeriesRule({
- tvg_id: program.tvg_id,
- mode,
- title: program.title,
- });
- await API.evaluateSeriesRules(program.tvg_id);
+ await createSeriesRule(program, mode);
+ await evaluateSeriesRule(program);
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
@@ -672,7 +563,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
error
);
}
- notifications.show({
+ showNotification({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
});
}, []);
@@ -680,7 +571,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const openRules = useCallback(async () => {
setRulesOpen(true);
try {
- const r = await API.listSeriesRules();
+ const r = await fetchRules();
setRules(r);
} catch (error) {
console.warn('Failed to load series rules', error);
@@ -697,12 +588,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
- let vidUrl = `/proxy/ts/stream/${matched.uuid}`;
- if (env_mode === 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
-
- showVideo(vidUrl);
+ showVideo(getShowVideoUrl(matched, env_mode));
},
[env_mode, findChannelByTvgId, showVideo]
);
@@ -711,12 +597,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
(channel, event) => {
event.stopPropagation();
- let vidUrl = `/proxy/ts/stream/${channel.uuid}`;
- if (env_mode === 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
-
- showVideo(vidUrl);
+ showVideo(getShowVideoUrl(channel, env_mode));
},
[env_mode, showVideo]
);
@@ -725,13 +606,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
(program, event) => {
event.stopPropagation();
- const programStartMs =
- program.startMs ?? dayjs(program.start_time).valueOf();
- const startOffsetMinutes = (programStartMs - start.valueOf()) / 60000;
- const leftPx =
- (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
- const desiredScrollPosition = Math.max(0, leftPx - 20);
-
if (expandedProgramId === program.id) {
setExpandedProgramId(null);
setRecordingForProgram(null);
@@ -740,6 +614,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
}
+ const leftPx = calculateLeftScrollPosition(program, start);
+ const desiredScrollPosition = calculateDesiredScrollPosition(leftPx);
+
const guideNode = guideRef.current;
if (guideNode) {
const currentScrollPosition = guideNode.scrollLeft;
@@ -767,31 +644,22 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
- const roundedNow =
- now.minute() < 30
- ? now.startOf('hour')
- : now.startOf('hour').add(30, 'minute');
- const nowOffset = roundedNow.diff(start, 'minute');
- const scrollPosition =
- (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
-
- const scrollPos = Math.max(scrollPosition, 0);
- syncScrollLeft(scrollPos, 'smooth');
+ syncScrollLeft(calculateScrollPosition(now, start), 'smooth');
}, [now, nowPosition, start, syncScrollLeft]);
const handleTimelineScroll = useCallback(() => {
- if (!timelineRef.current) {
+ if (!timelineRef.current || isSyncingScroll.current) {
return;
}
const nextLeft = timelineRef.current.scrollLeft;
- guideScrollLeftRef.current = nextLeft;
- setGuideScrollLeft(nextLeft);
-
- if (isSyncingScroll.current) {
+ if (nextLeft === guideScrollLeftRef.current) {
return;
}
+ guideScrollLeftRef.current = nextLeft;
+ setGuideScrollLeft(nextLeft);
+
isSyncingScroll.current = true;
if (guideRef.current) {
if (typeof guideRef.current.scrollTo === 'function') {
@@ -819,44 +687,26 @@ export default function TVChannelGuide({ startDate, endDate }) {
const handleTimeClick = useCallback(
(clickedTime, event) => {
- const rect = event.currentTarget.getBoundingClientRect();
- const clickPositionX = event.clientX - rect.left;
- const percentageAcross = clickPositionX / rect.width;
- const minuteWithinHour = Math.floor(percentageAcross * 60);
-
- let snappedMinute;
- if (minuteWithinHour < 7.5) {
- snappedMinute = 0;
- } else if (minuteWithinHour < 22.5) {
- snappedMinute = 15;
- } else if (minuteWithinHour < 37.5) {
- snappedMinute = 30;
- } else if (minuteWithinHour < 52.5) {
- snappedMinute = 45;
- } else {
- snappedMinute = 0;
- clickedTime = clickedTime.add(1, 'hour');
- }
-
- const snappedTime = clickedTime.minute(snappedMinute);
- const snappedOffset = snappedTime.diff(start, 'minute');
- const scrollPosition =
- (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
-
- syncScrollLeft(scrollPosition, 'smooth');
+ syncScrollLeft(
+ calculateScrollPositionByTimeClick(event, clickedTime, start),
+ 'smooth'
+ );
},
[start, syncScrollLeft]
);
const renderProgram = useCallback(
(program, channelStart = start, channel = null) => {
- const programStartMs =
- program.startMs ?? dayjs(program.start_time).valueOf();
- const programEndMs = program.endMs ?? dayjs(program.end_time).valueOf();
- const programStart = dayjs(programStartMs);
- const programEnd = dayjs(programEndMs);
+ const {
+ programStart,
+ programEnd,
+ startMs: programStartMs,
+ endMs: programEndMs,
+ isLive,
+ isPast,
+ } = program;
const startOffsetMinutes =
- (programStartMs - channelStart.valueOf()) / 60000;
+ (programStartMs - convertToMs(channelStart)) / 60000;
const durationMinutes = (programEndMs - programStartMs) / 60000;
const leftPx =
(startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@@ -867,10 +717,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const recording = recordingsByProgramId.get(program.id);
- const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
- const isPast = now.isAfter(programEnd);
const isExpanded = expandedProgramId === program.id;
-
const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
const MIN_EXPANDED_WIDTH = 450;
const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH);
@@ -888,36 +735,61 @@ export default function TVChannelGuide({ startDate, endDate }) {
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 && (
@@ -973,8 +850,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
overflow: 'hidden',
}}
>
- {programStart.format(timeFormat)} -{' '}
- {programEnd.format(timeFormat)}
+ {format(programStart, timeFormat)} -{' '}
+ {format(programEnd, timeFormat)}
@@ -988,13 +865,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
{program.description}
@@ -1002,37 +879,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
)}
{isExpanded && (
-
+
- {!isPast && (
- }
- variant="filled"
- color="red"
- size="xs"
- onClick={(event) => {
- event.stopPropagation();
- openRecordChoice(program);
- }}
- >
- Record
-
- )}
+ {!isPast && }
- {isLive && (
- }
- variant="filled"
- color="blue"
- size="xs"
- onClick={(event) => {
- event.stopPropagation();
- handleWatchStream(program);
- }}
- >
- Watch Now
-
- )}
+ {isLive && }
)}
@@ -1115,49 +966,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
}, [searchQuery, selectedGroupId, selectedProfileId]);
// Create group options for dropdown - but only include groups used by guide channels
- const groupOptions = useMemo(() => {
- const options = [{ value: 'all', label: 'All Channel Groups' }];
-
- if (channelGroups && guideChannels.length > 0) {
- // Get unique channel group IDs from the channels that have program data
- const usedGroupIds = new Set();
- guideChannels.forEach((channel) => {
- if (channel.channel_group_id) {
- usedGroupIds.add(channel.channel_group_id);
- }
- });
- // Only add groups that are actually used by channels in the guide
- Object.values(channelGroups)
- .filter((group) => usedGroupIds.has(group.id))
- .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
- .forEach((group) => {
- options.push({
- value: group.id.toString(),
- label: group.name,
- });
- });
- }
- return options;
- }, [channelGroups, guideChannels]);
+ const groupOptions = useMemo(
+ () => getGroupOptions(channelGroups, guideChannels),
+ [channelGroups, guideChannels]
+ );
// Create profile options for dropdown
- const profileOptions = useMemo(() => {
- const options = [{ value: 'all', label: 'All Profiles' }];
-
- if (profiles) {
- Object.values(profiles).forEach((profile) => {
- if (profile.id !== '0') {
- // Skip the 'All' default profile
- options.push({
- value: profile.id.toString(),
- label: profile.name,
- });
- }
- });
- }
-
- return options;
- }, [profiles]);
+ const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]);
// Clear all filters
const clearFilters = () => {
@@ -1176,39 +991,45 @@ 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, ${dateFormat}, YYYY • ${timeFormat}`)}
+ {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"
@@ -1251,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
/>
@@ -1260,7 +1081,7 @@ 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
/>
@@ -1278,14 +1099,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
onClick={openRules}
style={{
backgroundColor: '#245043',
- border: '1px solid #3BA882',
- color: '#FFFFFF',
}}
+ bd={'1px solid #3BA882'}
+ color='#FFFFFF'
>
Series Rules
-
+
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
@@ -1295,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 */}
@@ -1330,122 +1151,33 @@ export default function TVChannelGuide({ startDate, endDate }) {
style={{
flex: 1,
overflow: 'hidden',
- position: 'relative',
}}
+ pos='relative'
>
- {' '}
- {hourTimeline.map((hourData) => {
- const { time, isNewDay } = hourData;
-
- return (
- handleTimeClick(time, e)}
- >
- {/* Remove the special day label for new days since we'll show day for all hours */}
-
- {/* Position time label at the left border of each hour block */}
-
- {/* Show day above time for every hour using the same format */}
-
- {formatDayLabel(time)}{' '}
- {/* Use same formatDayLabel function for all hours */}
-
- {time.format(timeFormat)}
-
- {/*time.format('A')*/}
-
-
-
- {/* Hour boundary marker - more visible */}
-
-
- {/* Quarter hour tick marks */}
-
- {[15, 30, 45].map((minute) => (
-
- ))}
-
-
- );
- })}
+
@@ -1456,22 +1188,23 @@ export default function TVChannelGuide({ startDate, endDate }) {
ref={guideContainerRef}
style={{
flex: 1,
- position: 'relative',
overflow: 'hidden',
}}
+ pos='relative'
>
+
{nowPosition >= 0 && (
)}
@@ -1492,13 +1225,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
{GuideRow}
) : (
-
+
No channels match your filters
{/* Record choice modal */}
{recordChoiceOpen && recordChoiceProgram && (
- setRecordChoiceOpen(false)}
- title={`Record: ${recordChoiceProgram.title}`}
- centered
- radius="md"
- zIndex={9999}
- overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
- styles={{
- content: { backgroundColor: '#18181B', color: 'white' },
- header: { backgroundColor: '#18181B', color: 'white' },
- title: { color: 'white' },
- }}
- >
-
-
-
-
- {recordingForProgram && (
- <>
-
-
- >
- )}
- {existingRuleMode && (
-
- )}
-
-
+
+ }>
+ setRecordChoiceOpen(false)}
+ program={recordChoiceProgram}
+ recording={recordingForProgram}
+ existingRuleMode={existingRuleMode}
+ onRecordOne={() => recordOne(recordChoiceProgram)}
+ onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')}
+ onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')}
+ onExistingRuleModeChange={setExistingRuleMode}
+ />
+
+
)}
{/* Series rules modal */}
{rulesOpen && (
- setRulesOpen(false)}
- title="Series Recording Rules"
- centered
- radius="md"
- zIndex={9999}
- overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
- styles={{
- content: { backgroundColor: '#18181B', color: 'white' },
- header: { backgroundColor: '#18181B', color: 'white' },
- title: { color: 'white' },
- }}
- >
-
- {(!rules || rules.length === 0) && (
-
- No series rules configured
-
- )}
- {rules &&
- rules.map((r) => (
-
-
- {r.title || r.tvg_id} —{' '}
- {r.mode === 'new' ? 'New episodes' : 'Every episode'}
-
-
-
-
-
-
- ))}
-
-
+
+ }>
+ 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 e9751d8d..00000000
--- a/frontend/src/pages/Home.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-// src/components/Home.js
-import React, { useState } from 'react';
-
-const Home = () => {
- const [newChannel, setNewChannel] = useState('');
-
- return (
-
-
Home Page
-
- );
-};
-
-export default Home;
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
index 92a4aab8..f95212d6 100644
--- a/frontend/src/pages/Logos.jsx
+++ b/frontend/src/pages/Logos.jsx
@@ -1,45 +1,86 @@
-import React, { useEffect, useCallback } from 'react';
-import { Box, Loader, Center, Text, Stack } from '@mantine/core';
-import { notifications } from '@mantine/notifications';
+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 { fetchAllLogos, isLoading, needsAllLogos } = useLogosStore();
+ 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 loadLogos = useCallback(async () => {
+ const loadChannelLogos = useCallback(async () => {
try {
// Only fetch all logos if we haven't loaded them yet
- if (needsAllLogos()) {
- await fetchAllLogos();
+ if (useLogosStore.getState().needsAllLogos()) {
+ await useLogosStore.getState().fetchAllLogos();
}
} catch (err) {
- notifications.show({
+ showNotification({
title: 'Error',
- message: 'Failed to load logos',
+ message: 'Failed to load channel logos',
color: 'red',
});
- console.error('Failed to load logos:', err);
+ console.error('Failed to load channel logos:', err);
}
- }, [fetchAllLogos, needsAllLogos]);
+ }, []);
useEffect(() => {
- loadLogos();
- }, [loadLogos]);
+ // Always load channel logos on mount
+ loadChannelLogos();
+ }, [loadChannelLogos]);
return (
-
- {isLoading && (
-
-
-
-
- Loading all logos...
+
+ {/* Header with title and tabs */}
+
+
+
+
+ Logos
-
-
- )}
-
+
+ ({logoCount} {logoCount !== 1 ? 'logos' : 'logo'})
+
+
+
+
+
+ Channel Logos
+ VOD Logos
+
+
+
+
+
+ {/* Content based on active tab */}
+ {activeTab === 'channel' && }
+ {activeTab === 'vod' && }
);
};
diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx
index f2902523..21df7faf 100644
--- a/frontend/src/pages/Plugins.jsx
+++ b/frontend/src/pages/Plugins.jsx
@@ -1,353 +1,108 @@
-import React, { useEffect, useState } from 'react';
+import React, {
+ Suspense,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import {
- AppShell,
- Box,
+ ActionIcon,
Alert,
+ AppShellMain,
+ Box,
Button,
- Card,
+ Divider,
+ FileInput,
Group,
Loader,
+ Modal,
+ SimpleGrid,
Stack,
Switch,
Text,
- TextInput,
- NumberInput,
- Select,
- Divider,
- ActionIcon,
- SimpleGrid,
- Modal,
- FileInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
-import { RefreshCcw, Trash2 } from 'lucide-react';
-import API from '../api';
-import { notifications } from '@mantine/notifications';
+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 Field = ({ field, value, onChange }) => {
- const common = { label: field.label, description: field.help_text };
- const effective = value ?? field.default;
- switch (field.type) {
- case 'boolean':
- return (
- onChange(field.id, e.currentTarget.checked)}
- label={field.label}
- description={field.help_text}
- />
- );
- case 'number':
- return (
- onChange(field.id, v)}
- {...common}
- />
- );
- case 'select':
- return (
-
-
+
+ {/* 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 e6d6378c..4ce519a3 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,1332 +1,166 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} 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,
- Alert,
+ AccordionControl,
+ AccordionItem,
+ AccordionPanel,
Box,
- Button,
Center,
- Flex,
- Group,
- FileInput,
- MultiSelect,
- Select,
- Stack,
- Switch,
Text,
- TextInput,
- NumberInput,
+ Loader
} from '@mantine/core';
-import { isNotEmpty, useForm } from '@mantine/form';
-import { notifications } from '@mantine/notifications';
-import UserAgentsTable from '../components/tables/UserAgentsTable';
-import StreamProfilesTable from '../components/tables/StreamProfilesTable';
-import useLocalStorage from '../hooks/useLocalStorage';
+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,
- NETWORK_ACCESS_OPTIONS,
- PROXY_SETTINGS_OPTIONS,
- REGION_CHOICES,
-} from '../constants';
-import ConfirmationDialog from '../components/ConfirmationDialog';
-import useWarningsStore from '../store/warnings';
-
-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}`;
-};
-
-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;
-};
-
-const getDefaultTimeZone = () => {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
- } catch (error) {
- return 'UTC';
- }
-};
+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 suppressWarning = useWarningsStore((s) => s.suppressWarning);
- const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [accordianValue, setAccordianValue] = useState(null);
- const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
- const [networkAccessError, setNetworkAccessError] = useState(null);
- const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] =
- useState(false);
- const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] =
- useState([]);
-
- const [proxySettingsSaved, setProxySettingsSaved] = useState(false);
- const [generalSettingsSaved, setGeneralSettingsSaved] = useState(false);
- const [rehashingStreams, setRehashingStreams] = useState(false);
- const [rehashSuccess, setRehashSuccess] = useState(false);
- const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false);
-
- // Add a new state to track the dialog type
- const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
-
- // Store pending changed settings when showing the dialog
- const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
- const [comskipFile, setComskipFile] = useState(null);
- const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
- const [comskipConfig, setComskipConfig] = useState({
- path: '',
- exists: false,
- });
-
- // UI / local storage settings
- const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
- const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
- const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
- const [timeZone, setTimeZone] = useLocalStorage(
- 'time-zone',
- getDefaultTimeZone()
- );
- const timeZoneOptions = useMemo(
- () => buildTimeZoneOptions(timeZone),
- [timeZone]
- );
- const timeZoneSyncedRef = useRef(false);
-
- const persistTimeZoneSetting = useCallback(
- async (tzValue) => {
- try {
- const existing = settings['system-time-zone'];
- if (existing && existing.id) {
- await API.updateSetting({ ...existing, value: tzValue });
- } else {
- await API.createSetting({
- key: 'system-time-zone',
- name: 'System Time Zone',
- value: tzValue,
- });
- }
- } catch (error) {
- console.error('Failed to persist time zone setting', error);
- notifications.show({
- title: 'Failed to update time zone',
- message: 'Could not save the selected time zone. Please try again.',
- color: 'red',
- });
- }
- },
- [settings]
- );
-
- const regionChoices = REGION_CHOICES;
-
- const form = useForm({
- mode: 'controlled',
- initialValues: {
- 'default-user-agent': '',
- 'default-stream-profile': '',
- 'preferred-region': '',
- 'auto-import-mapped-files': true,
- 'm3u-hash-key': [],
- 'dvr-tv-template': '',
- 'dvr-movie-template': '',
- 'dvr-tv-fallback-template': '',
- 'dvr-movie-fallback-template': '',
- 'dvr-comskip-enabled': false,
- 'dvr-comskip-custom-path': '',
- 'dvr-pre-offset-minutes': 0,
- 'dvr-post-offset-minutes': 0,
- },
-
- validate: {
- 'default-user-agent': isNotEmpty('Select a user agent'),
- 'default-stream-profile': isNotEmpty('Select a stream profile'),
- 'preferred-region': isNotEmpty('Select a region'),
- },
- });
-
- const networkAccessForm = useForm({
- mode: 'controlled',
- initialValues: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = '0.0.0.0/0,::0/0';
- return acc;
- }, {}),
- validate: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = (value) => {
- const cidrs = value.split(',');
- const ipv4CidrRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
- const ipv6CidrRegex = /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
- for (const cidr of cidrs) {
- if (cidr.match(ipv4CidrRegex) || cidr.match(ipv6CidrRegex)) {
- continue;
- }
-
- return 'Invalid CIDR range';
- }
-
- return null;
- };
- return acc;
- }, {}),
- });
-
- const proxySettingsForm = useForm({
- mode: 'controlled',
- initialValues: Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => {
- acc[key] = '';
- return acc;
- }, {}),
- });
-
- useEffect(() => {
- if (settings) {
- const formValues = Object.entries(settings).reduce(
- (acc, [key, value]) => {
- // Modify each value based on its own properties
- switch (value.value) {
- case 'true':
- value.value = true;
- break;
- case 'false':
- value.value = false;
- break;
- }
-
- let val = null;
- switch (key) {
- case 'm3u-hash-key':
- // Split comma-separated string, filter out empty strings
- val = value.value ? value.value.split(',').filter((v) => v) : [];
- break;
- case 'dvr-pre-offset-minutes':
- case 'dvr-post-offset-minutes':
- val = Number.parseInt(value.value || '0', 10);
- if (Number.isNaN(val)) val = 0;
- break;
- default:
- val = value.value;
- break;
- }
-
- acc[key] = val;
- return acc;
- },
- {}
- );
-
- form.setValues(formValues);
- if (formValues['dvr-comskip-custom-path']) {
- setComskipConfig((prev) => ({
- path: formValues['dvr-comskip-custom-path'],
- exists: prev.exists,
- }));
- }
-
- const networkAccessSettings = JSON.parse(
- settings['network-access'].value || '{}'
- );
- networkAccessForm.setValues(
- Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::0/0';
- return acc;
- }, {})
- );
-
- if (settings['proxy-settings']?.value) {
- try {
- const proxySettings = JSON.parse(settings['proxy-settings'].value);
- proxySettingsForm.setValues(proxySettings);
- } catch (error) {
- console.error('Error parsing proxy settings:', error);
- }
- }
-
- const tzSetting = settings['system-time-zone'];
- if (tzSetting?.value) {
- timeZoneSyncedRef.current = true;
- setTimeZone((prev) =>
- prev === tzSetting.value ? prev : tzSetting.value
- );
- } else if (!timeZoneSyncedRef.current && timeZone) {
- timeZoneSyncedRef.current = true;
- persistTimeZoneSetting(timeZone);
- }
- }
- }, [settings, timeZone, setTimeZone, persistTimeZoneSetting]);
-
- useEffect(() => {
- const loadComskipConfig = async () => {
- try {
- const response = await API.getComskipConfig();
- if (response) {
- setComskipConfig({
- path: response.path || '',
- exists: Boolean(response.exists),
- });
- if (response.path) {
- form.setFieldValue('dvr-comskip-custom-path', response.path);
- }
- }
- } catch (error) {
- console.error('Failed to load comskip config', error);
- }
- };
- loadComskipConfig();
- }, []);
-
- // Clear success states when switching accordion panels
- useEffect(() => {
- setGeneralSettingsSaved(false);
- setProxySettingsSaved(false);
- setNetworkAccessSaved(false);
- setRehashSuccess(false);
- }, [accordianValue]);
-
- const onSubmit = async () => {
- setGeneralSettingsSaved(false);
-
- const values = form.getValues();
- const changedSettings = {};
- let m3uHashKeyChanged = false;
-
- for (const settingKey in values) {
- // Only compare against existing value if the setting exists
- const existing = settings[settingKey];
-
- // Convert array values (like m3u-hash-key) to comma-separated strings
- let stringValue;
- if (Array.isArray(values[settingKey])) {
- stringValue = values[settingKey].join(',');
- } else {
- stringValue = `${values[settingKey]}`;
- }
-
- // Skip empty values to avoid validation errors
- if (!stringValue) {
- continue;
- }
-
- if (!existing) {
- // Create new setting on save
- changedSettings[settingKey] = stringValue;
- } else if (stringValue !== String(existing.value)) {
- // If the user changed the setting's value from what's in the DB:
- changedSettings[settingKey] = stringValue;
-
- // Check if M3U hash key was changed
- if (settingKey === 'm3u-hash-key') {
- m3uHashKeyChanged = true;
- }
- }
- }
-
- // If M3U hash key changed, show warning (unless suppressed)
- if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
- // Store the changed settings before showing dialog
- setPendingChangedSettings(changedSettings);
- setRehashDialogType('save'); // Set dialog type to save
- setRehashConfirmOpen(true);
- return;
- }
-
- // Update each changed setting in the backend (create if missing)
- try {
- for (const updatedKey in changedSettings) {
- const existing = settings[updatedKey];
- if (existing && existing.id) {
- const result = await API.updateSetting({
- ...existing,
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to update setting');
- }
- } else {
- const result = await API.createSetting({
- key: updatedKey,
- name: updatedKey.replace(/-/g, ' '),
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to create setting');
- }
- }
- }
-
- setGeneralSettingsSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- }
- };
-
- const onNetworkAccessSubmit = async () => {
- setNetworkAccessSaved(false);
- setNetworkAccessError(null);
- const check = await API.checkSetting({
- ...settings['network-access'],
- value: JSON.stringify(networkAccessForm.getValues()),
- });
-
- if (check.error && check.message) {
- setNetworkAccessError(`${check.message}: ${check.data}`);
- return;
- }
-
- // For now, only warn if we're blocking the UI
- const blockedAccess = check.UI;
- if (blockedAccess.length == 0) {
- return saveNetworkAccess();
- }
-
- setNetNetworkAccessConfirmCIDRs(blockedAccess);
- setNetworkAccessConfirmOpen(true);
- };
-
- const onProxySettingsSubmit = async () => {
- setProxySettingsSaved(false);
-
- try {
- const result = await API.updateSetting({
- ...settings['proxy-settings'],
- value: JSON.stringify(proxySettingsForm.getValues()),
- });
- // API functions return undefined on error
- if (result) {
- setProxySettingsSaved(true);
- }
- } catch (error) {
- // Error notifications are already shown by API functions
- console.error('Error saving proxy settings:', error);
- }
- };
-
- const onComskipUpload = async () => {
- if (!comskipFile) {
- return;
- }
-
- setComskipUploadLoading(true);
- try {
- const response = await API.uploadComskipIni(comskipFile);
- if (response?.path) {
- notifications.show({
- title: 'comskip.ini uploaded',
- message: response.path,
- autoClose: 3000,
- color: 'green',
- });
- form.setFieldValue('dvr-comskip-custom-path', response.path);
- useSettingsStore.getState().updateSetting({
- ...(settings['dvr-comskip-custom-path'] || {
- key: 'dvr-comskip-custom-path',
- name: 'DVR Comskip Custom Path',
- }),
- value: response.path,
- });
- setComskipConfig({ path: response.path, exists: true });
- }
- } catch (error) {
- console.error('Failed to upload comskip.ini', error);
- } finally {
- setComskipUploadLoading(false);
- setComskipFile(null);
- }
- };
-
- const resetProxySettingsToDefaults = () => {
- const defaultValues = {
- buffering_timeout: 15,
- buffering_speed: 1.0,
- redis_chunk_ttl: 60,
- channel_shutdown_delay: 0,
- channel_init_grace_period: 5,
- };
-
- proxySettingsForm.setValues(defaultValues);
- };
-
- const saveNetworkAccess = async () => {
- setNetworkAccessSaved(false);
- try {
- await API.updateSetting({
- ...settings['network-access'],
- value: JSON.stringify(networkAccessForm.getValues()),
- });
- setNetworkAccessSaved(true);
- setNetworkAccessConfirmOpen(false);
- } catch (e) {
- const errors = {};
- for (const key in e.body.value) {
- errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`;
- }
- networkAccessForm.setErrors(errors);
- }
- };
-
- const onUISettingsChange = (name, value) => {
- switch (name) {
- case 'table-size':
- if (value) setTableSize(value);
- break;
- case 'time-format':
- if (value) setTimeFormat(value);
- break;
- case 'date-format':
- if (value) setDateFormat(value);
- break;
- case 'time-zone':
- if (value) {
- setTimeZone(value);
- persistTimeZoneSetting(value);
- }
- break;
- }
- };
-
- const executeSettingsSaveAndRehash = async () => {
- setRehashConfirmOpen(false);
- setGeneralSettingsSaved(false);
-
- // Use the stored pending values that were captured before the dialog was shown
- const changedSettings = pendingChangedSettings || {};
-
- // Update each changed setting in the backend (create if missing)
- try {
- for (const updatedKey in changedSettings) {
- const existing = settings[updatedKey];
- if (existing && existing.id) {
- const result = await API.updateSetting({
- ...existing,
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to update setting');
- }
- } else {
- const result = await API.createSetting({
- key: updatedKey,
- name: updatedKey.replace(/-/g, ' '),
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to create setting');
- }
- }
- }
-
- // Clear the pending values
- setPendingChangedSettings(null);
- setGeneralSettingsSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- setPendingChangedSettings(null);
- }
- };
-
- const executeRehashStreamsOnly = async () => {
- setRehashingStreams(true);
- setRehashSuccess(false);
- setRehashConfirmOpen(false);
-
- try {
- await API.rehashStreams();
- setRehashSuccess(true);
- setTimeout(() => setRehashSuccess(false), 5000);
- } catch (error) {
- console.error('Error rehashing streams:', error);
- } finally {
- setRehashingStreams(false);
- }
- };
-
- const onRehashStreams = async () => {
- // Skip warning if it's been suppressed
- if (isWarningSuppressed('rehash-streams')) {
- return executeRehashStreamsOnly();
- }
-
- setRehashDialogType('rehash'); // Set dialog type to rehash
- setRehashConfirmOpen(true);
- };
-
- // Create a function to handle the confirmation based on dialog type
- const handleRehashConfirm = () => {
- if (rehashDialogType === 'save') {
- executeSettingsSaveAndRehash();
- } else {
- executeRehashStreamsOnly();
- }
- };
return (
-
-
+
+
-
- UI Settings
-
-
-
+
+ UI Settings
+
+
+
+
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
-
- DVR
-
-
-
-
-
- Stream Settings
-
-
-
-
+
+ Stream Profiles
+
+
+ }>
+
+
+
+
+
-
- User-Agents
-
-
-
-
-
-
- Stream Profiles
-
-
-
-
-
-
-
+
+
Network Access
- {accordianValue == 'network-access' && (
+ {accordianValue === 'network-access' && (
Comma-Delimited CIDR ranges
)}
-
-
-
-
-
-
-
-
+
+
Proxy Settings
-
-
-
-
-
+
+ Backup & Restore
+
+
+ }>
+
+
+
+
+
>
)}
-
- {
- setRehashConfirmOpen(false);
- setRehashDialogType(null);
- // Clear pending values when dialog is cancelled
- setPendingChangedSettings(null);
- }}
- onConfirm={handleRehashConfirm}
- title={
- rehashDialogType === 'save'
- ? 'Save Settings and Rehash Streams'
- : 'Confirm Stream Rehash'
- }
- message={
-
- {`Are you sure you want to rehash all streams?
-
-This process may take a while depending on the number of streams.
-Do not shut down Dispatcharr until the rehashing is complete.
-M3U refreshes will be blocked until this process finishes.
-
-Please ensure you have time to let this complete before proceeding.`}
-
- }
- confirmLabel={
- rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
- }
- cancelLabel="Cancel"
- actionKey="rehash-streams"
- onSuppressChange={suppressWarning}
- size="md"
- />
-
- setNetworkAccessConfirmOpen(false)}
- onConfirm={saveNetworkAccess}
- title={`Confirm Network Access Blocks`}
- message={
- <>
-
- Your client is not included in the allowed networks for the web
- UI. Are you sure you want to proceed?
-
-
-
- {netNetworkAccessConfirmCIDRs.map((cidr) => (
- - {cidr}
- ))}
-
- >
- }
- confirmLabel="Save"
- cancelLabel="Cancel"
- size="md"
- />
);
};
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index 52c31656..19702ae6 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -1,1276 +1,83 @@
-import React, { useMemo, useState, useEffect, useCallback } from 'react';
-import {
- ActionIcon,
- Box,
- Button,
- Card,
- Center,
- Container,
- Flex,
- Group,
- Progress,
- SimpleGrid,
- Stack,
- Text,
- Title,
- Tooltip,
- Select,
- Badge,
- NumberInput,
-} from '@mantine/core';
-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 useLogosStore from '../store/logos';
-import logo from '../images/logo.png';
-import {
- ChevronDown,
- 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 useStreamProfilesStore from '../store/streamProfiles';
-import usePlaylistsStore from '../store/playlists'; // Add this import
-import useSettingsStore from '../store/settings';
-import { useLocation } from 'react-router-dom';
-import { notifications } from '@mantine/notifications';
-import { CustomTable, useTable } from '../components/tables/CustomTable';
import useLocalStorage from '../hooks/useLocalStorage';
-
-dayjs.extend(duration);
-dayjs.extend(relativeTime);
-
-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 VOD Card component similar to ChannelCard
-const VODCard = ({ vodContent }) => {
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
- const [isClientExpanded, setIsClientExpanded] = useState(false);
- const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
-
- // Get metadata from the VOD content
- const metadata = vodContent.content_metadata || {};
- const contentType = vodContent.content_type;
- const isMovie = contentType === 'movie';
- const isEpisode = contentType === 'episode';
-
- // Set up timer to update progress every second
- useEffect(() => {
- const interval = setInterval(() => {
- setUpdateTrigger((prev) => prev + 1);
- }, 1000);
-
- return () => clearInterval(interval);
- }, []);
-
- // Get the individual connection (since we now separate cards per connection)
- const connection =
- vodContent.individual_connection ||
- (vodContent.connections && vodContent.connections[0]);
-
- // Get poster/logo URL
- const posterUrl = metadata.logo_url || logo;
-
- // Format duration for content length
- 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`;
- };
-
- // Get display title
- const getDisplayTitle = () => {
- if (isMovie) {
- return vodContent.content_name;
- } else if (isEpisode) {
- 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}`;
- }
- return vodContent.content_name;
- };
-
- // Get subtitle info
- const getSubtitle = () => {
- if (isMovie) {
- const parts = [];
- if (metadata.genre) parts.push(metadata.genre);
- // We'll handle rating separately as a badge now
- return parts;
- } else if (isEpisode) {
- return [metadata.episode_name || 'Episode'];
- }
- return [];
- };
-
- // Render subtitle
- const renderSubtitle = () => {
- const subtitleParts = getSubtitle();
- if (subtitleParts.length === 0) return null;
-
- return (
-
- {subtitleParts.join(' • ')}
-
- );
- };
-
- // Calculate progress percentage and time
- const calculateProgress = useCallback(() => {
- if (!connection || !metadata.duration_secs) {
- return {
- percentage: 0,
- currentTime: 0,
- totalTime: metadata.duration_secs || 0,
- };
- }
-
- const totalSeconds = metadata.duration_secs;
- let percentage = 0;
- let currentTime = 0;
- const now = Date.now() / 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,
- };
- }, [connection, metadata.duration_secs]);
-
- // Format time for display (e.g., "1:23:45" or "23:45")
- 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')}`;
- }
- };
-
- // Calculate duration for connection
- const calculateConnectionDuration = useCallback((connection) => {
- // If duration is provided by API, use it
- if (connection.duration && connection.duration > 0) {
- return dayjs.duration(connection.duration, 'seconds').humanize();
- }
-
- // 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 = Date.now() / 1000;
- const duration = currentTime - clientStartTime;
- return dayjs.duration(duration, 'seconds').humanize();
- }
- } catch {
- // Ignore parsing errors
- }
- }
-
- return 'Unknown duration';
- }, []);
-
- // Get connection start time for tooltip
- const getConnectionStartTime = useCallback(
- (connection) => {
- if (connection.connected_at) {
- return dayjs(connection.connected_at * 1000).format(
- `${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 dayjs(clientStartTime).format(`${dateFormat} HH:mm:ss`);
- }
- } catch {
- // Ignore parsing errors
- }
- }
-
- return 'Unknown';
- },
- [dateFormat]
- );
-
- return (
-
-
- {/* Header with poster and basic info */}
-
-
-
-
-
-
- {connection && (
-
-
-
- {calculateConnectionDuration(connection)}
-
-
- )}
-
-
-
- {/* Title and type */}
-
-
- {getDisplayTitle()}
-
-
-
-
-
- {isMovie ? 'Movie' : 'TV Episode'}
-
-
-
-
- {/* Display M3U profile information - matching channel card style */}
- {connection &&
- connection.m3u_profile &&
- (connection.m3u_profile.profile_name ||
- connection.m3u_profile.account_name) && (
-
-
-
-
-
-
- {connection.m3u_profile.account_name || 'Unknown Account'}
-
-
-
-
- {connection.m3u_profile.profile_name || 'Default Profile'}
-
-
-
-
-
- )}
-
- {/* Subtitle/episode info */}
- {getSubtitle().length > 0 && (
-
- {renderSubtitle()}
-
- )}
-
- {/* Content information badges - streamlined to avoid duplication */}
-
- {metadata.year && (
-
-
- {metadata.year}
-
-
- )}
-
- {metadata.duration_secs && (
-
-
- {formatDuration(metadata.duration_secs)}
-
-
- )}
-
- {metadata.rating && (
-
-
- {parseFloat(metadata.rating).toFixed(1)}/10
-
-
- )}
-
-
- {/* Progress bar - show current position in content */}
- {connection &&
- metadata.duration_secs &&
- (() => {
- const progress = calculateProgress();
- return progress.totalTime > 0 ? (
-
-
-
- Progress
-
-
- {formatTime(progress.currentTime)} /{' '}
- {formatTime(progress.totalTime)}
-
-
-
-
- {progress.percentage.toFixed(1)}% watched
-
-
- ) : null;
- })()}
-
- {/* Client information section - collapsible like channel cards */}
- {connection && (
-
- {/* Client summary header - always visible */}
- setIsClientExpanded(!isClientExpanded)}
- >
-
-
- Client:
-
-
- {connection.client_ip || 'Unknown IP'}
-
-
-
-
-
- {isClientExpanded ? 'Hide Details' : 'Show Details'}
-
-
-
-
-
- {/* Expanded client details */}
- {isClientExpanded && (
-
- {connection.user_agent &&
- connection.user_agent !== 'Unknown' && (
-
-
- User Agent:
-
-
- {connection.user_agent.length > 100
- ? `${connection.user_agent.substring(0, 100)}...`
- : connection.user_agent}
-
-
- )}
-
-
-
- Client ID:
-
-
- {connection.client_id || 'Unknown'}
-
-
-
- {connection.connected_at && (
-
-
- Connected:
-
- {getConnectionStartTime(connection)}
-
- )}
-
- {connection.duration && connection.duration > 0 && (
-
-
- Watch Duration:
-
-
- {dayjs
- .duration(connection.duration, 'seconds')
- .humanize()}
-
-
- )}
-
- {/* Seek/Position Information */}
- {(connection.last_seek_percentage > 0 ||
- connection.last_seek_byte > 0) && (
- <>
-
-
- Last Seek:
-
-
- {connection.last_seek_percentage?.toFixed(1)}%
- {connection.total_content_size > 0 && (
-
- {' '}
- (
- {Math.round(
- connection.last_seek_byte / (1024 * 1024)
- )}
- MB /{' '}
- {Math.round(
- connection.total_content_size / (1024 * 1024)
- )}
- MB)
-
- )}
-
-
-
- {Number(connection.last_seek_timestamp) > 0 && (
-
-
- Seek Time:
-
-
- {dayjs
- .unix(Number(connection.last_seek_timestamp))
- .fromNow()}
-
-
- )}
- >
- )}
-
- {connection.bytes_sent > 0 && (
-
-
- Data Sent:
-
-
- {(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
-
-
- )}
-
- )}
-
- )}
-
-
- );
-};
-
-// Create a separate component for each channel card to properly handle the hook
-const ChannelCard = ({
- channel,
- clients,
- stopClient,
+import SystemEvents from '../components/SystemEvents';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ getStatsByChannelId,
+ getVODStats,
stopChannel,
- logos,
+ stopClient,
+ stopVODClient,
+} from '../utils/pages/StatsUtils.js';
+const VodConnectionCard = React.lazy(() =>
+ import('../components/cards/VodConnectionCard.jsx'));
+const StreamConnectionCard = React.lazy(() =>
+ import('../components/cards/StreamConnectionCard.jsx'));
+
+const Connections = ({
+ combinedConnections,
+ clients,
channelsByUUID,
+ handleStopVODClient,
}) => {
- 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
- const [data, setData] = useState([]);
- const [previewedStream, setPreviewedStream] = useState(null);
+ const logos = useLogosStore((s) => s.logos);
- // Get Date-format from localStorage
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
- // Get M3U account data from the playlists store
- const m3uAccounts = usePlaylistsStore((s) => s.playlists);
- const [tableSize] = useLocalStorage('table-size', 'default');
- // Get settings for speed threshold
- const settings = useSettingsStore((s) => s.settings);
-
- // Parse proxy settings to get buffering_speed
- const getBufferingSpeedThreshold = () => {
- try {
- if (settings['proxy-settings']?.value) {
- const proxySettings = JSON.parse(settings['proxy-settings'].value);
- return parseFloat(proxySettings.buffering_speed) || 1.0;
- }
- } catch (error) {
- console.error('Error parsing proxy settings:', error);
- }
- return 1.0; // Default fallback
- };
-
- // 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]);
-
- // 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]);
-
- useEffect(() => {
- setData(
- clients
- .filter((client) => client.channel.channel_id === channel.channel_id)
- .map((client) => ({
- id: client.client_id,
- ...client,
- }))
- );
- }, [clients, channel.channel_id]);
-
- const renderHeaderCell = (header) => {
- switch (header.id) {
- default:
- return (
-
-
- {header.column.columnDef.header}
-
-
- );
- }
- };
-
- const renderBodyCell = ({ cell, row }) => {
- switch (cell.column.id) {
- case 'actions':
- return (
-
-
-
-
- stopClient(
- row.original.channel.uuid,
- row.original.client_id
- )
- }
- >
-
-
-
-
-
- );
- }
- };
-
- // 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',
- });
- }
- };
- console.log(data);
-
- const clientsColumns = useMemo(
- () => [
- {
- id: 'expand',
- size: 20,
- },
- {
- header: 'IP Address',
- accessorKey: 'ip_address',
- },
- // Updated Connected column with tooltip
- {
- id: 'connected',
- 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(`${dateFormat} HH:mm:ss`);
- }
-
- // Fallback to connected_at if it exists
- if (row.connected_at) {
- const connectedTime = dayjs(row.connected_at * 1000);
- return connectedTime.format(`${dateFormat} HH:mm:ss`);
- }
-
- return 'Unknown';
- },
- cell: ({ cell }) => (
-
- {cell.getValue()}
-
- ),
- },
- // Update Duration column with tooltip showing exact seconds
- {
- id: 'duration',
- 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()}
-
- );
- },
- },
- {
- id: 'actions',
- header: 'Actions',
- size: tableSize == 'compact' ? 75 : 100,
- },
- ],
- []
- );
-
- // This hook is now at the top level of this component
- const channelClientsTable = useTable({
- ...TableHelper.defaultProperties,
- columns: clientsColumns,
- data,
- allRowIds: data.map((client) => client.id),
- tableCellProps: () => ({
- padding: 4,
- borderColor: '#444',
- color: '#E0E0E0',
- fontSize: '0.85rem',
- }),
- headerCellRenderFns: {
- ip_address: renderHeaderCell,
- connected: renderHeaderCell,
- duration: renderHeaderCell,
- actions: renderHeaderCell,
- },
- bodyCellRenderFns: {
- actions: renderBodyCell,
- },
- getExpandedRowHeight: (row) => {
- return 20 + 28 * row.original.streams.length;
- },
- expandedRowRenderer: ({ row }) => {
- return (
-
-
-
- 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,
- },
- },
- });
-
- // 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) ||
- (previewedStream && previewedStream.logo_url) ||
- null;
-
- useEffect(() => {
- let isMounted = true;
- // Only fetch if we have a stream_id and NO channel.name
- if (!channel.name && channel.stream_id) {
- API.getStreamsByIds([channel.stream_id]).then((streams) => {
- if (isMounted && streams && streams.length > 0) {
- setPreviewedStream(streams[0]);
- }
- });
- }
- return () => {
- isMounted = false;
- };
- }, [channel.name, channel.stream_id]);
-
- const channelName =
- channel.name || previewedStream?.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}]`,
- };
- });
-
- if (location.pathname != '/stats') {
- return <>>;
- }
-
- // Safety check - if channel doesn't have required data, don't render
- if (!channel || !channel.channel_id) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {dayjs.duration(uptime, 'seconds').humanize()}
-
-
-
-
-
- stopChannel(channel.channel_id)}
- >
-
-
-
-
-
-
-
-
-
- {channelName}
-
-
-
-
-
- {streamProfileName}
-
-
-
-
- {/* Display M3U profile information */}
-
-
-
-
- {m3uProfileName}
-
-
-
-
- {/* Add stream selection dropdown */}
- {availableStreams.length > 0 && (
-
-
-
- )}
-
- {/* Add stream information badges */}
-
- {channel.resolution && (
-
-
- {channel.resolution}
-
-
- )}
- {channel.source_fps && (
-
-
- {channel.source_fps} FPS
-
-
- )}
- {channel.video_codec && (
-
-
- {channel.video_codec.toUpperCase()}
-
-
- )}
- {channel.audio_codec && (
-
-
- {channel.audio_codec.toUpperCase()}
-
-
- )}
- {channel.audio_channels && (
-
-
- {channel.audio_channels}
-
-
- )}
- {channel.stream_type && (
-
-
- {channel.stream_type.toUpperCase()}
-
-
- )}
- {channel.ffmpeg_speed && (
-
- =
- getBufferingSpeedThreshold()
- ? 'green'
- : 'red'
- }
- >
- {parseFloat(channel.ffmpeg_speed).toFixed(2)}x
-
-
- )}
-
-
-
-
-
-
-
- {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 StatsPage = () => {
const channels = useChannelsStore((s) => s.channels);
const channelsByUUID = useChannelsStore((s) => s.channelsByUUID);
const channelStats = useChannelsStore((s) => s.stats);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
- const logos = useLogosStore((s) => s.logos);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const [clients, setClients] = useState([]);
@@ -1284,19 +91,22 @@ const ChannelsPage = () => {
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 stopClient = async (channelId, clientId) => {
- await API.stopClient(channelId, clientId);
+ const handleStopVODClient = async (clientId) => {
+ await stopVODClient(clientId);
+ // Refresh VOD stats after stopping to update the UI
+ fetchVODStats();
};
// Function to fetch channel stats from API
const fetchChannelStats = useCallback(async () => {
try {
- const response = await API.fetchActiveChannelStats();
+ const response = await fetchActiveChannelStats();
if (response) {
setChannelStats(response);
} else {
@@ -1314,7 +124,7 @@ const ChannelsPage = () => {
const fetchVODStats = useCallback(async () => {
try {
- const response = await API.getVODStats();
+ const response = await getVODStats();
if (response) {
setVodConnections(response.vod_connections || []);
} else {
@@ -1381,210 +191,112 @@ const ChannelsPage = () => {
// Use functional update to access previous state without dependency
setChannelHistory((prevChannelHistory) => {
// Create a completely new object based only on current channel stats
- const stats = {};
- const newChannelHistory = {}; // Start fresh instead of preserving old channels
-
- 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;
- }
-
- 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)
- );
-
- const channelWithMetadata = {
- ...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,
- };
-
- stats[ch.channel_id] = channelWithMetadata;
- newChannelHistory[ch.channel_id] = channelWithMetadata; // Only add currently active channels
- });
+ const stats = getStatsByChannelId(channelStats, prevChannelHistory, channelsByUUID, channels, streamProfiles);
console.log('Processed active channels:', stats);
// Update clients based on new 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);
+ setClients(getClientStats(stats));
- return newChannelHistory; // Return only currently active channels
+ return stats; // Return only currently active channels
});
}, [channelStats, channels, channelsByUUID, streamProfiles]);
// Combine active streams and VOD connections into a single mixed list
const combinedConnections = useMemo(() => {
- 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
- );
+ return getCombinedConnections(channelHistory, vodConnections);
}, [channelHistory, vodConnections]);
return (
-
-
-
- Active Connections
-
-
- {Object.keys(channelHistory).length} stream
- {Object.keys(channelHistory).length !== 1 ? 's' : ''} •{' '}
- {vodConnections.reduce(
- (total, vodContent) =>
- total + (vodContent.connections?.length || 0),
- 0
- )}{' '}
- VOD connection
- {vodConnections.reduce(
- (total, vodContent) =>
- total + (vodContent.connections?.length || 0),
- 0
- ) !== 1
- ? 's'
- : ''}
-
-
- Refresh Interval (seconds):
- setRefreshIntervalSeconds(value || 0)}
- min={0}
- max={300}
- step={1}
- size="xs"
- style={{ width: 120 }}
- />
- {refreshIntervalSeconds === 0 && (
+ <>
+
+
+
+
+ Active Connections
+
- Refreshing disabled
+ {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
+
+
- {isPollingActive && refreshInterval > 0 && (
-
- Refreshing every {refreshIntervalSeconds}s
-
- )}
- {
- fetchChannelStats();
- fetchVODStats();
- }}
- loading={false}
- >
- Refresh Now
-
-
-
-
-
- {combinedConnections.length === 0 ? (
+
-
- No active connections
-
+
- ) : (
- combinedConnections.map((connection) => {
- if (connection.type === 'stream') {
- return (
-
- );
- } else if (connection.type === 'vod') {
- return (
-
- );
- }
- return null;
- })
- )}
-
-
+
+
+
+ {/* 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
index 570e49c1..e69f07f8 100644
--- a/frontend/src/pages/Users.jsx
+++ b/frontend/src/pages/Users.jsx
@@ -1,55 +1,25 @@
-import React, { useState } from 'react';
import UsersTable from '../components/tables/UsersTable';
import { Box } from '@mantine/core';
import useAuthStore from '../store/auth';
-import { USER_LEVELS } from '../constants';
+import ErrorBoundary from '../components/ErrorBoundary';
-const UsersPage = () => {
+const PageContent = () => {
const authUser = useAuthStore((s) => s.user);
-
- const [selectedUser, setSelectedUser] = useState(null);
- const [userModalOpen, setUserModalOpen] = useState(false);
- const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
- const [deleteTarget, setDeleteTarget] = useState(null);
- const [userToDelete, setUserToDelete] = useState(null);
-
- if (!authUser.id) {
- return <>>;
- }
-
- const closeUserModal = () => {
- setSelectedUser(null);
- setUserModalOpen(false);
- };
- const editUser = (user) => {
- setSelectedUser(user);
- setUserModalOpen(true);
- };
-
- const deleteUser = (id) => {
- // Get user details for the confirmation dialog
- const user = users.find((u) => u.id === id);
- setUserToDelete(user);
- setDeleteTarget(id);
-
- // Skip warning if it's been suppressed
- if (isWarningSuppressed('delete-user')) {
- return executeDeleteUser(id);
- }
-
- setConfirmDeleteOpen(true);
- };
-
- const executeDeleteUser = async (id) => {
- await API.deleteUser(id);
- setConfirmDeleteOpen(false);
- };
+ if (!authUser.id) throw new Error();
return (
-
+
);
+}
+
+const UsersPage = () => {
+ return (
+
+
+
+ );
};
export default UsersPage;
diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx
index 3c9e2b0f..460b7211 100644
--- a/frontend/src/pages/VODs.jsx
+++ b/frontend/src/pages/VODs.jsx
@@ -1,244 +1,31 @@
-import React, { useState, useEffect } from 'react';
+import React, { Suspense, useEffect, useState } from 'react';
import {
Box,
- Button,
- Card,
Flex,
- Group,
- Image,
- Text,
- Title,
- Select,
- TextInput,
- Pagination,
- Badge,
Grid,
+ GridCol,
+ Group,
Loader,
- Stack,
+ LoadingOverlay,
+ Pagination,
SegmentedControl,
- ActionIcon,
+ Select,
+ Stack,
+ TextInput,
+ Title,
} from '@mantine/core';
-import { Search, Play, Calendar, Clock, Star } from 'lucide-react';
+import { Search } from 'lucide-react';
import { useDisclosure } from '@mantine/hooks';
import useVODStore from '../store/useVODStore';
-import SeriesModal from '../components/SeriesModal';
-import VODModal from '../components/VODModal';
-
-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`;
-};
-
-const VODCard = ({ vod, onClick }) => {
- const isEpisode = vod.type === 'episode';
-
- const getDisplayTitle = () => {
- if (isEpisode && vod.series) {
- const seasonEp =
- vod.season_number && vod.episode_number
- ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
- : '';
- return (
-
-
- {vod.series.name}
-
-
- {seasonEp} - {vod.name}
-
-
- );
- }
- return {vod.name};
- };
-
- const handleCardClick = async () => {
- // Just pass the basic vod info to the parent handler
- onClick(vod);
- };
-
- return (
-
-
-
- {vod.logo?.url ? (
-
- ) : (
-
-
-
- )}
-
- {
- e.stopPropagation();
- onClick(vod);
- }}
- >
-
-
-
-
- {isEpisode ? 'Episode' : 'Movie'}
-
-
-
-
-
- {getDisplayTitle()}
-
-
- {vod.year && (
-
-
-
- {vod.year}
-
-
- )}
-
- {vod.duration && (
-
-
-
- {formatDuration(vod.duration_secs)}
-
-
- )}
-
- {vod.rating && (
-
-
-
- {vod.rating}
-
-
- )}
-
-
- {vod.genre && (
-
- {vod.genre}
-
- )}
-
-
- );
-};
-
-const SeriesCard = ({ series, onClick }) => {
- return (
- onClick(series)}
- >
-
-
- {series.logo?.url ? (
-
- ) : (
-
-
-
- )}
- {/* Add Series badge in the same position as Movie badge */}
-
- Series
-
-
-
-
-
- {series.name}
-
-
- {series.year && (
-
-
-
- {series.year}
-
-
- )}
- {series.rating && (
-
-
-
- {series.rating}
-
-
- )}
-
-
- {series.genre && (
-
- {series.genre}
-
- )}
-
-
- );
-};
+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;
@@ -312,19 +99,7 @@ const VODsPage = () => {
};
useEffect(() => {
- // setCategories(allCategories)
- setCategories(
- 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;
- }, {})
- );
+ setCategories(filterCategoriesToEnabled(allCategories));
}, [allCategories]);
useEffect(() => {
@@ -356,19 +131,7 @@ const VODsPage = () => {
setPage(1);
};
- const categoryOptions = [
- { 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})`,
- })),
- ];
+ const categoryOptions = getCategoryOptions(categories, filters);
const totalPages = Math.ceil(totalCount / pageSize);
@@ -396,7 +159,7 @@ const VODsPage = () => {
icon={}
value={filters.search}
onChange={(e) => setFilters({ search: e.target.value })}
- style={{ minWidth: 200 }}
+ miw={200}
/>
{
value={filters.category}
onChange={onCategoryChange}
clearable
- style={{ minWidth: 150 }}
+ miw={150}
/>
{
value: v,
label: v,
}))}
- style={{ width: 110 }}
+ w={110}
/>
@@ -428,23 +191,25 @@ const VODsPage = () => {
) : (
<>
- {getDisplayData().map((item) => (
-
- {item.contentType === 'series' ? (
-
- ) : (
-
- )}
-
- ))}
+
+ }>
+ {getDisplayData().map((item) => (
+
+ {item.contentType === 'series' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
{/* Pagination */}
@@ -462,18 +227,26 @@ const VODsPage = () => {
{/* Series Episodes Modal */}
-
+
+ }>
+
+
+
{/* VOD Details Modal */}
-
+
+ }>
+
+
+
);
};
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}
+ >
+
+ {data?.map((option) => (
+
+ ))}
+
+ ),
+ 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 }) => (
+
+ ),
+ 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 && }
+ 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 && }
+ onChange?.(e.target.value)}
+ aria-label={placeholder || label}
+ >
+ {data?.map((option) => (
+
+ ))}
+
+
+ ),
+ 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
index 58a6d292..01bbe846 100644
--- a/frontend/src/pages/__tests__/guideUtils.test.js
+++ b/frontend/src/pages/__tests__/guideUtils.test.js
@@ -1,100 +1,1108 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import dayjs from 'dayjs';
-import {
- PROGRAM_HEIGHT,
- EXPANDED_PROGRAM_HEIGHT,
- buildChannelIdMap,
- mapProgramsByChannel,
- computeRowHeights,
-} from '../guideUtils.js';
+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('maps tvg ids from epg records and falls back to channel uuid', () => {
+ it('should create map with channel UUIDs when no EPG data', () => {
const channels = [
- { id: 1, epg_data_id: 'epg-1', uuid: 'uuid-1' },
- { id: 2, epg_data_id: null, uuid: 'uuid-2' },
+ { 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: 'alpha' },
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
};
- const map = buildChannelIdMap(channels, tvgsById);
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
- expect(map.get('alpha')).toBe(1);
- expect(map.get('uuid-2')).toBe(2);
+ 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('groups programs by channel and sorts them by start time', () => {
+ 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: 10,
- tvg_id: 'alpha',
- start_time: dayjs('2025-01-01T02:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T03:00:00Z').toISOString(),
- title: 'Late Show',
- },
- {
- id: 11,
- tvg_id: 'alpha',
- start_time: dayjs('2025-01-01T01:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T02:00:00Z').toISOString(),
- title: 'Evening News',
- },
- {
- id: 20,
- tvg_id: 'beta',
- start_time: dayjs('2025-01-01T00:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T01:00:00Z').toISOString(),
- title: 'Morning Show',
+ 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 channelIdByTvgId = new Map([
- ['alpha', 1],
- ['beta', 2],
- ]);
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
- const map = mapProgramsByChannel(programs, channelIdByTvgId);
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(1)[0]).toMatchObject({
+ id: 1,
+ tvg_id: 'tvg-1',
+ });
+ });
- expect(map.get(1)).toHaveLength(2);
- expect(map.get(1)?.map((item) => item.id)).toEqual([11, 10]);
- expect(map.get(2)).toHaveLength(1);
- expect(map.get(2)?.[0].startMs).toBeTypeOf('number');
- expect(map.get(2)?.[0].endMs).toBeTypeOf('number');
+ 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('returns program heights with expanded rows when needed', () => {
- const filteredChannels = [
- { id: 1 },
- { id: 2 },
+ 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 programsByChannel = new Map([
- [1, [{ id: 10 }, { id: 11 }]],
- [2, [{ id: 20 }]],
- ]);
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
- const collapsed = computeRowHeights(
- filteredChannels,
- programsByChannel,
- null
- );
- expect(collapsed).toEqual([PROGRAM_HEIGHT, PROGRAM_HEIGHT]);
+ expect(result).toHaveLength(2);
+ });
- const expanded = computeRowHeights(
- filteredChannels,
- programsByChannel,
- 10
- );
- expect(expanded).toEqual([
- EXPANDED_PROGRAM_HEIGHT,
- PROGRAM_HEIGHT,
- ]);
+ 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 a916f3d9..15ff6e0e 100644
--- a/frontend/src/pages/guide.css
+++ b/frontend/src/pages/guide.css
@@ -70,11 +70,13 @@
/* Hide bottom horizontal scrollbar for the guide's virtualized list only */
.tv-guide .guide-list-outer {
- /* Prevent horizontal page scrollbar while preserving internal scroll behavior */
- overflow-x: hidden !important;
+ /* 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 {
- height: 0px;
+ display: none; /* Chrome, Safari, Opera */
}
diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js
index 1f4ff671..68bb74b2 100644
--- a/frontend/src/pages/guideUtils.js
+++ b/frontend/src/pages/guideUtils.js
@@ -1,7 +1,26 @@
-import dayjs from 'dayjs';
+import {
+ convertToMs,
+ initializeTime,
+ startOfDay,
+ isBefore,
+ isAfter,
+ isSame,
+ add,
+ diff,
+ format,
+ getNow,
+ getNowMs,
+ roundToNearest
+} from '../utils/dateTimeUtils.js';
+import API from '../api.js';
export const PROGRAM_HEIGHT = 90;
export const EXPANDED_PROGRAM_HEIGHT = 180;
+/** Layout constants */
+export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
+export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
+export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
+export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const map = new Map();
@@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
return map;
}
-export function mapProgramsByChannel(programs, channelIdByTvgId) {
+export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
if (!programs?.length || !channelIdByTvgId?.size) {
return new Map();
}
const map = new Map();
+ const nowMs = getNowMs();
+
programs.forEach((program) => {
const channelIds = channelIdByTvgId.get(String(program.tvg_id));
if (!channelIds || channelIds.length === 0) {
return;
}
- const startMs = program.startMs ?? dayjs(program.start_time).valueOf();
- const endMs = program.endMs ?? dayjs(program.end_time).valueOf();
+ const startMs = program.startMs ?? convertToMs(program.start_time);
+ const endMs = program.endMs ?? convertToMs(program.end_time);
const programData = {
...program,
startMs,
endMs,
+ programStart: initializeTime(program.startMs),
+ programEnd: initializeTime(program.endMs),
+ // Precompute live/past status
+ isLive: nowMs >= program.startMs && nowMs < program.endMs,
+ isPast: nowMs >= program.endMs,
};
// Add this program to all channels that share the same TVG ID
@@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) {
});
return map;
-}
+};
export function computeRowHeights(
filteredChannels,
@@ -94,3 +120,282 @@ export function computeRowHeights(
return expanded ? expandedHeight : defaultHeight;
});
}
+
+export const fetchPrograms = async () => {
+ console.log('Fetching program grid...');
+ const fetched = await API.getGrid(); // GETs your EPG grid
+ console.log(`Received ${fetched.length} programs`);
+
+ return fetched.map((program) => {
+ return {
+ ...program,
+ startMs: convertToMs(program.start_time),
+ endMs: convertToMs(program.end_time),
+ };
+ });
+};
+
+export const sortChannels = (channels) => {
+ // Include ALL channels, sorted by channel number - don't filter by EPG data
+ const sortedChannels = Object.values(channels).sort(
+ (a, b) =>
+ (a.channel_number || Infinity) - (b.channel_number || Infinity)
+ );
+
+ console.log(`Using all ${sortedChannels.length} available channels`);
+ return sortedChannels;
+}
+
+export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
+ return guideChannels.filter((channel) => {
+ // Search filter
+ if (searchQuery) {
+ if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
+ }
+
+ // Channel group filter
+ if (selectedGroupId !== 'all') {
+ if (channel.channel_group_id !== parseInt(selectedGroupId)) return false;
+ }
+
+ // Profile filter
+ if (selectedProfileId !== 'all') {
+ const profileChannels = profiles[selectedProfileId]?.channels || [];
+ const enabledChannelIds = Array.isArray(profileChannels)
+ ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
+ : profiles[selectedProfileId]?.channels instanceof Set
+ ? Array.from(profiles[selectedProfileId].channels)
+ : [];
+
+ if (!enabledChannelIds.includes(channel.id)) return false;
+ }
+
+ return true;
+ });
+}
+
+export const calculateEarliestProgramStart = (programs, defaultStart) => {
+ if (!programs.length) return defaultStart;
+ return programs.reduce((acc, p) => {
+ const s = initializeTime(p.start_time);
+ return isBefore(s, acc) ? s : acc;
+ }, defaultStart);
+}
+
+export const calculateLatestProgramEnd = (programs, defaultEnd) => {
+ if (!programs.length) return defaultEnd;
+ return programs.reduce((acc, p) => {
+ const e = initializeTime(p.end_time);
+ return isAfter(e, acc) ? e : acc;
+ }, defaultEnd);
+}
+
+export const calculateStart = (earliestProgramStart, defaultStart) => {
+ return isBefore(earliestProgramStart, defaultStart)
+ ? earliestProgramStart
+ : defaultStart;
+}
+
+export const calculateEnd = (latestProgramEnd, defaultEnd) => {
+ return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
+}
+
+export const mapChannelsById = (guideChannels) => {
+ const map = new Map();
+ guideChannels.forEach((channel) => {
+ map.set(channel.id, channel);
+ });
+ return map;
+}
+
+export const mapRecordingsByProgramId = (recordings) => {
+ const map = new Map();
+ (recordings || []).forEach((recording) => {
+ const programId = recording?.custom_properties?.program?.id;
+ if (programId != null) {
+ map.set(programId, recording);
+ }
+ });
+ return map;
+}
+
+export const formatTime = (time, dateFormat) => {
+ const today = startOfDay(getNow());
+ const tomorrow = add(today, 1, 'day');
+ const weekLater = add(today, 7, 'day');
+ const day = startOfDay(time);
+
+ if (isSame(day, today, 'day')) {
+ return 'Today';
+ } else if (isSame(day, tomorrow, 'day')) {
+ return 'Tomorrow';
+ } else if (isBefore(day, weekLater)) {
+ // Within a week, show day name
+ return format(time, 'dddd');
+ } else {
+ // Beyond a week, show month and day
+ return format(time, dateFormat);
+ }
+}
+
+export const calculateHourTimeline = (start, end, formatDayLabel) => {
+ const hours = [];
+ let current = start;
+ let currentDay = null;
+
+ while (isBefore(current, end)) {
+ // Check if we're entering a new day
+ const day = startOfDay(current);
+ const isNewDay = !currentDay || !isSame(day, currentDay, 'day');
+
+ if (isNewDay) {
+ currentDay = day;
+ }
+
+ // Add day information to our hour object
+ hours.push({
+ time: current,
+ isNewDay,
+ dayLabel: formatDayLabel(current),
+ });
+
+ current = add(current, 1, 'hour');
+ }
+ return hours;
+}
+
+export const calculateNowPosition = (now, start, end) => {
+ if (isBefore(now, start) || isAfter(now, end)) return -1;
+ const minutesSinceStart = diff(now, start, 'minute');
+ return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const calculateScrollPosition = (now, start) => {
+ const roundedNow = roundToNearest(now, 30);
+ const nowOffset = diff(roundedNow, start, 'minute');
+ const scrollPosition =
+ (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
+
+ return Math.max(scrollPosition, 0);
+};
+
+export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
+ const channelIds = channelIdByTvgId.get(String(tvgId));
+ if (!channelIds || channelIds.length === 0) {
+ return null;
+ }
+ // Return the first channel that matches this TVG ID
+ return channelById.get(channelIds[0]) || null;
+}
+
+export const fetchRules = async () => {
+ return await API.listSeriesRules();
+}
+
+export const getRuleByProgram = (rules, program) => {
+ return (rules || []).find(
+ (r) =>
+ String(r.tvg_id) === String(program.tvg_id) &&
+ (!r.title || r.title === program.title)
+ );
+}
+
+export const createRecording = async (channel, program) => {
+ await API.createRecording({
+ channel: `${channel.id}`,
+ start_time: program.start_time,
+ end_time: program.end_time,
+ custom_properties: { program },
+ });
+}
+
+export const createSeriesRule = async (program, mode) => {
+ await API.createSeriesRule({
+ tvg_id: program.tvg_id,
+ mode,
+ title: program.title,
+ });
+}
+
+export const evaluateSeriesRule = async (program) => {
+ await API.evaluateSeriesRules(program.tvg_id);
+}
+
+export const calculateLeftScrollPosition = (program, start) => {
+ const programStartMs =
+ program.startMs ?? convertToMs(program.start_time);
+ const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
+
+ return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const calculateDesiredScrollPosition = (leftPx) => {
+ return Math.max(0, leftPx - 20);
+}
+
+export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const clickPositionX = event.clientX - rect.left;
+ const percentageAcross = clickPositionX / rect.width;
+ const minuteWithinHour = percentageAcross * 60;
+
+ const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
+
+ const adjustedTime = (snappedMinute === 60)
+ ? add(clickedTime, 1, 'hour').minute(0)
+ : clickedTime.minute(snappedMinute);
+
+ const snappedOffset = diff(adjustedTime, start, 'minute');
+ return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const getGroupOptions = (channelGroups, guideChannels) => {
+ const options = [{ value: 'all', label: 'All Channel Groups' }];
+
+ if (channelGroups && guideChannels.length > 0) {
+ // Get unique channel group IDs from the channels that have program data
+ const usedGroupIds = new Set();
+ guideChannels.forEach((channel) => {
+ if (channel.channel_group_id) {
+ usedGroupIds.add(channel.channel_group_id);
+ }
+ });
+ // Only add groups that are actually used by channels in the guide
+ Object.values(channelGroups)
+ .filter((group) => usedGroupIds.has(group.id))
+ .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
+ .forEach((group) => {
+ options.push({
+ value: group.id.toString(),
+ label: group.name,
+ });
+ });
+ }
+ return options;
+}
+
+export const getProfileOptions = (profiles) => {
+ const options = [{ value: 'all', label: 'All Profiles' }];
+
+ if (profiles) {
+ Object.values(profiles).forEach((profile) => {
+ if (profile.id !== '0') {
+ // Skip the 'All' default profile
+ options.push({
+ value: profile.id.toString(),
+ label: profile.name,
+ });
+ }
+ });
+ }
+
+ return options;
+}
+
+export const deleteSeriesRuleByTvgId = async (tvg_id) => {
+ await API.deleteSeriesRule(tvg_id);
+}
+
+export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
+ await API.evaluateSeriesRules(tvg_id);
+}
\ No newline at end of file
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index fd2c52b8..8fe943b7 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -7,7 +7,6 @@ import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
import useUserAgentsStore from './userAgents';
import useUsersStore from './users';
-import useLogosStore from './logos';
import API from '../api';
import { USER_LEVELS } from '../constants';
@@ -43,6 +42,8 @@ const useAuthStore = create((set, get) => ({
throw new Error('Unauthorized');
}
+ set({ user, isAuthenticated: true });
+
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
@@ -63,7 +64,8 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
- set({ user, isAuthenticated: true });
+ // 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);
}
@@ -134,13 +136,21 @@ const useAuthStore = create((set, get) => ({
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
}
},
// 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,
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 3635d784..9fb958b2 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -402,6 +402,7 @@ const useChannelsStore = create((set, get) => ({
try {
set({
recordings: await api.getRecordings(),
+ isLoading: false,
});
} catch (error) {
console.error('Failed to fetch recordings:', error);
diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx
index 6b3ffa81..1760bc45 100644
--- a/frontend/src/store/epgs.jsx
+++ b/frontend/src/store/epgs.jsx
@@ -5,6 +5,7 @@ const useEPGsStore = create((set) => ({
epgs: {},
tvgs: [],
tvgsById: {},
+ tvgsLoaded: false,
isLoading: false,
error: null,
refreshProgress: {},
@@ -36,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,
+ });
}
},
@@ -50,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) => {
@@ -66,6 +80,12 @@ const useEPGsStore = create((set) => ({
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;
@@ -97,18 +117,29 @@ const useEPGsStore = create((set) => ({
? 'success' // Mark as success when progress is 100%
: state.epgs[data.source]?.status || 'idle';
- // Create a new epgs object with the updated source status
- const newEpgs = {
- ...state.epgs,
- [data.source]: {
- ...state.epgs[data.source],
- status: sourceStatus,
- last_message:
- data.status === 'error'
- ? data.error || 'Unknown error'
- : state.epgs[data.source]?.last_message,
- },
- };
+ // 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,
diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx
index eb2a7597..5843b113 100644
--- a/frontend/src/store/logos.jsx
+++ b/frontend/src/store/logos.jsx
@@ -3,22 +3,16 @@ import api from '../api';
const useLogosStore = create((set, get) => ({
logos: {},
- channelLogos: {}, // Keep this for simplicity, but we'll be more careful about when we populate it
+ 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-assignable logos
+ hasLoadedChannelLogos: false, // Track if we've loaded channel logos
error: null,
+ allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready
- // Basic CRUD operations
- setLogos: (logos) => {
- set({
- logos: logos.reduce((acc, logo) => {
- acc[logo.id] = { ...logo };
- return acc;
- }, {}),
- });
- },
+ // Enable logo rendering (call this after tables have loaded and painted)
+ enableLogoRendering: () => set({ allowLogoRendering: true }),
addLogo: (newLogo) =>
set((state) => {
@@ -27,10 +21,9 @@ const useLogosStore = create((set, get) => ({
...state.logos,
[newLogo.id]: { ...newLogo },
};
-
- // Add to channelLogos if the user has loaded channel-assignable logos
+
+ // 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
- // Newly created logos are channel-assignable (they start unused)
let newChannelLogos = state.channelLogos;
if (state.hasLoadedChannelLogos) {
newChannelLogos = {
@@ -74,6 +67,9 @@ const useLogosStore = create((set, get) => ({
// Smart loading methods
fetchLogos: async (pageSize = 100) => {
+ // Don't fetch if logo fetching is not allowed yet
+ if (!get().allowLogoFetching) return [];
+
set({ isLoading: true, error: null });
try {
const response = await api.getLogos({ page_size: pageSize });
@@ -96,11 +92,14 @@ const useLogosStore = create((set, get) => ({
}
},
- fetchAllLogos: async () => {
+ fetchAllLogos: async (force = false) => {
const { isLoading, hasLoadedAll, logos } = get();
// Prevent unnecessary reloading if we already have all logos
- if (isLoading || (hasLoadedAll && Object.keys(logos).length > 0)) {
+ if (
+ !force &&
+ (isLoading || (hasLoadedAll && Object.keys(logos).length > 0))
+ ) {
return Object.values(logos);
}
@@ -161,60 +160,28 @@ const useLogosStore = create((set, get) => ({
},
fetchChannelAssignableLogos: async () => {
- const { backgroundLoading, hasLoadedChannelLogos, channelLogos } = get();
+ const { hasLoadedChannelLogos, channelLogos } = get();
- // Prevent concurrent calls
- if (
- backgroundLoading ||
- (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0)
- ) {
+ // Return cached if already loaded
+ if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) {
return Object.values(channelLogos);
}
- set({ backgroundLoading: true, error: null });
- try {
- // Load logos suitable for channel assignment (unused + channel-used, exclude VOD-only)
- const response = await api.getLogos({
- channel_assignable: 'true',
- no_pagination: 'true', // Get all channel-assignable logos
- });
+ // Fetch all logos and cache them as channel logos
+ const logos = await get().fetchAllLogos();
- // Handle both paginated and non-paginated responses
- const logos = Array.isArray(response) ? response : response.results || [];
+ set({
+ channelLogos: logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ hasLoadedChannelLogos: true,
+ });
- console.log(`Fetched ${logos.length} channel-assignable logos`);
-
- // Store in both places, but this is intentional and only when specifically requested
- set({
- logos: {
- ...get().logos, // Keep existing logos
- ...logos.reduce((acc, logo) => {
- acc[logo.id] = { ...logo };
- return acc;
- }, {}),
- },
- channelLogos: logos.reduce((acc, logo) => {
- acc[logo.id] = { ...logo };
- return acc;
- }, {}),
- hasLoadedChannelLogos: true,
- backgroundLoading: false,
- });
-
- return logos;
- } catch (error) {
- console.error('Failed to fetch channel-assignable logos:', error);
- set({
- error: 'Failed to load channel-assignable logos.',
- backgroundLoading: false,
- });
- throw error;
- }
+ return logos;
},
fetchLogosByIds: async (logoIds) => {
- if (!logoIds || logoIds.length === 0) return [];
-
try {
// Filter out logos we already have
const missingIds = logoIds.filter((id) => !get().logos[id]);
@@ -327,7 +294,7 @@ const useLogosStore = create((set, get) => ({
}, 0); // Execute immediately but asynchronously
},
- // Background loading specifically for channel-assignable logos after login
+ // Background loading for channel logos after login
backgroundLoadChannelLogos: async () => {
const { backgroundLoading, channelLogos, hasLoadedChannelLogos } = get();
@@ -342,10 +309,10 @@ const useLogosStore = create((set, get) => ({
set({ backgroundLoading: true });
try {
- console.log('Background loading channel-assignable logos...');
+ console.log('Background loading channel logos...');
await get().fetchChannelAssignableLogos();
console.log(
- `Background loaded ${Object.keys(get().channelLogos).length} channel-assignable logos`
+ `Background loaded ${Object.keys(get().channelLogos).length} channel logos`
);
} catch (error) {
console.error('Background channel logo loading failed:', error);
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/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
index b0aecd61..43edb1c9 100644
--- a/frontend/src/store/useVODStore.jsx
+++ b/frontend/src/store/useVODStore.jsx
@@ -364,7 +364,7 @@ const useVODStore = create((set, get) => ({
name: seriesInfo.name,
},
type: 'episode',
- uuid: episode.id, // Use the stream ID as UUID for playback
+ uuid: episode.uuid,
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
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/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/requirements.txt b/requirements.txt
index 9d7c1965..3416804d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,32 +1,32 @@
-Django==5.2.4
-psycopg2-binary==2.9.10
-celery[redis]==5.5.3
-djangorestframework==3.16.0
-requests==2.32.4
-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==25.5.1
+gevent==25.9.1
daphne
uwsgi
django-cors-headers
djangorestframework-simplejwt
m3u8
-rapidfuzz==3.13.0
+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.7.1+cpu
+torch==2.9.1+cpu
# ML/NLP dependencies
-sentence-transformers==5.1.0
+sentence-transformers==5.2.0
channels
channels-redis==4.3.0
django-filter
django-celery-beat
-lxml==6.0.0
+lxml==6.0.2
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 f2ebe1df..1aae4039 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.11.2' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process