Switch to LF line endings and apply prettier formatting.

This commit is contained in:
SergeantPanda 2026-02-09 17:01:35 -06:00
parent 78a53e03db
commit 631f3d4528
100 changed files with 2880 additions and 1690 deletions

View file

@ -1,7 +1,7 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{ ignores: ['dist'] },
@ -30,4 +30,4 @@ export default [
],
},
},
]
];

View file

@ -4,10 +4,24 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/favicon-16x16.png"
/>
<link rel="manifest" href="/static/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dispatcharr</title>

View file

@ -3,8 +3,9 @@ export default {
semi: true, // Add semicolons at the end of statements
singleQuote: true, // Use single quotes instead of double
tabWidth: 2, // Set the indentation width
trailingComma: "es5", // Add trailing commas where valid in ES5
trailingComma: 'es5', // Add trailing commas where valid in ES5
printWidth: 80, // Wrap lines at 80 characters
bracketSpacing: true, // Add spaces inside object braces
arrowParens: "always", // Always include parentheses around arrow function parameters
arrowParens: 'always', // Always include parentheses around arrow function parameters
endOfLine: 'lf', // Enforce LF for all files
};

View file

@ -1 +1,19 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -171,7 +171,7 @@ export default class API {
static async logout() {
return await request(`${host}/api/accounts/auth/logout/`, {
auth: true, // Send JWT token so backend can identify the user
auth: true, // Send JWT token so backend can identify the user
method: 'POST',
});
}
@ -261,15 +261,11 @@ export default class API {
API.lastQueryParams = newParams;
const [response, ids] = await Promise.all([
request(
`${host}/api/channels/channels/?${newParams.toString()}`
),
request(`${host}/api/channels/channels/?${newParams.toString()}`),
API.getAllChannelIds(newParams),
]);
useChannelsTableStore
.getState()
.queryChannels(response, newParams);
useChannelsTableStore.getState().queryChannels(response, newParams);
useChannelsTableStore.getState().setAllQueryIds(ids);
return response;
@ -390,7 +386,8 @@ export default class API {
channelData.channel_number === '' ||
channelData.channel_number === null ||
channelData.channel_number === undefined ||
(typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '')
(typeof channelData.channel_number === 'string' &&
channelData.channel_number.trim() === '')
) {
delete channelData.channel_number;
}
@ -719,7 +716,11 @@ export default class API {
}
}
static async createChannelsFromStreamsAsync(streamIds, channelProfileIds = null, startingChannelNumber = null) {
static async createChannelsFromStreamsAsync(
streamIds,
channelProfileIds = null,
startingChannelNumber = null
) {
try {
const requestBody = {
stream_ids: streamIds,
@ -815,15 +816,11 @@ export default class API {
try {
const [response, ids] = await Promise.all([
request(
`${host}/api/channels/streams/?${params.toString()}`
),
request(`${host}/api/channels/streams/?${params.toString()}`),
API.getAllStreamIds(params),
]);
useStreamsTableStore
.getState()
.queryStreams(response, params);
useStreamsTableStore.getState().queryStreams(response, params);
useStreamsTableStore.getState().setAllQueryIds(ids);
return response;
@ -1179,13 +1176,10 @@ export default class API {
static async getCurrentPrograms(channelIds = null) {
try {
const response = await request(
`${host}/api/epg/current-programs/`,
{
method: 'POST',
body: { channel_ids: channelIds },
}
);
const response = await request(`${host}/api/epg/current-programs/`, {
method: 'POST',
body: { channel_ids: channelIds },
});
return response;
} catch (e) {
@ -1318,9 +1312,15 @@ export default class API {
errorNotification('Failed to retrieve timezones', e);
// Return fallback data instead of throwing
return {
timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'],
timezones: [
'UTC',
'US/Eastern',
'US/Central',
'US/Mountain',
'US/Pacific',
],
grouped: {},
count: 5
count: 5,
};
}
}
@ -1444,16 +1444,22 @@ export default class API {
static async refreshAccountInfo(profileId) {
try {
const response = await request(`${host}/api/m3u/refresh-account-info/${profileId}/`, {
method: 'POST',
});
const response = await request(
`${host}/api/m3u/refresh-account-info/${profileId}/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
// If it's a structured error response, return it instead of throwing
if (e.body && typeof e.body === 'object') {
return e.body;
}
errorNotification(`Failed to refresh account info for profile ${profileId}`, e);
errorNotification(
`Failed to refresh account info for profile ${profileId}`,
e
);
throw e;
}
}
@ -1580,7 +1586,11 @@ export default class API {
});
// Wait for the task to complete using token for auth
const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token);
const result = await API.waitForBackupTask(
response.task_id,
onProgress,
response.task_token
);
return result;
} catch (e) {
errorNotification('Failed to create backup', e);
@ -1593,13 +1603,10 @@ export default class API {
const formData = new FormData();
formData.append('file', file);
const response = await request(
`${host}/api/backups/upload/`,
{
method: 'POST',
body: formData,
}
);
const response = await request(`${host}/api/backups/upload/`, {
method: 'POST',
body: formData,
});
return response;
} catch (e) {
errorNotification('Failed to upload backup', e);
@ -1622,7 +1629,9 @@ export default class API {
static async getDownloadToken(filename) {
// Get a download token from the server
try {
const response = await request(`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`);
const response = await request(
`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`
);
return response.token;
} catch (e) {
throw e;
@ -1666,7 +1675,11 @@ export default class API {
// Wait for the task to complete using token for auth
// Token-based auth allows status polling even after DB restore invalidates user sessions
const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token);
const result = await API.waitForBackupTask(
response.task_id,
onProgress,
response.task_token
);
return result;
} catch (e) {
errorNotification('Failed to restore backup', e);
@ -1741,17 +1754,27 @@ export default class API {
return response;
} catch (e) {
// Show only the concise error message for plugin import
const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin';
notifications.show({ title: 'Import failed', message: msg, color: 'red' });
const msg =
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed to import plugin';
notifications.show({
title: 'Import failed',
message: msg,
color: 'red',
});
throw e;
}
}
static async deletePlugin(key) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, {
method: 'DELETE',
});
const response = await request(
`${host}/api/plugins/plugins/${key}/delete/`,
{
method: 'DELETE',
}
);
return response;
} catch (e) {
errorNotification('Failed to delete plugin', e);
@ -1776,10 +1799,13 @@ export default class API {
static async runPluginAction(key, action, params = {}) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/run/`, {
method: 'POST',
body: { action, params },
});
const response = await request(
`${host}/api/plugins/plugins/${key}/run/`,
{
method: 'POST',
body: { action, params },
}
);
return response;
} catch (e) {
errorNotification('Failed to run plugin action', e);
@ -1788,10 +1814,13 @@ export default class API {
static async setPluginEnabled(key, enabled) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, {
method: 'POST',
body: { enabled },
});
const response = await request(
`${host}/api/plugins/plugins/${key}/enabled/`,
{
method: 'POST',
body: { enabled },
}
);
return response;
} catch (e) {
errorNotification('Failed to update plugin enabled state', e);
@ -1973,7 +2002,7 @@ export default class API {
if (!logoIds || logoIds.length === 0) return [];
const params = new URLSearchParams();
logoIds.forEach(id => params.append('ids', id));
logoIds.forEach((id) => params.append('ids', id));
// Disable pagination for ID-based queries to get all matching logos
params.append('no_pagination', 'true');
@ -2440,10 +2469,13 @@ export default class API {
static async updateRecurringRule(ruleId, payload) {
try {
const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, {
method: 'PATCH',
body: payload,
});
const response = await request(
`${host}/api/channels/recurring-rules/${ruleId}/`,
{
method: 'PATCH',
body: payload,
}
);
return response;
} catch (e) {
errorNotification(`Failed to update recurring rule ${ruleId}`, e);
@ -2462,9 +2494,13 @@ export default class API {
static async deleteRecording(id) {
try {
await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' });
await request(`${host}/api/channels/recordings/${id}/`, {
method: 'DELETE',
});
// Optimistically remove locally for instant UI update
try { useChannelsStore.getState().removeRecording(id); } catch {}
try {
useChannelsStore.getState().removeRecording(id);
} catch {}
} catch (e) {
errorNotification(`Failed to delete recording ${id}`, e);
}
@ -2472,9 +2508,12 @@ export default class API {
static async runComskip(recordingId) {
try {
const resp = await request(`${host}/api/channels/recordings/${recordingId}/comskip/`, {
method: 'POST',
});
const resp = await request(
`${host}/api/channels/recordings/${recordingId}/comskip/`,
{
method: 'POST',
}
);
// Refresh recordings list to reflect comskip status when done later
// This endpoint just queues the task; the websocket/refresh will update eventually
return resp;
@ -2512,7 +2551,9 @@ export default class API {
static async deleteSeriesRule(tvgId) {
try {
const encodedTvgId = encodeURIComponent(tvgId);
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { method: 'DELETE' });
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, {
method: 'DELETE',
});
notifications.show({ title: 'Series rule removed' });
} catch (e) {
errorNotification('Failed to remove series rule', e);
@ -2522,9 +2563,12 @@ export default class API {
static async deleteAllUpcomingRecordings() {
try {
const resp = await request(`${host}/api/channels/recordings/bulk-delete-upcoming/`, {
method: 'POST',
});
const resp = await request(
`${host}/api/channels/recordings/bulk-delete-upcoming/`,
{
method: 'POST',
}
);
notifications.show({ title: `Removed ${resp.removed || 0} upcoming` });
useChannelsStore.getState().fetchRecordings();
return resp;
@ -2545,12 +2589,19 @@ export default class API {
}
}
static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title' }) {
static async bulkRemoveSeriesRecordings({
tvg_id,
title = null,
scope = 'title',
}) {
try {
const resp = await request(`${host}/api/channels/series-rules/bulk-remove/`, {
method: 'POST',
body: { tvg_id, title, scope },
});
const resp = await request(
`${host}/api/channels/series-rules/bulk-remove/`,
{
method: 'POST',
body: { tvg_id, title, scope },
}
);
notifications.show({ title: `Removed ${resp.removed || 0} scheduled` });
return resp;
} catch (e) {
@ -2712,13 +2763,10 @@ export default class API {
try {
// Use POST for large ID lists to avoid URL length limitations
if (ids.length > 50) {
const response = await request(
`${host}/api/channels/streams/by-ids/`,
{
method: 'POST',
body: { ids },
}
);
const response = await request(`${host}/api/channels/streams/by-ids/`, {
method: 'POST',
body: { ids },
});
return response;
} else {
// Use GET for small ID lists for backward compatibility
@ -2744,8 +2792,9 @@ export default class API {
return response;
} catch (e) {
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
const isInvalidPage =
e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve movies', e);
@ -2762,8 +2811,9 @@ export default class API {
return response;
} catch (e) {
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
const isInvalidPage =
e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve series', e);
@ -2774,7 +2824,10 @@ export default class API {
static async getAllContent(params = new URLSearchParams()) {
try {
console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`);
console.log(
'Calling getAllContent with URL:',
`${host}/api/vod/all/?${params.toString()}`
);
const response = await request(
`${host}/api/vod/all/?${params.toString()}`
);
@ -2787,8 +2840,9 @@ export default class API {
console.error('Error message:', e.message);
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
const isInvalidPage =
e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve content', e);
@ -2911,9 +2965,8 @@ export default class API {
);
// Update the store with fetched notifications
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore.getState().setNotifications(response.notifications);
return response;
@ -2928,9 +2981,8 @@ export default class API {
const response = await request(`${host}/api/core/notifications/count/`);
// Update the store with the count
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore.getState().setUnreadCount(response.unread_count);
return response;
@ -2962,10 +3014,11 @@ export default class API {
);
// Update the store
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
useNotificationsStore.getState().dismissNotification(response.notification_key);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore
.getState()
.dismissNotification(response.notification_key);
return response;
} catch (e) {
@ -2984,9 +3037,8 @@ export default class API {
);
// Update the store
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore.getState().dismissAllNotifications();
return response;

View file

@ -1 +1,19 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -15,4 +15,4 @@ class ErrorBoundary extends React.Component {
}
}
export default ErrorBoundary;
export default ErrorBoundary;

View file

@ -1,4 +1,11 @@
import { NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core';
import {
NumberInput,
Select,
Switch,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import React from 'react';
export const Field = ({ field, value, onChange }) => {

View file

@ -1,13 +1,13 @@
import React from "react";
import React from 'react';
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../pages/guideUtils.js';
import {Box, Flex, Text} from "@mantine/core";
import {Play} from "lucide-react";
import logo from "../images/logo.png";
import { Box, Flex, Text } from '@mantine/core';
import { Play } from 'lucide-react';
import logo from '../images/logo.png';
const GuideRow = React.memo(({ index, style, data }) => {
const {
@ -36,31 +36,33 @@ const GuideRow = React.memo(({ index, style, data }) => {
: PROGRAM_HEIGHT);
const PlaceholderProgram = () => {
return <>
{Array.from({length: Math.ceil(24 / 2)}).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos='absolute'
left={placeholderIndex * (HOUR_WIDTH * 2)}
top={0}
w={HOUR_WIDTH * 2}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c='#4A5568'
>
<Text size="sm">No program data</Text>
</Box>
)
)}
</>;
}
return (
<>
{Array.from({ length: Math.ceil(24 / 2) }).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos="absolute"
left={placeholderIndex * (HOUR_WIDTH * 2)}
top={0}
w={HOUR_WIDTH * 2}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c="#4A5568"
>
<Text size="sm">No program data</Text>
</Box>
)
)}
</>
);
};
return (
<div
@ -75,7 +77,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
}}
display={'flex'}
h={'100%'}
pos='relative'
pos="relative"
>
<Box
className="channel-logo"
@ -96,7 +98,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
display={'flex'}
left={0}
h={'100%'}
pos='relative'
pos="relative"
onClick={(event) => handleLogoClick(channel, event)}
onMouseEnter={() => setHoveredChannelId(channel.id)}
onMouseLeave={() => setHoveredChannelId(null)}
@ -110,7 +112,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
zIndex: 10,
animation: 'fadeIn 0.2s',
}}
pos='absolute'
pos="absolute"
top={0}
left={0}
right={0}
@ -133,7 +135,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
w={'100%'}
h={'100%'}
p={'4px'}
pos='relative'
pos="relative"
>
<Box
style={{
@ -167,7 +169,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
alignItems: 'center',
justifyContent: 'center',
}}
pos='absolute'
pos="absolute"
bottom={4}
left={'50%'}
p={'2px 8px'}
@ -188,7 +190,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
transition: 'height 0.2s ease',
}}
flex={1}
pos='relative'
pos="relative"
h={'100%'}
pl={0}
>
@ -196,11 +198,13 @@ const GuideRow = React.memo(({ index, style, data }) => {
channelPrograms.map((program) =>
renderProgram(program, undefined, channel)
)
) : <PlaceholderProgram />}
) : (
<PlaceholderProgram />
)}
</Box>
</Box>
</div>
);
});
export default GuideRow;
export default GuideRow;

View file

@ -3,103 +3,102 @@ import { Box, Text } from '@mantine/core';
import { format } from '../utils/dateTimeUtils.js';
import { HOUR_WIDTH } from '../pages/guideUtils.js';
const HourBlock = React.memo(({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {
const { time, isNewDay } = hourData;
const HourBlock = React.memo(
({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {
const { time, isNewDay } = hourData;
return (
<Box
key={format(time)}
style={{
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
}}
w={HOUR_WIDTH}
h={'40px'}
pos='relative'
c='#a0aec0'
onClick={(e) => handleTimeClick(time, e)}
>
<Text
size="sm"
style={{ transform: 'none' }}
pos='absolute'
top={8}
left={4}
bdrs={2}
lh={1.2}
ta='left'
return (
<Box
key={format(time)}
style={{
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
}}
w={HOUR_WIDTH}
h={'40px'}
pos="relative"
c="#a0aec0"
onClick={(e) => handleTimeClick(time, e)}
>
<Text
span
size="xs"
display={'block'}
opacity={0.7}
fw={isNewDay ? 600 : 400}
c={isNewDay ? '#3BA882' : undefined}
size="sm"
style={{ transform: 'none' }}
pos="absolute"
top={8}
left={4}
bdrs={2}
lh={1.2}
ta="left"
>
{formatDayLabel(time)}
<Text
span
size="xs"
display={'block'}
opacity={0.7}
fw={isNewDay ? 600 : 400}
c={isNewDay ? '#3BA882' : undefined}
>
{formatDayLabel(time)}
</Text>
{format(time, timeFormat)}
<Text span size="xs" ml={1} opacity={0.7} />
</Text>
{format(time, timeFormat)}
<Text span size="xs" ml={1} opacity={0.7} />
</Text>
<Box
style={{
backgroundColor: '#27272A',
zIndex: 10,
}}
pos='absolute'
left={0}
top={0}
bottom={0}
w={'1px'}
/>
<Box
style={{
backgroundColor: '#27272A',
zIndex: 10,
}}
pos="absolute"
left={0}
top={0}
bottom={0}
w={'1px'}
/>
<Box
style={{ justifyContent: 'space-between' }}
pos='absolute'
bottom={0}
w={'100%'}
display={'flex'}
p={'0 1px'}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{ backgroundColor: '#718096' }}
w={'1px'}
h={'8px'}
pos='absolute'
bottom={0}
left={`${(minute / 60) * 100}%`}
<Box
style={{ justifyContent: 'space-between' }}
pos="absolute"
bottom={0}
w={'100%'}
display={'flex'}
p={'0 1px'}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{ backgroundColor: '#718096' }}
w={'1px'}
h={'8px'}
pos="absolute"
bottom={0}
left={`${(minute / 60) * 100}%`}
/>
))}
</Box>
</Box>
);
}
);
const HourTimeline = React.memo(
({ hourTimeline, timeFormat, formatDayLabel, handleTimeClick }) => {
return (
<>
{hourTimeline.map((hourData) => (
<HourBlock
key={format(hourData.time)}
hourData={hourData}
timeFormat={timeFormat}
formatDayLabel={formatDayLabel}
handleTimeClick={handleTimeClick}
/>
))}
</Box>
</Box>
);
});
const HourTimeline = React.memo(({
hourTimeline,
timeFormat,
formatDayLabel,
handleTimeClick
}) => {
return (
<>
{hourTimeline.map((hourData) => (
<HourBlock
key={format(hourData.time)}
hourData={hourData}
timeFormat={timeFormat}
formatDayLabel={formatDayLabel}
handleTimeClick={handleTimeClick}
/>
))}
</>
);
});
</>
);
}
);
export default HourTimeline;

View file

@ -1,4 +1,4 @@
import { Text, } from '@mantine/core';
import { Text } from '@mantine/core';
// Short preview that triggers the details modal when clicked
const RecordingSynopsis = ({ description, onOpen }) => {
@ -23,4 +23,4 @@ const RecordingSynopsis = ({ description, onOpen }) => {
);
};
export default RecordingSynopsis;
export default RecordingSynopsis;

View file

@ -29,7 +29,12 @@ const PluginFieldList = ({ plugin, settings, updateField }) => {
));
};
const PluginActionList = ({ plugin, enabled, runningActionId, handlePluginRun }) => {
const PluginActionList = ({
plugin,
enabled,
runningActionId,
handlePluginRun,
}) => {
return plugin.actions.map((action) => (
<Group key={action.id} justify="space-between">
<div>
@ -226,7 +231,12 @@ const PluginCard = ({
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
>
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<ActionIcon
variant="subtle"
size="sm"
@ -306,35 +316,51 @@ const PluginCard = ({
</Text>
)}
{expanded && !missing && enabled && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
settings={settings}
updateField={updateField}
/>
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
</Button>
</Group>
</Stack>
)}
{expanded && !missing && enabled && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
{expanded &&
!missing &&
enabled &&
plugin.fields &&
plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
settings={settings}
updateField={updateField}
/>
<PluginActionStatus running={!!runningActionId} lastResult={lastResult} />
<Group>
<Button
loading={saving}
onClick={save}
variant="default"
size="xs"
>
Save Settings
</Button>
</Group>
</Stack>
</>
)}
)}
{expanded &&
!missing &&
enabled &&
plugin.actions &&
plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
/>
<PluginActionStatus
running={!!runningActionId}
lastResult={lastResult}
/>
</Stack>
</>
)}
</Card>
);
};

View file

@ -8,8 +8,8 @@ import {
Stack,
Text,
} from '@mantine/core';
import {Calendar, Play, Star} from "lucide-react";
import React from "react";
import { Calendar, Play, Star } from 'lucide-react';
import React from 'react';
const SeriesCard = ({ series, onClick }) => {
return (
@ -82,4 +82,4 @@ const SeriesCard = ({ series, onClick }) => {
);
};
export default SeriesCard;
export default SeriesCard;

View file

@ -140,4 +140,4 @@ const VODCard = ({ vod, onClick }) => {
);
};
export default VODCard;
export default VODCard;

View file

@ -74,23 +74,33 @@ export default function ProgramRecordingModal({
Just this one
</Button>
<Button variant="light" onClick={() => {
onRecordSeriesAll();
onClose();
}}>
<Button
variant="light"
onClick={() => {
onRecordSeriesAll();
onClose();
}}
>
Every episode
</Button>
<Button variant="light" onClick={() => {
onRecordSeriesNew();
onClose();
}}>
<Button
variant="light"
onClick={() => {
onRecordSeriesNew();
onClose();
}}
>
New episodes only
</Button>
{recording && (
<>
<Button color="orange" variant="light" onClick={handleRemoveRecording}>
<Button
color="orange"
variant="light"
onClick={handleRemoveRecording}
>
Remove this recording
</Button>
<Button color="red" variant="light" onClick={handleRemoveSeries}>

View file

@ -44,7 +44,11 @@ const toIsoIfDate = (value) => {
const toTimeString = (value) => {
if (!value) return '00:00';
if (typeof value === 'string') {
const parsed = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], true);
const parsed = dayjs(
value,
['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
true
);
if (parsed.isValid()) return parsed.format('HH:mm');
return value;
}
@ -77,7 +81,12 @@ const timeChange = (setter) => (valOrEvent) => {
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
};
const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => {
const RecordingModal = ({
recording = null,
channel = null,
isOpen,
onClose,
}) => {
const channels = useChannelsStore((s) => s.channels);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
@ -93,9 +102,17 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
const singleForm = useForm({
mode: 'controlled',
initialValues: {
channel_id: recording ? `${recording.channel}` : channel ? `${channel.id}` : '',
start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart,
end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
channel_id: recording
? `${recording.channel}`
: channel
? `${channel.id}`
: '',
start_time: recording
? asDate(recording.start_time) || defaultStart
: defaultStart,
end_time: recording
? asDate(recording.end_time) || defaultEnd
: defaultEnd,
},
validate: {
channel_id: isNotEmpty('Select a channel'),
@ -126,13 +143,22 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
},
validate: {
channel_id: isNotEmpty('Select a channel'),
days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'),
days_of_week: (value) =>
value && value.length ? null : 'Pick at least one day',
start_time: (value) => (value ? null : 'Select a start time'),
end_time: (value, values) => {
if (!value) return 'Select an end time';
const start = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
const start = dayjs(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
true
);
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
if (start.isValid() && end.isValid() && end.diff(start, 'minute') === 0) {
if (
start.isValid() &&
end.isValid() &&
end.diff(start, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
@ -192,7 +218,10 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
return aNum - bNum;
});
return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` }));
return list.map((item) => ({
value: `${item.id}`,
label: item.name || `Channel ${item.id}`,
}));
}, [channels]);
const resetForms = () => {
@ -287,7 +316,8 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
icon={<CircleAlert />}
style={{ paddingBottom: 5, marginBottom: 12 }}
>
Recordings may fail if active streams or overlapping recordings use up all available tuners.
Recordings may fail if active streams or overlapping recordings use up
all available tuners.
</Alert>
<Stack gap="md">
@ -330,14 +360,24 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
key={singleForm.key('start_time')}
label="Start"
valueFormat="MMM D, YYYY h:mm A"
timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
timeInputProps={{
format: '12',
withSeconds: false,
amLabel: 'AM',
pmLabel: 'PM',
}}
/>
<DateTimePicker
{...singleForm.getInputProps('end_time')}
key={singleForm.key('end_time')}
label="End"
valueFormat="MMM D, YYYY h:mm A"
timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
timeInputProps={{
format: '12',
withSeconds: false,
amLabel: 'AM',
pmLabel: 'PM',
}}
/>
</>
) : (
@ -364,14 +404,19 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
label="Start date"
value={recurringForm.values.start_date}
onChange={(value) =>
recurringForm.setFieldValue('start_date', value || new Date())
recurringForm.setFieldValue(
'start_date',
value || new Date()
)
}
valueFormat="MMM D, YYYY"
/>
<DatePickerInput
label="End date"
value={recurringForm.values.end_date}
onChange={(value) => recurringForm.setFieldValue('end_date', value)}
onChange={(value) =>
recurringForm.setFieldValue('end_date', value)
}
valueFormat="MMM D, YYYY"
minDate={recurringForm.values.start_date || undefined}
/>
@ -382,11 +427,14 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
label="Start time"
value={recurringForm.values.start_time}
onChange={timeChange((val) =>
recurringForm.setFieldValue('start_time', toTimeString(val))
recurringForm.setFieldValue(
'start_time',
toTimeString(val)
)
)}
onBlur={() => recurringForm.validateField('start_time')}
withSeconds={false}
format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
inputMode="numeric"
amLabel="AM"
pmLabel="PM"

View file

@ -2,14 +2,17 @@ import React from 'react';
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js';
import {
evaluateSeriesRulesByTvgId,
fetchRules,
} from '../../pages/guideUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
export default function SeriesRecordingModal({
opened,
onClose,
rules,
onRulesUpdate
opened,
onClose,
rules,
onRulesUpdate,
}) {
const handleEvaluateNow = async (r) => {
await evaluateSeriesRulesByTvgId(r.tvg_id);
@ -56,35 +59,36 @@ export default function SeriesRecordingModal({
No series rules configured
</Text>
)}
{rules && rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}`}
justify="space-between"
align="center"
>
<Text size="sm">
{r.title || r.tvg_id} {' '}
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove this series (scheduled)
</Button>
</Group>
</Flex>
))}
{rules &&
rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}`}
justify="space-between"
align="center"
>
<Text size="sm">
{r.title || r.tvg_id} {' '}
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove this series (scheduled)
</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
);

View file

@ -22,10 +22,14 @@ const getEpgSettingsFromStore = (settings) => {
const epgSettings = settings?.['epg_settings']?.value;
return {
epg_match_mode: epgSettings?.epg_match_mode || 'default',
epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes)
epg_match_ignore_prefixes: Array.isArray(
epgSettings?.epg_match_ignore_prefixes
)
? epgSettings.epg_match_ignore_prefixes
: [],
epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes)
epg_match_ignore_suffixes: Array.isArray(
epgSettings?.epg_match_ignore_suffixes
)
? epgSettings.epg_match_ignore_suffixes
: [],
epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom)
@ -34,11 +38,7 @@ const getEpgSettingsFromStore = (settings) => {
};
};
const EPGMatchModal = ({
opened,
onClose,
selectedChannelIds = [],
}) => {
const EPGMatchModal = ({ opened, onClose, selectedChannelIds = [] }) => {
const settings = useSettingsStore((s) => s.settings);
const [loading, setLoading] = useState(false);
@ -107,9 +107,10 @@ const EPGMatchModal = ({
}
};
const scopeText = selectedChannelIds.length > 0
? `${selectedChannelIds.length} selected channel(s)`
: 'all channels without EPG';
const scopeText =
selectedChannelIds.length > 0
? `${selectedChannelIds.length} selected channel(s)`
: 'all channels without EPG';
return (
<Modal
@ -191,8 +192,8 @@ const EPGMatchModal = ({
/>
<Text size="xs" c="dimmed">
Channel display names are never modified. These settings only affect
the matching algorithm.
Channel display names are never modified. These settings only
affect the matching algorithm.
</Text>
</>
)}

View file

@ -43,7 +43,14 @@ vi.mock('../../../store/settings', () => ({
vi.mock('@mantine/core', () => {
const React = require('react');
const RadioComponent = ({ label, value, checked, description, groupValue, groupOnChange }) => {
const RadioComponent = ({
label,
value,
checked,
description,
groupValue,
groupOnChange,
}) => {
const isChecked = checked !== undefined ? checked : groupValue === value;
const handleChange = groupOnChange || (() => {});
@ -67,17 +74,26 @@ vi.mock('@mantine/core', () => {
const enhancedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// If it's a Stack or other container, recursively enhance its children
if (child.type?.name === 'Stack' || child.props['data-testid'] === 'stack') {
if (
child.type?.name === 'Stack' ||
child.props['data-testid'] === 'stack'
) {
return React.cloneElement(child, {
children: React.Children.map(child.props.children, (nestedChild) => {
if (React.isValidElement(nestedChild) && nestedChild.type === RadioComponent) {
return React.cloneElement(nestedChild, {
groupValue: value,
groupOnChange: onChange,
});
children: React.Children.map(
child.props.children,
(nestedChild) => {
if (
React.isValidElement(nestedChild) &&
nestedChild.type === RadioComponent
) {
return React.cloneElement(nestedChild, {
groupValue: value,
groupOnChange: onChange,
});
}
return nestedChild;
}
return nestedChild;
}),
),
});
}
// If it's a Radio component, inject props directly
@ -157,7 +173,9 @@ describe('EPGMatchModal', () => {
it('should not show advanced fields in default mode', () => {
render(<EPGMatchModal {...defaultProps} />);
expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Ignore Prefixes')
).not.toBeInTheDocument();
});
it('should show advanced fields when advanced mode is selected', async () => {
@ -169,7 +187,9 @@ describe('EPGMatchModal', () => {
await waitFor(() => {
expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Custom Strings')).toBeInTheDocument();
expect(
screen.getByLabelText('Ignore Custom Strings')
).toBeInTheDocument();
});
});
});
@ -199,7 +219,9 @@ describe('EPGMatchModal', () => {
describe('Form Submission', () => {
it('should save mode and trigger auto-match', async () => {
SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
SettingsUtils.getChangedSettings.mockReturnValue({
epg_match_mode: 'default',
});
SettingsUtils.saveChangedSettings.mockResolvedValue();
API.matchEpg.mockResolvedValue();
@ -220,7 +242,9 @@ describe('EPGMatchModal', () => {
SettingsUtils.getChangedSettings.mockReturnValue({});
API.matchEpg.mockResolvedValue();
render(<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />);
render(
<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />
);
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
@ -232,7 +256,9 @@ describe('EPGMatchModal', () => {
it('should handle save errors gracefully', async () => {
const error = new Error('Save failed');
SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
SettingsUtils.getChangedSettings.mockReturnValue({
epg_match_mode: 'default',
});
SettingsUtils.saveChangedSettings.mockRejectedValue(error);
render(<EPGMatchModal {...defaultProps} />);
@ -270,13 +296,23 @@ describe('EPGMatchModal', () => {
describe('UI Text', () => {
it('should show correct text for selected channels', () => {
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />);
expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
render(
<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />
);
expect(
screen.getByText(
/Match channels to EPG data for 3 selected channel\(s\)/
)
).toBeInTheDocument();
});
it('should show correct text for all channels', () => {
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[]} />);
expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument();
expect(
screen.getByText(
/Match channels to EPG data for all channels without EPG/
)
).toBeInTheDocument();
});
});
});

View file

@ -13,21 +13,21 @@
/* Active state styles */
.navlink.navlink-active {
color: #FFFFFF;
color: #ffffff;
background-color: #245043;
border: 1px solid #3BA882;
border: 1px solid #3ba882;
}
/* Hover effect */
.navlink:hover {
background-color: #2A2F34; /* Gray hover effect when not active */
border: 1px solid #3D3D42;
background-color: #2a2f34; /* Gray hover effect when not active */
border: 1px solid #3d3d42;
}
/* Hover effect for active state */
.navlink.navlink-active:hover {
background-color: #3A3A40;
border: 1px solid #3BA882;
background-color: #3a3a40;
border: 1px solid #3ba882;
}
/* Collapse condition for justifyContent */

View file

@ -180,7 +180,6 @@ const ChannelTableHeader = ({
}
};
const assignChannels = async () => {
try {
// Call our custom API endpoint

View file

@ -92,7 +92,7 @@ html {
opacity: 0;
}
*:hover>.resizer {
*:hover > .resizer {
opacity: 1;
}
}
@ -104,7 +104,7 @@ html {
/* .table-striped .tbody .tr:nth-child(even), */
.table-striped .tbody .tr-even {
background-color: #27272A;
background-color: #27272a;
}
/* Style for rows with no streams */
@ -138,7 +138,7 @@ html {
/* Always allow text selection in editable elements */
.shift-key-active input,
.shift-key-active textarea,
.shift-key-active [contenteditable="true"],
.shift-key-active [contenteditable='true'],
.shift-key-active .table-input-header input {
user-select: text !important;
-webkit-user-select: text !important;
@ -169,7 +169,7 @@ html {
/* Always allow text selection in inputs even when shift is pressed */
.shift-key-active input,
.shift-key-active textarea,
.shift-key-active [contenteditable="true"],
.shift-key-active [contenteditable='true'],
.shift-key-active select,
.shift-key-active .mantine-Select-input,
.shift-key-active .mantine-MultiSelect-input,

View file

@ -326,22 +326,31 @@ export const REGION_CHOICES = [
export const VOD_TYPES = {
MOVIE: 'movie',
EPISODE: 'episode'
EPISODE: 'episode',
};
export const VOD_FILTERS = {
ALL: 'all',
MOVIES: 'movies',
SERIES: 'series'
SERIES: 'series',
};
export const VOD_SORT_OPTIONS = [
{ value: 'name', label: 'Name' },
{ value: 'year', label: 'Year' },
{ value: 'created_at', label: 'Date Added' },
{ value: 'rating', label: 'Rating' }
{ value: 'rating', label: 'Rating' },
];
export const CONTAINER_EXTENSIONS = [
'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
'mp4',
'mkv',
'avi',
'mov',
'wmv',
'flv',
'webm',
'm4v',
'ts',
'mpg',
];

View file

@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
})
}),
};
})();
@ -32,7 +32,9 @@ describe('useLocalStorage', () => {
});
it('should initialize with default value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('defaultValue');
});
@ -40,7 +42,9 @@ describe('useLocalStorage', () => {
it('should initialize with value from localStorage if available', () => {
localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('storedValue');
});
@ -52,14 +56,19 @@ describe('useLocalStorage', () => {
result.current[1]('updated');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated'));
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'testKey',
JSON.stringify('updated')
);
expect(result.current[0]).toBe('updated');
});
it('should handle complex objects', () => {
const complexObject = { name: 'test', count: 42, nested: { value: true } };
const { result } = renderHook(() => useLocalStorage('testKey', complexObject));
const { result } = renderHook(() =>
useLocalStorage('testKey', complexObject)
);
act(() => {
result.current[1]({ name: 'updated', count: 100 });
@ -73,7 +82,9 @@ describe('useLocalStorage', () => {
throw new Error('Read error');
});
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalledWith(
@ -102,7 +113,9 @@ describe('useLocalStorage', () => {
it('should handle invalid JSON in localStorage', () => {
localStorageMock.getItem.mockReturnValueOnce('invalid json{');
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalled();

View file

@ -1,6 +1,10 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos';
import {
useLogoSelection,
useChannelLogoSelection,
useLogosById,
} from '../useSmartLogos';
import useLogosStore from '../../store/logos';
// Mock the logos store
@ -13,10 +17,12 @@ describe('useSmartLogos', () => {
});
it('should initialize with empty state', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogos: vi.fn(),
})
);
const { result } = renderHook(() => useLogoSelection());
@ -27,10 +33,12 @@ describe('useSmartLogos', () => {
it('should load logos when ensureLogosLoaded is called', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: mockFetchLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogos: mockFetchLogos,
})
);
const { result } = renderHook(() => useLogoSelection());
@ -43,10 +51,12 @@ describe('useSmartLogos', () => {
it('should not reload logos if already loaded', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' } },
fetchLogos: mockFetchLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: { logo1: { id: 'logo1' } },
fetchLogos: mockFetchLogos,
})
);
const { result } = renderHook(() => useLogoSelection());
@ -62,12 +72,18 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching logos', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: mockFetchLogos,
}));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetchLogos = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogos: mockFetchLogos,
})
);
const { result } = renderHook(() => useLogoSelection());
@ -84,10 +100,12 @@ describe('useSmartLogos', () => {
});
it('should indicate hasLogos when logos are present', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
fetchLogos: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
fetchLogos: vi.fn(),
})
);
const { result } = renderHook(() => useLogoSelection());
@ -101,12 +119,14 @@ describe('useSmartLogos', () => {
});
it('should initialize with channel logos state', () => {
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: vi.fn(),
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -117,12 +137,14 @@ describe('useSmartLogos', () => {
it('should load channel logos when ensureLogosLoaded is called', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -135,12 +157,14 @@ describe('useSmartLogos', () => {
it('should not reload if already loaded', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: { logo1: { id: 'logo1' } },
hasLoadedChannelLogos: true,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: { logo1: { id: 'logo1' } },
hasLoadedChannelLogos: true,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -153,12 +177,14 @@ describe('useSmartLogos', () => {
it('should not load if backgroundLoading is true', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: true,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: true,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -170,14 +196,20 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching channel logos', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetchChannelLogos = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -200,10 +232,12 @@ describe('useSmartLogos', () => {
});
it('should initialize with empty logos', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: vi.fn(),
})
);
const { result } = renderHook(() => useLogosById([]));
@ -214,10 +248,12 @@ describe('useSmartLogos', () => {
it('should fetch missing logos by IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1', 'logo2']));
@ -228,10 +264,12 @@ describe('useSmartLogos', () => {
it('should not fetch logos that are already loaded', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' } },
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: { logo1: { id: 'logo1' } },
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1', 'logo2']));
@ -241,12 +279,18 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching logos by IDs', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetchLogosByIds = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1']));
@ -262,10 +306,12 @@ describe('useSmartLogos', () => {
it('should filter out null/undefined IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
@ -276,10 +322,12 @@ describe('useSmartLogos', () => {
it('should not refetch the same IDs multiple times', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
const { rerender } = renderHook(() => useLogosById(['logo1']));

View file

@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
})
}),
};
})();
@ -50,7 +50,10 @@ describe('useTablePreferences', () => {
});
it('should initialize headerPinned from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
const { result } = renderHook(() => useTablePreferences());
@ -58,7 +61,10 @@ describe('useTablePreferences', () => {
});
it('should initialize tableSize from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ tableSize: 'compact' })
);
const { result } = renderHook(() => useTablePreferences());
@ -66,7 +72,10 @@ describe('useTablePreferences', () => {
});
it('should initialize both preferences from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })
);
const { result } = renderHook(() => useTablePreferences());
@ -83,7 +92,10 @@ describe('useTablePreferences', () => {
});
it('should prefer new localStorage location over old location', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ tableSize: 'comfortable' })
);
localStorageMock.setItem('table-size', JSON.stringify('compact'));
const { result } = renderHook(() => useTablePreferences());
@ -127,7 +139,10 @@ describe('useTablePreferences', () => {
});
it('should preserve existing preferences when updating headerPinned', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ tableSize: 'compact' })
);
const { result } = renderHook(() => useTablePreferences());
@ -205,7 +220,10 @@ describe('useTablePreferences', () => {
});
it('should preserve existing preferences when updating tableSize', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
const { result } = renderHook(() => useTablePreferences());
@ -320,7 +338,10 @@ describe('useTablePreferences', () => {
});
it('should not update if value is the same', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
const { result } = renderHook(() => useTablePreferences());
const initialHeaderPinned = result.current.headerPinned;

View file

@ -7,9 +7,9 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #18181b;
@ -18,8 +18,8 @@ body {
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
/* Example scrollbars - optional, to match a dark theme. */
@ -28,7 +28,7 @@ code {
}
::-webkit-scrollbar-track {
background: #3B3C41;
background: #3b3c41;
}
::-webkit-scrollbar-thumb {
@ -45,7 +45,9 @@ code {
}
}
table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel {
table.mrt-table
tr.mantine-Table-tr.mantine-Table-tr-detail-panel
td.mantine-Table-td-detail-panel {
width: 100% !important;
}
@ -71,7 +73,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
/* Create a short vertical bar */
.sash.sash-vertical::before {
content: "";
content: '';
position: absolute;
top: 50%;
left: 50%;
@ -103,7 +105,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
}
/* For elements that specifically need transforms (like draggable elements) */
[data-draggable="true"] {
[data-draggable='true'] {
transform: translate3d(0, 0, 0) !important;
}
@ -137,4 +139,4 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View file

@ -4,6 +4,6 @@ import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<App />
</React.StrictMode>
);

View file

@ -2,7 +2,7 @@ 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'
import ErrorBoundary from '../components/ErrorBoundary';
const PageContent = () => {
const error = useUserAgentsStore((state) => state.error);
@ -28,14 +28,14 @@ const PageContent = () => {
</Box>
</Stack>
);
}
};
const M3UPage = () => {
return (
<ErrorBoundary>
<PageContent/>
<PageContent />
</ErrorBoundary>
);
}
};
export default M3UPage;

View file

@ -10,24 +10,23 @@ import {
Title,
useMantineTheme,
} from '@mantine/core';
import {
SquarePlus,
} from 'lucide-react';
import { SquarePlus } from 'lucide-react';
import useChannelsStore from '../store/channels';
import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
import {
isAfter,
isBefore,
useTimeHelpers,
} from '../utils/dateTimeUtils.js';
const RecordingDetailsModal = lazy(() =>
import('../components/forms/RecordingDetailsModal'));
import { 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 {
getPosterUrl,
getRecordingUrl,
getShowVideoUrl,
} from '../utils/cards/RecordingCardUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
@ -113,32 +112,35 @@ const DVRPage = () => {
const now = userNow();
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
if(isAfter(now, s) && isBefore(now, e)) {
if (isAfter(now, s) && isBefore(now, e)) {
// call into child RecordingCard behavior by constructing a URL like there
const channel = channels[rec.channel];
if (!channel) return;
const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode);
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;
detailsRecording.custom_properties,
useSettingsStore.getState().environment.env_mode
);
if (!url) return;
useVideoStore.getState().showVideo(url, 'vod', {
name:
detailsRecording.custom_properties?.program?.title ||
'Recording',
name: detailsRecording.custom_properties?.program?.title || 'Recording',
logo: {
url: getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
undefined,
channels[detailsRecording.channel]?.logo?.cache_url
)
),
},
});
}
};
return (
<Box p={10}>
<Button
@ -170,11 +172,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
{<RecordingList
list={inProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{
<RecordingList
list={inProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
}
{inProgress.length === 0 && (
<Text size="sm" c="dimmed">
Nothing recording right now.
@ -196,11 +200,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
{<RecordingList
list={upcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{
<RecordingList
list={upcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
}
{upcoming.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming recordings.
@ -222,11 +228,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
{<RecordingList
list={completed}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{
<RecordingList
list={completed}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
}
{completed.length === 0 && (
<Text size="sm" c="dimmed">
No completed recordings yet.
@ -286,4 +294,4 @@ const DVRPage = () => {
);
};
export default DVRPage;
export default DVRPage;

View file

@ -7,12 +7,11 @@ import VODLogosTable from '../components/tables/VODLogosTable';
import { showNotification } from '../utils/notificationUtils.js';
const LogosPage = () => {
const logos = useLogosStore(s => s.logos);
const totalCount = useVODLogosStore(s => s.totalCount);
const 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 logoCount =
activeTab === 'channel' ? Object.keys(logos).length : totalCount;
const loadChannelLogos = useCallback(async () => {
try {
@ -38,11 +37,7 @@ const LogosPage = () => {
return (
<Box>
{/* Header with title and tabs */}
<Box
style={{ justifyContent: 'center' }}
display={'flex'}
p={'10px 0'}
>
<Box style={{ justifyContent: 'center' }} display={'flex'} p={'10px 0'}>
<Flex
style={{
alignItems: 'center',
@ -58,7 +53,7 @@ const LogosPage = () => {
fz={'20px'}
fw={500}
lh={1}
c='white'
c="white"
mb={0}
lts={'-0.3px'}
>

View file

@ -22,7 +22,10 @@ import {
Text,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
import {
showNotification,
updateNotification,
} from '../utils/notificationUtils.js';
import { usePluginStore } from '../store/plugins.jsx';
import {
deletePluginByKey,
@ -34,8 +37,9 @@ import {
} 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 PluginCard = React.lazy(
() => import('../components/cards/PluginCard.jsx')
);
const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const plugins = usePluginStore((state) => state.plugins);
@ -68,7 +72,7 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
return (
<>
{plugins.length > 0 &&
{plugins.length > 0 && (
<SimpleGrid
cols={2}
spacing="md"
@ -91,13 +95,13 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
</Suspense>
</ErrorBoundary>
</SimpleGrid>
}
)}
{plugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
and reload.
No plugins found. Drop a plugin into <code>/data/plugins</code> and
reload.
</Text>
</Box>
)}
@ -255,12 +259,15 @@ export default function PluginsPage() {
};
};
const handleConfirm = useCallback((confirmed) => {
const resolver = confirmConfig.resolve;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', resolve: null });
if (resolver) resolver(confirmed);
}, [confirmConfig.resolve]);
const handleConfirm = useCallback(
(confirmed) => {
const resolver = confirmConfig.resolve;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', resolve: null });
if (resolver) resolver(confirmed);
},
[confirmConfig.resolve]
);
return (
<AppShellMain p={16}>

View file

@ -12,12 +12,12 @@ const PageContent = () => {
<UsersTable />
</Box>
);
}
};
const UsersPage = () => {
return (
<ErrorBoundary>
<PageContent/>
<PageContent />
</ErrorBoundary>
);
};

View file

@ -7,10 +7,10 @@ import ChannelsPage from '../Channels';
vi.mock('../../store/auth');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../components/tables/ChannelsTable', () => ({
default: () => <div data-testid="channels-table">ChannelsTable</div>
default: () => <div data-testid="channels-table">ChannelsTable</div>,
}));
vi.mock('../../components/tables/StreamsTable', () => ({
default: () => <div data-testid="streams-table">StreamsTable</div>
default: () => <div data-testid="streams-table">StreamsTable</div>,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,

View file

@ -5,10 +5,10 @@ import useUserAgentsStore from '../../store/userAgents';
vi.mock('../../store/userAgents');
vi.mock('../../components/tables/M3UsTable', () => ({
default: () => <div data-testid="m3us-table">M3UsTable</div>
default: () => <div data-testid="m3us-table">M3UsTable</div>,
}));
vi.mock('../../components/tables/EPGsTable', () => ({
default: () => <div data-testid="epgs-table">EPGsTable</div>
default: () => <div data-testid="epgs-table">EPGsTable</div>,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
@ -30,4 +30,4 @@ describe('ContentSourcesPage', () => {
expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
});
});
});

View file

@ -5,10 +5,10 @@ import useAuthStore from '../../store/auth';
vi.mock('../../store/auth');
vi.mock('../../components/forms/LoginForm', () => ({
default: () => <div data-testid="login-form">LoginForm</div>
default: () => <div data-testid="login-form">LoginForm</div>,
}));
vi.mock('../../components/forms/SuperuserForm', () => ({
default: () => <div data-testid="superuser-form">SuperuserForm</div>
default: () => <div data-testid="superuser-form">SuperuserForm</div>,
}));
vi.mock('@mantine/core', () => ({
Text: ({ children }) => <div>{children}</div>,
@ -18,7 +18,7 @@ describe('Login', () => {
it('renders SuperuserForm when superuser does not exist', async () => {
useAuthStore.mockReturnValue(false);
render(<Login/>);
render(<Login />);
await waitFor(() => {
expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
@ -29,7 +29,7 @@ describe('Login', () => {
it('renders LoginForm when superuser exists', () => {
useAuthStore.mockReturnValue(true);
render(<Login/>);
render(<Login />);
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();

View file

@ -3,7 +3,10 @@ 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';
import {
showNotification,
updateNotification,
} from '../../utils/notificationUtils.js';
vi.mock('../../store/logos');
vi.mock('../../store/vodLogos');
@ -12,16 +15,21 @@ vi.mock('../../utils/notificationUtils.js', () => ({
updateNotification: vi.fn(),
}));
vi.mock('../../components/tables/LogosTable', () => ({
default: () => <div data-testid="logos-table">LogosTable</div>
default: () => <div data-testid="logos-table">LogosTable</div>,
}));
vi.mock('../../components/tables/VODLogosTable', () => ({
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>,
}));
vi.mock('@mantine/core', () => {
const tabsComponent = ({ children, value, onChange }) =>
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>{children}</div>;
const tabsComponent = ({ children, value, onChange }) => (
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>
{children}
</div>
);
tabsComponent.List = ({ children }) => <div>{children}</div>;
tabsComponent.Tab = ({ children, value }) => <button data-value={value}>{children}</button>;
tabsComponent.Tab = ({ children, value }) => (
<button data-value={value}>{children}</button>
);
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
@ -113,7 +121,9 @@ describe('LogosPage', () => {
});
it('shows error notification when fetching logos fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const error = new Error('Failed to fetch');
mockFetchAllLogos.mockRejectedValue(error);

View file

@ -1,7 +1,10 @@
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 {
showNotification,
updateNotification,
} from '../../utils/notificationUtils.js';
import {
deletePluginByKey,
importPlugin,
@ -47,7 +50,16 @@ vi.mock('@mantine/core', async () => {
{children}
</span>
),
Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
Button: ({
children,
onClick,
leftSection,
variant,
color,
loading,
disabled,
fullWidth,
}) => (
<button
onClick={onClick}
disabled={loading || disabled}
@ -73,13 +85,16 @@ vi.mock('@mantine/core', async () => {
),
Divider: ({ my }) => <hr data-my={my} />,
ActionIcon: ({ children, onClick, color, variant, title }) => (
<button onClick={onClick} data-color={color} data-variant={variant} title={title}>
<button
onClick={onClick}
data-color={color}
data-variant={variant}
title={title}
>
{children}
</button>
),
SimpleGrid: ({ children, cols }) => (
<div data-cols={cols}>{children}</div>
),
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
Modal: ({ opened, onClose, title, children, size, centered }) =>
opened ? (
<div data-testid="modal" data-size={size} data-centered={centered}>
@ -109,7 +124,9 @@ vi.mock('@mantine/dropzone', () => ({
data-accept={accept}
data-max-size={maxSize}
onClick={() => {
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
onDrop([file]);
}}
>
@ -188,7 +205,11 @@ describe('PluginsPage', () => {
});
it('shows loader when loading and no plugins', () => {
const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() };
const loadingState = {
plugins: [],
loading: true,
fetchPlugins: vi.fn(),
};
usePluginStore.mockImplementation((selector) => {
return selector ? selector(loadingState) : loadingState;
});
@ -219,7 +240,9 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Import Plugin'
);
});
it('shows dropzone and file input in import modal', () => {
@ -228,7 +251,9 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
expect(screen.getByTestId('dropzone')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Select plugin .zip')
).toBeInTheDocument();
});
it('closes import modal when close button is clicked', () => {
@ -245,7 +270,11 @@ describe('PluginsPage', () => {
it('handles file upload via dropzone', async () => {
importPlugin.mockResolvedValue({
success: true,
plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' },
plugin: {
key: 'new-plugin',
name: 'New Plugin',
description: 'New Description',
},
});
render(<PluginsPage />);
@ -255,9 +284,9 @@ describe('PluginsPage', () => {
fireEvent.click(dropzone);
await waitFor(() => {
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
expect(uploadButton).not.toBeDisabled();
});
});
@ -279,12 +308,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -305,12 +336,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -340,12 +373,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -372,12 +407,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -387,9 +424,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@ -417,12 +454,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -432,13 +471,15 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument();
expect(
screen.getByText('Enable third-party plugins?')
).toBeInTheDocument();
});
});
@ -460,12 +501,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -475,9 +518,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@ -508,12 +551,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/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'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -523,9 +568,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {

View file

@ -5,7 +5,7 @@ import useAuthStore from '../../store/auth';
vi.mock('../../store/auth');
vi.mock('../../components/tables/UsersTable', () => ({
default: () => <div data-testid="users-table">UsersTable</div>
default: () => <div data-testid="users-table">UsersTable</div>,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,

View file

@ -16,7 +16,7 @@ vi.mock('../../components/SeriesModal', () => ({
<div data-testid="series-name">{series?.name}</div>
<button onClick={onClose}>Close</button>
</div>
) : null
) : null,
}));
vi.mock('../../components/VODModal', () => ({
default: ({ opened, vod, onClose }) =>
@ -25,26 +25,30 @@ vi.mock('../../components/VODModal', () => ({
<div data-testid="vod-name">{vod?.name}</div>
<button onClick={onClose}>Close</button>
</div>
) : null
) : null,
}));
vi.mock('../../components/cards/VODCard', () => ({
default: ({ vod, onClick }) => (
<div data-testid="vod-card" onClick={() => onClick(vod)}>
<div>{vod.name}</div>
</div>
)
),
}));
vi.mock('../../components/cards/SeriesCard', () => ({
default: ({ series, onClick }) => (
<div data-testid="series-card" onClick={() => onClick(series)}>
<div>{series.name}</div>
</div>
)
),
}));
vi.mock('@mantine/core', () => {
const gridComponent = ({ children, ...props }) => <div {...props}>{children}</div>;
gridComponent.Col = ({ children, ...props }) => <div {...props}>{children}</div>;
const gridComponent = ({ children, ...props }) => (
<div {...props}>{children}</div>
);
gridComponent.Col = ({ children, ...props }) => (
<div {...props}>{children}</div>
);
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
@ -97,7 +101,9 @@ vi.mock('@mantine/core', () => {
<button onClick={() => onChange(page - 1)} disabled={page === 1}>
Prev
</button>
<span>{page} of {total}</span>
<span>
{page} of {total}
</span>
<button onClick={() => onChange(page + 1)} disabled={page === total}>
Next
</button>
@ -208,9 +214,7 @@ describe('VODsPage', () => {
it('renders series cards for series', async () => {
const stateWithSeries = {
...defaultStoreState,
currentPageContent: [
{ id: 1, name: 'Series 1', contentType: 'series' },
],
currentPageContent: [{ id: 1, name: 'Series 1', contentType: 'series' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithSeries));
@ -224,9 +228,7 @@ describe('VODsPage', () => {
it('opens VOD modal when VOD card is clicked', async () => {
const stateWithMovies = {
...defaultStoreState,
currentPageContent: [
{ id: 1, name: 'Test Movie', contentType: 'movie' },
],
currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithMovies));
@ -262,9 +264,7 @@ describe('VODsPage', () => {
it('closes VOD modal when close button is clicked', async () => {
const stateWithMovies = {
...defaultStoreState,
currentPageContent: [
{ id: 1, name: 'Test Movie', contentType: 'movie' },
],
currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithMovies));
@ -326,9 +326,7 @@ describe('VODsPage', () => {
});
it('updates filters and resets page when category changes', async () => {
getCategoryOptions.mockReturnValue([
{ value: 'action', label: 'Action' },
]);
getCategoryOptions.mockReturnValue([{ value: 'action', label: 'Action' }]);
render(<VODsPage />);
@ -370,9 +368,7 @@ describe('VODsPage', () => {
totalCount: 25,
pageSize: 12,
};
useVODStore.mockImplementation((selector) =>
selector(stateWithPagination)
);
useVODStore.mockImplementation((selector) => selector(stateWithPagination));
render(<VODsPage />);
@ -405,9 +401,7 @@ describe('VODsPage', () => {
pageSize: 12,
currentPage: 1,
};
useVODStore.mockImplementation((selector) =>
selector(stateWithPagination)
);
useVODStore.mockImplementation((selector) => selector(stateWithPagination));
render(<VODsPage />);

View file

@ -63,9 +63,7 @@ describe('guideUtils', () => {
});
it('should use tvg_id from EPG data for regular sources', () => {
const channels = [
{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
};
@ -79,9 +77,7 @@ describe('guideUtils', () => {
});
it('should use channel UUID for dummy EPG sources', () => {
const channels = [
{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
};
@ -113,9 +109,7 @@ describe('guideUtils', () => {
});
it('should fall back to UUID when tvg_id is null', () => {
const channels = [
{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: null, epg_source: 'source-1' },
};
@ -160,7 +154,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)).toHaveLength(1);
expect(result.get(1)[0]).toMatchObject({
@ -185,7 +182,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0]).toHaveProperty('startMs');
expect(result.get(1)[0]).toHaveProperty('endMs');
@ -209,7 +209,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0].isLive).toBe(true);
expect(result.get(1)[0].isPast).toBe(false);
@ -233,7 +236,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0].isLive).toBe(false);
expect(result.get(1)[0].isPast).toBe(true);
@ -252,7 +258,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)).toHaveLength(1);
expect(result.get(2)).toHaveLength(1);
@ -282,7 +291,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0].id).toBe(1);
expect(result.get(1)[1].id).toBe(2);
@ -300,9 +312,16 @@ describe('guideUtils', () => {
const channels = [{ id: 1 }, { id: 2 }];
const programsByChannelId = new Map();
const result = guideUtils.computeRowHeights(channels, programsByChannelId, null);
const result = guideUtils.computeRowHeights(
channels,
programsByChannelId,
null
);
expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
expect(result).toEqual([
guideUtils.PROGRAM_HEIGHT,
guideUtils.PROGRAM_HEIGHT,
]);
});
it('should return expanded height for channel with expanded program', () => {
@ -312,9 +331,16 @@ describe('guideUtils', () => {
[2, [{ id: 'program-2' }]],
]);
const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1');
const result = guideUtils.computeRowHeights(
channels,
programsByChannelId,
'program-1'
);
expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
expect(result).toEqual([
guideUtils.EXPANDED_PROGRAM_HEIGHT,
guideUtils.PROGRAM_HEIGHT,
]);
});
it('should use custom heights when provided', () => {
@ -393,7 +419,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'Channel 2' },
];
const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
const result = guideUtils.filterGuideChannels(
channels,
'',
'all',
'all',
{}
);
expect(result).toHaveLength(2);
});
@ -404,7 +436,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'CNN' },
];
const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {});
const result = guideUtils.filterGuideChannels(
channels,
'espn',
'all',
'all',
{}
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('ESPN');
@ -416,7 +454,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'Channel 2', channel_group_id: 2 },
];
const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {});
const result = guideUtils.filterGuideChannels(
channels,
'',
'1',
'all',
{}
);
expect(result).toHaveLength(1);
expect(result[0].channel_group_id).toBe(1);
@ -436,7 +480,13 @@ describe('guideUtils', () => {
},
};
const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
const result = guideUtils.filterGuideChannels(
channels,
'',
'all',
'profile1',
profiles
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@ -453,7 +503,13 @@ describe('guideUtils', () => {
},
};
const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
const result = guideUtils.filterGuideChannels(
channels,
'',
'all',
'profile1',
profiles
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@ -474,7 +530,13 @@ describe('guideUtils', () => {
},
};
const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles);
const result = guideUtils.filterGuideChannels(
channels,
'espn',
'1',
'profile1',
profiles
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@ -491,8 +553,12 @@ describe('guideUtils', () => {
});
it('should return earliest program start', () => {
dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
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' },
@ -501,7 +567,10 @@ describe('guideUtils', () => {
];
const defaultStart = dayjs.utc('2024-01-16T00:00:00Z');
const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart);
const result = guideUtils.calculateEarliestProgramStart(
programs,
defaultStart
);
expect(result.hour()).toBe(10);
});
@ -517,8 +586,12 @@ describe('guideUtils', () => {
});
it('should return latest program end', () => {
dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b)));
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' },
@ -638,8 +711,12 @@ describe('guideUtils', () => {
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.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');
@ -651,8 +728,12 @@ describe('guideUtils', () => {
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.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');
@ -664,8 +745,12 @@ describe('guideUtils', () => {
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.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');
@ -679,8 +764,12 @@ describe('guideUtils', () => {
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.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');
@ -695,13 +784,23 @@ describe('guideUtils', () => {
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.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);
const result = guideUtils.calculateHourTimeline(
start,
end,
formatDayLabel
);
expect(result).toHaveLength(3);
expect(formatDayLabel).toHaveBeenCalledTimes(3);
@ -710,13 +809,25 @@ describe('guideUtils', () => {
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));
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);
const result = guideUtils.calculateHourTimeline(
start,
end,
formatDayLabel
);
expect(result[0].isNewDay).toBe(true);
});
@ -791,7 +902,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map();
const channelById = new Map();
const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
const result = guideUtils.matchChannelByTvgId(
channelIdByTvgId,
channelById,
'tvg-1'
);
expect(result).toBeNull();
});
@ -801,7 +916,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
const channelById = new Map([[1, channel]]);
const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
const result = guideUtils.matchChannelByTvgId(
channelIdByTvgId,
channelById,
'tvg-1'
);
expect(result).toBe(channel);
});
@ -810,7 +929,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map([['tvg-1', [999]]]);
const channelById = new Map();
const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
const result = guideUtils.matchChannelByTvgId(
channelIdByTvgId,
channelById,
'tvg-1'
);
expect(result).toBeNull();
});
@ -933,7 +1056,9 @@ describe('guideUtils', () => {
start_time: '2024-01-15T10:30:00Z',
};
const start = '2024-01-15T10:00:00Z';
dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf());
dateTimeUtils.convertToMs.mockImplementation((time) =>
dayjs(time).valueOf()
);
const result = guideUtils.calculateLeftScrollPosition(program, start);
@ -965,10 +1090,16 @@ describe('guideUtils', () => {
};
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.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.diff.mockReturnValue(60);
const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
const result = guideUtils.calculateScrollPositionByTimeClick(
event,
clickedTime,
start
);
expect(result).toBeGreaterThanOrEqual(0);
});
@ -982,7 +1113,9 @@ describe('guideUtils', () => {
};
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.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.diff.mockReturnValue(75);
guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
@ -999,12 +1132,22 @@ describe('guideUtils', () => {
};
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.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.diff.mockReturnValue(120);
const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
const result = guideUtils.calculateScrollPositionByTimeClick(
event,
clickedTime,
start
);
expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour');
expect(dateTimeUtils.add).toHaveBeenCalledWith(
expect.anything(),
1,
'hour'
);
});
});
@ -1037,9 +1180,7 @@ describe('guideUtils', () => {
1: { id: 1, name: 'Sports' },
2: { id: 2, name: 'News' },
};
const channels = [
{ id: 1, channel_group_id: 1 },
];
const channels = [{ id: 1, channel_group_id: 1 }];
const result = guideUtils.getGroupOptions(channelGroups, channels);

View file

@ -7,22 +7,22 @@
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 8px;
background: linear-gradient(to right, #2D2D2F, #1F1F20);
background: linear-gradient(to right, #2d2d2f, #1f1f20);
/* Default background */
color: #fff;
transition: all 0.2s ease-out;
}
.tv-guide .guide-program-container .guide-program.live {
background: linear-gradient(to right, #3BA882, #245043);
background: linear-gradient(to right, #3ba882, #245043);
}
.tv-guide .guide-program-container .guide-program.live:hover {
background: linear-gradient(to right, #2E9E80, #206E5E);
background: linear-gradient(to right, #2e9e80, #206e5e);
}
.tv-guide .guide-program-container .guide-program.not-live:hover {
background: linear-gradient(to right, #2F3F3A, #1E2926);
background: linear-gradient(to right, #2f3f3a, #1e2926);
}
/* New styles for expanded programs */
@ -33,15 +33,15 @@
}
.tv-guide .guide-program-container .guide-program.expanded.live {
background: linear-gradient(to right, #226F5D, #3BA882);
background: linear-gradient(to right, #226f5d, #3ba882);
}
.tv-guide .guide-program-container .guide-program.expanded.not-live {
background: linear-gradient(to right, #2C3F3A, #206E5E);
background: linear-gradient(to right, #2c3f3a, #206e5e);
}
.tv-guide .guide-program-container .guide-program.expanded.past {
background: linear-gradient(to right, #1F2423, #2F3A37);
background: linear-gradient(to right, #1f2423, #2f3a37);
}
/* Ensure channel logo is always on top */

View file

@ -10,7 +10,7 @@ import {
format,
getNow,
getNowMs,
roundToNearest
roundToNearest,
} from '../utils/dateTimeUtils.js';
import API from '../api.js';
@ -28,7 +28,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const tvgRecord = channel.epg_data_id
? tvgsById[channel.epg_data_id]
: null;
// For dummy EPG sources, ALWAYS use channel UUID to ensure unique programs per channel
// This prevents multiple channels with the same dummy EPG from showing identical data
let tvgId;
@ -45,7 +45,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
// No EPG data: use channel UUID
tvgId = channel.uuid;
}
if (tvgId) {
const tvgKey = String(tvgId);
if (!map.has(tvgKey)) {
@ -138,19 +138,25 @@ export const fetchPrograms = async () => {
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)
(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) => {
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;
if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase()))
return false;
}
// Channel group filter
@ -162,17 +168,17 @@ export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId,
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)
: [];
? 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;
@ -180,7 +186,7 @@ export const calculateEarliestProgramStart = (programs, defaultStart) => {
const s = initializeTime(p.start_time);
return isBefore(s, acc) ? s : acc;
}, defaultStart);
}
};
export const calculateLatestProgramEnd = (programs, defaultEnd) => {
if (!programs.length) return defaultEnd;
@ -188,17 +194,17 @@ export const calculateLatestProgramEnd = (programs, defaultEnd) => {
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();
@ -206,7 +212,7 @@ export const mapChannelsById = (guideChannels) => {
map.set(channel.id, channel);
});
return map;
}
};
export const mapRecordingsByProgramId = (recordings) => {
const map = new Map();
@ -217,7 +223,7 @@ export const mapRecordingsByProgramId = (recordings) => {
}
});
return map;
}
};
export const formatTime = (time, dateFormat) => {
const today = startOfDay(getNow());
@ -236,7 +242,7 @@ export const formatTime = (time, dateFormat) => {
// Beyond a week, show month and day
return format(time, dateFormat);
}
}
};
export const calculateHourTimeline = (start, end, formatDayLabel) => {
const hours = [];
@ -262,7 +268,7 @@ export const calculateHourTimeline = (start, end, formatDayLabel) => {
current = add(current, 1, 'hour');
}
return hours;
}
};
export const calculateNowPosition = (now, start, end) => {
if (isBefore(now, start) || isAfter(now, end)) return -1;
@ -286,11 +292,11 @@ export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
}
// 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(
@ -298,7 +304,7 @@ export const getRuleByProgram = (rules, program) => {
String(r.tvg_id) === String(program.tvg_id) &&
(!r.title || r.title === program.title)
);
}
};
export const createRecording = async (channel, program) => {
await API.createRecording({
@ -307,7 +313,7 @@ export const createRecording = async (channel, program) => {
end_time: program.end_time,
custom_properties: { program },
});
}
};
export const createSeriesRule = async (program, mode) => {
await API.createSeriesRule({
@ -315,15 +321,14 @@ export const createSeriesRule = async (program, mode) => {
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 programStartMs = program.startMs ?? convertToMs(program.start_time);
const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@ -331,9 +336,13 @@ export const calculateLeftScrollPosition = (program, start) => {
export const calculateDesiredScrollPosition = (leftPx) => {
return Math.max(0, leftPx - 20);
}
};
export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
export const calculateScrollPositionByTimeClick = (
event,
clickedTime,
start
) => {
const rect = event.currentTarget.getBoundingClientRect();
const clickPositionX = event.clientX - rect.left;
const percentageAcross = clickPositionX / rect.width;
@ -341,9 +350,10 @@ export const calculateScrollPositionByTimeClick = (event, clickedTime, start) =>
const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
const adjustedTime = (snappedMinute === 60)
? add(clickedTime, 1, 'hour').minute(0)
: clickedTime.minute(snappedMinute);
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;
@ -372,7 +382,7 @@ export const getGroupOptions = (channelGroups, guideChannels) => {
});
}
return options;
}
};
export const getProfileOptions = (profiles) => {
const options = [{ value: 'all', label: 'All Profiles' }];
@ -390,12 +400,12 @@ export const getProfileOptions = (profiles) => {
}
return options;
}
};
export const deleteSeriesRuleByTvgId = async (tvg_id) => {
await API.deleteSeriesRule(tvg_id);
}
};
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
await API.evaluateSeriesRules(tvg_id);
}
};

View file

@ -54,36 +54,50 @@ describe('useAuthStore', () => {
localStorageMock.clear();
// Setup default store mocks
useSettingsStore.mockImplementation((selector) => selector({
fetchSettings: vi.fn().mockResolvedValue(),
}));
useSettingsStore.mockImplementation((selector) =>
selector({
fetchSettings: vi.fn().mockResolvedValue(),
})
);
useChannelsStore.mockImplementation((selector) => selector({
fetchChannels: vi.fn().mockResolvedValue(),
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
}));
useChannelsStore.mockImplementation((selector) =>
selector({
fetchChannels: vi.fn().mockResolvedValue(),
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
})
);
usePlaylistsStore.mockImplementation((selector) => selector({
fetchPlaylists: vi.fn().mockResolvedValue(),
}));
usePlaylistsStore.mockImplementation((selector) =>
selector({
fetchPlaylists: vi.fn().mockResolvedValue(),
})
);
useEPGsStore.mockImplementation((selector) => selector({
fetchEPGs: vi.fn().mockResolvedValue(),
fetchEPGData: vi.fn().mockResolvedValue(),
}));
useEPGsStore.mockImplementation((selector) =>
selector({
fetchEPGs: vi.fn().mockResolvedValue(),
fetchEPGData: vi.fn().mockResolvedValue(),
})
);
useStreamProfilesStore.mockImplementation((selector) => selector({
fetchProfiles: vi.fn().mockResolvedValue(),
}));
useStreamProfilesStore.mockImplementation((selector) =>
selector({
fetchProfiles: vi.fn().mockResolvedValue(),
})
);
useUserAgentsStore.mockImplementation((selector) => selector({
fetchUserAgents: vi.fn().mockResolvedValue(),
}));
useUserAgentsStore.mockImplementation((selector) =>
selector({
fetchUserAgents: vi.fn().mockResolvedValue(),
})
);
useUsersStore.mockImplementation((selector) => selector({
fetchUsers: vi.fn().mockResolvedValue(),
}));
useUsersStore.mockImplementation((selector) =>
selector({
fetchUsers: vi.fn().mockResolvedValue(),
})
);
});
afterEach(() => {
@ -140,13 +154,25 @@ describe('useAuthStore', () => {
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'password' });
await result.current.login({
username: 'testuser',
password: 'password',
});
});
expect(API.login).toHaveBeenCalledWith('testuser', 'password');
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number));
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'accessToken',
mockAccessToken
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'refreshToken',
mockRefreshToken
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'tokenExpiration',
expect.any(Number)
);
});
it('should handle login failure', async () => {
@ -182,7 +208,10 @@ describe('useAuthStore', () => {
expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
expect(newToken).toBe(mockNewAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'accessToken',
mockNewAccessToken
);
});
it('should return false if no refresh token exists', async () => {
@ -213,7 +242,9 @@ describe('useAuthStore', () => {
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'tokenExpiration'
);
});
});
@ -277,7 +308,9 @@ describe('useAuthStore', () => {
expect(result.current.user).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'tokenExpiration'
);
});
it('should continue logout even if API call fails', async () => {
@ -372,7 +405,6 @@ describe('useAuthStore', () => {
expect(fetchUsers).toHaveBeenCalled();
});
it('should not fetch users for non-admin user', async () => {
const mockUser = {
username: 'reseller',
@ -418,7 +450,9 @@ describe('useAuthStore', () => {
API.me.mockResolvedValue(mockUser);
const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed'));
const fetchChannels = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useChannelsStore.getState = vi.fn(() => ({
fetchChannels,
@ -437,7 +471,11 @@ describe('useAuthStore', () => {
describe('setUser', () => {
it('should update user state', () => {
const { result } = renderHook(() => useAuthStore());
const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN };
const newUser = {
username: 'test',
email: 'test@test.com',
user_level: USER_LEVELS.ADMIN,
};
act(() => {
result.current.setUser(newUser);
@ -494,7 +532,10 @@ describe('useAuthStore', () => {
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'password' });
await result.current.login({
username: 'testuser',
password: 'password',
});
});
expect(result.current.accessToken).toBeNull();
@ -577,7 +618,7 @@ describe('useAuthStore', () => {
// Reset state before the test
useAuthStore.setState({
isInitializing: false,
isInitialized: false
isInitialized: false,
});
API.me.mockRejectedValue(new Error('API error'));
@ -620,7 +661,7 @@ describe('useAuthStore', () => {
// Wait for the background call to complete
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
});
// The background fetchChannels is called synchronously without await

View file

@ -243,7 +243,11 @@ describe('useChannelsStore', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] });
result.current.updateProfile({
id: '1',
name: 'Updated',
channels: [3],
});
});
expect(result.current.profiles['1'].name).toBe('Updated');
@ -254,7 +258,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1' }, '2': { id: '2' } },
profiles: { 1: { id: '1' }, 2: { id: '2' } },
selectedProfileId: '1',
});
});
@ -274,7 +278,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1', channels: new Set([1]) } },
profiles: { 1: { id: '1', channels: new Set([1]) } },
});
});
@ -291,7 +295,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } },
profiles: { 1: { id: '1', channels: new Set([1, 2, 3]) } },
});
});
@ -316,9 +320,7 @@ describe('useChannelsStore', () => {
});
const newStats = {
channels: [
{ channel_id: 'uuid-1', clients: [] },
],
channels: [{ channel_id: 'uuid-1', clients: [] }],
};
act(() => {

View file

@ -40,7 +40,9 @@ describe('useChannelsTableStore', () => {
expect(result.current.channels).toEqual([]);
expect(result.current.pageCount).toBe(0);
expect(result.current.totalCount).toBe(0);
expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]);
expect(result.current.sorting).toEqual([
{ id: 'channel_number', desc: false },
]);
expect(result.current.pagination.pageIndex).toBe(0);
expect(result.current.pagination.pageSize).toBe(50);
expect(result.current.selectedChannelIds).toEqual([]);
@ -187,9 +189,7 @@ describe('useChannelsTableStore', () => {
it('should return empty array for channel without streams', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1' },
];
const mockChannels = [{ id: 1, name: 'Channel 1' }];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
@ -203,9 +203,7 @@ describe('useChannelsTableStore', () => {
it('should return empty array for non-existent channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', streams: ['stream1'] },
];
const mockChannels = [{ id: 1, name: 'Channel 1', streams: ['stream1'] }];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
@ -343,7 +341,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 };
const updatedChannel = {
id: 2,
name: 'Updated Channel 2',
channel_number: 22,
};
act(() => {
result.current.updateChannel(updatedChannel);
@ -368,7 +370,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 };
const updatedChannel = {
id: 999,
name: 'Non-existent',
channel_number: 999,
};
act(() => {
result.current.updateChannel(updatedChannel);
@ -389,7 +395,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 };
const updatedChannel = {
id: 1,
name: 'Updated Channel 1',
channel_number: 10,
};
act(() => {
result.current.updateChannel(updatedChannel);

View file

@ -65,7 +65,9 @@ describe('useEPGsStore', () => {
});
it('should set loading state while fetching', async () => {
api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
api.getEPGs.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
);
const { result } = renderHook(() => useEPGsStore());
@ -87,7 +89,9 @@ describe('useEPGsStore', () => {
const mockError = new Error('Network error');
api.getEPGs.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
@ -96,7 +100,10 @@ describe('useEPGsStore', () => {
expect(result.current.error).toBe('Failed to load epgs.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch epgs:',
mockError
);
consoleSpy.mockRestore();
});
@ -142,7 +149,9 @@ describe('useEPGsStore', () => {
});
it('should set loading state while fetching', async () => {
api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
api.getEPGData.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
);
const { result } = renderHook(() => useEPGsStore());
@ -163,7 +172,9 @@ describe('useEPGsStore', () => {
const mockError = new Error('API error');
api.getEPGData.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
@ -173,7 +184,10 @@ describe('useEPGsStore', () => {
expect(result.current.error).toBe('Failed to load tvgs.');
expect(result.current.tvgsLoaded).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch tvgs:',
mockError
);
consoleSpy.mockRestore();
});
@ -274,7 +288,9 @@ describe('useEPGsStore', () => {
});
it('should not update state when called with invalid epg (null)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@ -287,13 +303,18 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null);
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPG called with invalid epg:',
null
);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (missing id)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@ -308,13 +329,18 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG);
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPG called with invalid epg:',
invalidEPG
);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (non-object)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@ -327,7 +353,10 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid');
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPG called with invalid epg:',
'invalid'
);
consoleSpy.mockRestore();
});
@ -518,7 +547,9 @@ describe('useEPGsStore', () => {
});
});
expect(result.current.epgs.source1.last_message).toBe('Connection failed');
expect(result.current.epgs.source1.last_message).toBe(
'Connection failed'
);
});
it('should use default error message if error is not provided', () => {
@ -535,7 +566,9 @@ describe('useEPGsStore', () => {
});
it('should not update state when called with invalid data (null)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
@ -547,13 +580,18 @@ describe('useEPGsStore', () => {
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null);
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPGProgress called with invalid data:',
null
);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid data (missing source)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
@ -565,7 +603,10 @@ describe('useEPGsStore', () => {
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 });
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPGProgress called with invalid data:',
{ progress: 50 }
);
consoleSpy.mockRestore();
});
@ -667,7 +708,11 @@ describe('useEPGsStore', () => {
act(() => {
useEPGsStore.setState({
epgs: {
source1: { id: 'source1', status: 'parsing', last_message: 'Processing' },
source1: {
id: 'source1',
status: 'parsing',
last_message: 'Processing',
},
},
});
});

View file

@ -58,7 +58,11 @@ describe('useLogosStore', () => {
it('should add logo to main logos store', () => {
const { result } = renderHook(() => useLogosStore());
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
const newLogo = {
id: 'logo1',
name: 'Logo 1',
url: 'http://example.com/logo1.png',
};
act(() => {
result.current.addLogo(newLogo);
@ -76,7 +80,11 @@ describe('useLogosStore', () => {
useLogosStore.setState({ hasLoadedChannelLogos: true });
});
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
const newLogo = {
id: 'logo1',
name: 'Logo 1',
url: 'http://example.com/logo1.png',
};
act(() => {
result.current.addLogo(newLogo);
@ -89,7 +97,11 @@ describe('useLogosStore', () => {
it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => {
const { result } = renderHook(() => useLogosStore());
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
const newLogo = {
id: 'logo1',
name: 'Logo 1',
url: 'http://example.com/logo1.png',
};
act(() => {
result.current.addLogo(newLogo);
@ -104,8 +116,16 @@ describe('useLogosStore', () => {
it('should update logo in main logos store', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
const originalLogo = {
id: 'logo1',
name: 'Original',
url: 'http://example.com/original.png',
};
const updatedLogo = {
id: 'logo1',
name: 'Updated',
url: 'http://example.com/updated.png',
};
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
@ -121,8 +141,16 @@ describe('useLogosStore', () => {
it('should update logo in channelLogos if it exists there', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
const originalLogo = {
id: 'logo1',
name: 'Original',
url: 'http://example.com/original.png',
};
const updatedLogo = {
id: 'logo1',
name: 'Updated',
url: 'http://example.com/updated.png',
};
act(() => {
useLogosStore.setState({
@ -142,8 +170,16 @@ describe('useLogosStore', () => {
it('should not update channelLogos if logo does not exist there', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
const originalLogo = {
id: 'logo1',
name: 'Original',
url: 'http://example.com/original.png',
};
const updatedLogo = {
id: 'logo1',
name: 'Updated',
url: 'http://example.com/updated.png',
};
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
@ -249,7 +285,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -261,7 +299,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch logos:',
mockError
);
});
consoleSpy.mockRestore();
@ -354,7 +395,9 @@ describe('useLogosStore', () => {
const mockError = new Error('API error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -366,7 +409,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load all logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch all logos:',
mockError
);
});
consoleSpy.mockRestore();
@ -396,7 +442,10 @@ describe('useLogosStore', () => {
logo2: { id: 'logo2', name: 'Used Logo 2' },
});
expect(result.current.isLoading).toBe(false);
expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 });
expect(api.getLogos).toHaveBeenCalledWith({
used: 'true',
page_size: 100,
});
expect(response).toEqual(mockResponse);
});
@ -426,7 +475,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -437,7 +488,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load used logos.');
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch used logos:',
mockError
);
});
consoleSpy.mockRestore();
@ -537,7 +591,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogosByIds.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -546,7 +602,10 @@ describe('useLogosStore', () => {
})
).rejects.toThrow('Fetch error');
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch logos by IDs:',
mockError
);
consoleSpy.mockRestore();
});
@ -565,9 +624,7 @@ describe('useLogosStore', () => {
next: null,
};
api.getLogos
.mockResolvedValueOnce(page1)
.mockResolvedValueOnce(page2);
api.getLogos.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
const { result } = renderHook(() => useLogosStore());
@ -589,7 +646,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
@ -597,7 +656,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Background logo loading failed:',
mockError
);
consoleSpy.mockRestore();
});
@ -665,7 +727,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.backgroundLoadAllLogos();
@ -675,7 +739,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Background all logos loading failed:',
mockError
);
consoleSpy.mockRestore();
@ -713,7 +780,10 @@ describe('useLogosStore', () => {
});
it('should not start if channelLogos already has many items', async () => {
const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]);
const channelLogos = Array.from({ length: 150 }, (_, i) => [
`logo${i}`,
{ id: `logo${i}` },
]);
const channelLogosObj = Object.fromEntries(channelLogos);
const { result } = renderHook(() => useLogosStore());
@ -747,8 +817,12 @@ describe('useLogosStore', () => {
expect(result.current.hasLoadedChannelLogos).toBe(true);
expect(result.current.backgroundLoading).toBe(false);
expect(Object.keys(result.current.channelLogos).length).toBe(2);
expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...');
expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos');
expect(consoleSpy).toHaveBeenCalledWith(
'Background loading channel logos...'
);
expect(consoleSpy).toHaveBeenCalledWith(
'Background loaded 2 channel logos'
);
consoleSpy.mockRestore();
});
@ -757,8 +831,12 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
@ -766,7 +844,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Background channel logo loading failed:',
mockError
);
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
@ -801,7 +882,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Background error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.startBackgroundLoading();

View file

@ -65,13 +65,17 @@ describe('usePlaylistsStore', () => {
expect(api.getPlaylist).toHaveBeenCalledWith('playlist1');
expect(result.current.playlists).toEqual([mockPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
expect(result.current.profiles).toEqual({
playlist1: ['profile1', 'profile2'],
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch playlist error', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
api.getPlaylist.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
@ -110,7 +114,9 @@ describe('usePlaylistsStore', () => {
});
it('should handle fetch playlists error', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
api.getPlaylists.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
@ -142,8 +148,16 @@ describe('usePlaylistsStore', () => {
it('should update playlist', () => {
const { result } = renderHook(() => usePlaylistsStore());
const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] };
const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] };
const existingPlaylist = {
id: 'playlist1',
name: 'Old Name',
profiles: ['profile1'],
};
const updatedPlaylist = {
id: 'playlist1',
name: 'New Name',
profiles: ['profile1', 'profile2'],
};
act(() => {
result.current.playlists = [existingPlaylist];
@ -155,7 +169,9 @@ describe('usePlaylistsStore', () => {
});
expect(result.current.playlists).toEqual([updatedPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
expect(result.current.profiles).toEqual({
playlist1: ['profile1', 'profile2'],
});
});
it('should update profiles', () => {
@ -166,10 +182,16 @@ describe('usePlaylistsStore', () => {
});
act(() => {
result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']);
result.current.updateProfiles('playlist1', [
'profile1',
'profile2',
'profile3',
]);
});
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] });
expect(result.current.profiles).toEqual({
playlist1: ['profile1', 'profile2', 'profile3'],
});
});
it('should remove playlists', () => {
@ -187,7 +209,9 @@ describe('usePlaylistsStore', () => {
result.current.removePlaylists(['playlist1', 'playlist3']);
});
expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]);
expect(result.current.playlists).toEqual([
{ id: 'playlist2', name: 'Playlist 2' },
]);
});
it('should set refresh progress with two parameters', () => {
@ -228,7 +252,11 @@ describe('usePlaylistsStore', () => {
expect(result.current.refreshProgress.account1.action).toBe('initializing');
act(() => {
result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 });
result.current.setRefreshProgress({
account: 'account1',
action: 'refreshing',
progress: 25,
});
});
expect(result.current.refreshProgress.account1.action).toBe('refreshing');

View file

@ -136,7 +136,11 @@ describe('usePluginStore', () => {
result.current.updatePlugin('plugin1', { name: 'Updated Plugin' });
});
expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false });
expect(result.current.plugins[1]).toEqual({
key: 'plugin2',
name: 'Plugin 2',
enabled: false,
});
});
it('should add plugin', () => {
@ -152,7 +156,11 @@ describe('usePluginStore', () => {
it('should add plugin to existing plugins', () => {
const { result } = renderHook(() => usePluginStore());
const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true };
const existingPlugin = {
key: 'plugin1',
name: 'Existing Plugin',
enabled: true,
};
const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false };
act(() => {
@ -192,9 +200,7 @@ describe('usePluginStore', () => {
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
],
plugins: [{ key: 'plugin1', name: 'Plugin 1', enabled: true }],
});
});
@ -208,9 +214,7 @@ describe('usePluginStore', () => {
});
it('should invalidate plugins and refetch', async () => {
const mockPlugins = [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
];
const mockPlugins = [{ key: 'plugin1', name: 'Plugin 1', enabled: true }];
API.getPlugins.mockResolvedValue(mockPlugins);
@ -218,9 +222,7 @@ describe('usePluginStore', () => {
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'old-plugin', name: 'Old Plugin', enabled: false },
],
plugins: [{ key: 'old-plugin', name: 'Old Plugin', enabled: false }],
});
});

View file

@ -190,7 +190,10 @@ describe('useSettingsStore', () => {
result.current.updateSetting({ key: 'setting1', value: 'updated' });
});
expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' });
expect(result.current.settings.setting2).toEqual({
key: 'setting2',
value: 'value2',
});
});
it('should handle empty settings array', async () => {
@ -249,7 +252,10 @@ describe('useSettingsStore', () => {
},
});
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
api.getVersion.mockResolvedValue({
version: '2.0.0',
timestamp: '2024-01-01T00:00:00Z',
});
const { result } = renderHook(() => useSettingsStore());
@ -336,7 +342,10 @@ describe('useSettingsStore', () => {
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
api.getVersion.mockResolvedValue({
version: '2.0.0',
timestamp: '2024-01-01T00:00:00Z',
});
const { result } = renderHook(() => useSettingsStore());

View file

@ -47,7 +47,9 @@ describe('useStreamProfilesStore', () => {
const mockError = new Error('Network error');
api.getStreamProfiles.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useStreamProfilesStore());
@ -57,7 +59,10 @@ describe('useStreamProfilesStore', () => {
expect(result.current.error).toBe('Failed to load profiles.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch profiles:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -152,7 +157,11 @@ describe('useStreamProfilesStore', () => {
result.current.updateStreamProfile(updatedProfile);
});
expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 });
expect(result.current.profiles[1]).toEqual({
id: 2,
name: 'Profile 2',
bitrate: 8000,
});
});
it('should not modify profiles when updating non-existent profile', () => {
@ -166,7 +175,11 @@ describe('useStreamProfilesStore', () => {
});
const { result } = renderHook(() => useStreamProfilesStore());
const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 };
const nonExistentProfile = {
id: 999,
name: 'Non-existent',
bitrate: 10000,
};
act(() => {
result.current.updateStreamProfile(nonExistentProfile);
@ -246,9 +259,7 @@ describe('useStreamProfilesStore', () => {
});
it('should handle empty array when removing profiles', () => {
const initialProfiles = [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
];
const initialProfiles = [{ id: 1, name: 'Profile 1', bitrate: 5000 }];
useStreamProfilesStore.setState({
profiles: initialProfiles,

View file

@ -53,7 +53,9 @@ describe('useStreamsStore', () => {
const mockError = new Error('Network error');
api.getStreams.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useStreamsStore());
@ -63,7 +65,10 @@ describe('useStreamsStore', () => {
expect(result.current.error).toBe('Failed to load streams.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch streams:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -131,7 +136,11 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
const updatedStream = {
id: 1,
name: 'Updated Stream',
url: 'http://example.com/updated',
};
act(() => {
result.current.updateStream(updatedStream);
@ -152,13 +161,21 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
const updatedStream = {
id: 1,
name: 'Updated Stream',
url: 'http://example.com/updated',
};
act(() => {
result.current.updateStream(updatedStream);
});
expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' });
expect(result.current.streams[1]).toEqual({
id: 2,
name: 'Stream 2',
url: 'http://example.com/2',
});
});
it('should not modify streams when updating non-existent stream', () => {
@ -172,7 +189,11 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' };
const nonExistentStream = {
id: 999,
name: 'Non-existent',
url: 'http://example.com/999',
};
act(() => {
result.current.updateStream(nonExistentStream);

View file

@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
})
}),
};
})();
@ -58,7 +58,7 @@ describe('useStreamsTableStore', () => {
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
}
},
});
const { result } = renderHook(() => useStreamsTableStore());
@ -74,7 +74,7 @@ describe('useStreamsTableStore', () => {
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
}
},
});
const { result } = renderHook(() => useStreamsTableStore());
@ -112,10 +112,7 @@ describe('useStreamsTableStore', () => {
const mockParams = new URLSearchParams({ page_size: '25' });
act(() => {
result.current.queryStreams(
{ results: [], count: 75 },
mockParams
);
result.current.queryStreams({ results: [], count: 75 }, mockParams);
});
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
@ -127,10 +124,7 @@ describe('useStreamsTableStore', () => {
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
result.current.queryStreams(
{ results: [], count: 0 },
mockParams
);
result.current.queryStreams({ results: [], count: 0 }, mockParams);
});
expect(result.current.streams).toEqual([]);
@ -340,7 +334,9 @@ describe('useStreamsTableStore', () => {
expect(result.current.totalCount).toBe(50);
expect(result.current.pageCount).toBe(2);
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]);
expect(result.current.sorting).toEqual([
{ id: 'created_at', desc: true },
]);
expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]);
expect(result.current.selectedStreamIds).toEqual([1, 2]);
expect(result.current.lastQueryParams).toBe(mockParams);

View file

@ -103,7 +103,12 @@ describe('useVODStore', () => {
expect(api.getAllContent).toHaveBeenCalled();
expect(result.current.currentPageContent).toEqual([
{ id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' },
{ id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' },
{
id: 2,
name: 'Series 1',
content_type: 'series',
contentType: 'series',
},
]);
expect(result.current.totalCount).toBe(2);
expect(result.current.loading).toBe(false);
@ -171,7 +176,9 @@ describe('useVODStore', () => {
const mockError = new Error('Network error');
api.getAllContent.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -181,7 +188,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load content.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch content:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -189,7 +199,9 @@ describe('useVODStore', () => {
it('should handle invalid response format', async () => {
api.getAllContent.mockResolvedValue({ results: 'not-an-array' });
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -234,7 +246,9 @@ describe('useVODStore', () => {
const mockError = new Error('Not found');
api.getMovieDetails.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -248,7 +262,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load movie details.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch movie details:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -283,7 +300,9 @@ describe('useVODStore', () => {
const mockError = new Error('Provider error');
api.getMovieProviderInfo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -295,8 +314,13 @@ describe('useVODStore', () => {
}
});
expect(result.current.error).toBe('Failed to load movie details from provider.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError);
expect(result.current.error).toBe(
'Failed to load movie details from provider.'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch movie details from provider:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -324,7 +348,9 @@ describe('useVODStore', () => {
const mockError = new Error('Providers error');
api.getMovieProviders.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -336,7 +362,10 @@ describe('useVODStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch movie providers:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -361,7 +390,9 @@ describe('useVODStore', () => {
const mockError = new Error('Series providers error');
api.getSeriesProviders.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -373,7 +404,10 @@ describe('useVODStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch series providers:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -428,7 +462,9 @@ describe('useVODStore', () => {
const mockError = new Error('Series not found');
api.getSeriesInfo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -442,7 +478,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load series details.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch series info:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -494,7 +533,9 @@ describe('useVODStore', () => {
const mockError = new Error('Categories error');
api.getVODCategories.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -503,7 +544,10 @@ describe('useVODStore', () => {
});
expect(result.current.error).toBe('Failed to load categories.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch VOD categories:',
mockError
);
consoleErrorSpy.mockRestore();
});

View file

@ -68,7 +68,9 @@ describe('useVideoStore', () => {
const { result } = renderHook(() => useVideoStore());
act(() => {
result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' });
result.current.showVideo('http://example.com/stream.ts', 'vod', {
title: 'Test',
});
});
expect(result.current.isVisible).toBe(true);
@ -121,7 +123,7 @@ describe('useVideoStore', () => {
const metadata = {
title: 'Test Video',
duration: 120,
thumbnailUrl: 'http://example.com/thumb.jpg'
thumbnailUrl: 'http://example.com/thumb.jpg',
};
act(() => {
@ -139,13 +141,21 @@ describe('useVideoStore', () => {
const secondMetadata = { title: 'Second Video' };
act(() => {
result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata);
result.current.showVideo(
'http://example.com/first.mp4',
'vod',
firstMetadata
);
});
expect(result.current.metadata).toEqual(firstMetadata);
act(() => {
result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata);
result.current.showVideo(
'http://example.com/second.mp4',
'vod',
secondMetadata
);
});
expect(result.current.metadata).toEqual(secondMetadata);

View file

@ -47,7 +47,9 @@ describe('useUserAgentsStore', () => {
const mockError = new Error('Network error');
api.getUserAgents.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useUserAgentsStore());
@ -57,7 +59,10 @@ describe('useUserAgentsStore', () => {
expect(result.current.error).toBe('Failed to load userAgents.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch userAgents:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -125,7 +130,11 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
const updatedUserAgent = {
id: 1,
name: 'Chrome Updated',
string: 'Mozilla/5.0 Updated...',
};
act(() => {
result.current.updateUserAgent(updatedUserAgent);
@ -146,13 +155,21 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
const updatedUserAgent = {
id: 1,
name: 'Chrome Updated',
string: 'Mozilla/5.0 Updated...',
};
act(() => {
result.current.updateUserAgent(updatedUserAgent);
});
expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' });
expect(result.current.userAgents[1]).toEqual({
id: 2,
name: 'Firefox',
string: 'Mozilla/5.0...',
});
});
it('should not modify user agents when updating non-existent user agent', () => {
@ -166,7 +183,11 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' };
const nonExistentUserAgent = {
id: 999,
name: 'Non-existent',
string: 'Mozilla/5.0...',
};
act(() => {
result.current.updateUserAgent(nonExistentUserAgent);

View file

@ -47,7 +47,9 @@ describe('useUsersStore', () => {
const mockError = new Error('Network error');
api.getUsers.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useUsersStore());
@ -57,7 +59,10 @@ describe('useUsersStore', () => {
expect(result.current.error).toBe('Failed to load users.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch users:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -125,7 +130,11 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
const updatedUser = {
id: 1,
name: 'Updated User',
email: 'updated@example.com',
};
act(() => {
result.current.updateUser(updatedUser);
@ -146,13 +155,21 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
const updatedUser = {
id: 1,
name: 'Updated User',
email: 'updated@example.com',
};
act(() => {
result.current.updateUser(updatedUser);
});
expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' });
expect(result.current.users[1]).toEqual({
id: 2,
name: 'User 2',
email: 'user2@example.com',
});
});
it('should not modify users when updating non-existent user', () => {
@ -166,7 +183,11 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' };
const nonExistentUser = {
id: 999,
name: 'Non-existent',
email: 'none@example.com',
};
act(() => {
result.current.updateUser(nonExistentUser);
@ -252,7 +273,15 @@ describe('useUsersStore', () => {
result.current.removeUser(2);
});
expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' });
expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' });
expect(result.current.users[0]).toEqual({
id: 1,
name: 'User 1',
email: 'user1@example.com',
});
expect(result.current.users[1]).toEqual({
id: 3,
name: 'User 3',
email: 'user3@example.com',
});
});
});

View file

@ -105,7 +105,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Network error');
api.getVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -119,7 +121,10 @@ describe('useVODLogosStore', () => {
expect(result.current.error).toBe('Failed to load VOD logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch VOD logos:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -205,9 +210,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
expect(result.current.logos).toEqual([
{ id: 2, name: 'Logo 2' },
]);
expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]);
expect(result.current.totalCount).toBe(1);
});
@ -236,9 +239,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
expect(result.current.logos).toEqual([
{ id: 2, name: 'Logo 2' },
]);
expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]);
expect(result.current.totalCount).toBe(1);
});
@ -246,7 +247,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Delete failed');
api.deleteVODLogo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -258,7 +261,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to delete VOD logo:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -290,9 +296,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
3: { id: 3, name: 'Logo 3' },
});
expect(result.current.logos).toEqual([
{ id: 3, name: 'Logo 3' },
]);
expect(result.current.logos).toEqual([{ id: 3, name: 'Logo 3' }]);
expect(result.current.totalCount).toBe(1);
});
@ -300,7 +304,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Bulk delete failed');
api.deleteVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -312,7 +318,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to delete VOD logos:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -348,7 +357,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Cleanup failed');
api.cleanupUnusedVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -360,7 +371,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to cleanup unused VOD logos:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -517,7 +531,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Failed to fetch count');
api.getVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -529,7 +545,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch unused logos count:',
mockError
);
consoleErrorSpy.mockRestore();
});

View file

@ -12,9 +12,15 @@ const reduceChannels = (channels) => {
return acc;
}, {});
return { channelsByUUID, channelsByID };
}
};
const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => {
const showNotificationIfNewChannel = (
currentStats,
oldChannels,
ch,
channelsByUUID,
channels
) => {
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
// Add null checks to prevent accessing properties on undefined
@ -30,7 +36,7 @@ const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByU
}
}
}
}
};
const showNotificationIfNewClient = (currentStats, oldClients, client) => {
// This check prevents the notifications if streams are active on page load
@ -43,9 +49,15 @@ const showNotificationIfNewClient = (currentStats, oldClients, client) => {
});
}
}
}
};
const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => {
const showNotificationIfChannelStopped = (
currentStats,
oldChannels,
newChannels,
channelsByUUID,
channels
) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
@ -70,9 +82,13 @@ const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels
}
}
}
}
};
const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => {
const showNotificationIfClientStopped = (
currentStats,
oldClients,
newClients
) => {
if (currentStats.channels) {
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
@ -84,7 +100,7 @@ const showNotificationIfClientStopped = (currentStats, oldClients, newClients) =
}
}
}
}
};
const useChannelsStore = create((set, get) => ({
channels: [],
@ -228,7 +244,7 @@ const useChannelsStore = create((set, get) => ({
);
return;
}
const { channelsByUUID, updatedChannels } = reduceChannels(channels);
set((state) => ({
@ -383,24 +399,36 @@ const useChannelsStore = create((set, get) => ({
channelsByUUID,
} = state;
const newClients = {};
const newChannels = stats.channels.reduce((acc, ch) => {
acc[ch.channel_id] = ch;
return acc;
}, {});
stats.channels.forEach(ch => {
showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels);
stats.channels.forEach((ch) => {
showNotificationIfNewChannel(
currentStats,
oldChannels,
ch,
channelsByUUID,
channels
);
ch.clients.forEach(client => {
newClients[client.client_id] = client;
showNotificationIfNewClient(currentStats, oldClients, client);
});
ch.clients.forEach((client) => {
newClients[client.client_id] = client;
showNotificationIfNewClient(currentStats, oldClients, client);
});
});
showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels);
showNotificationIfChannelStopped(
currentStats,
oldChannels,
newChannels,
channelsByUUID,
channels
);
showNotificationIfClientStopped(currentStats, oldClients, newClients);
return {
stats,
activeChannels: newChannels,

View file

@ -4,7 +4,8 @@ import api from '../api';
const determineEPGStatus = (data, currentEpg) => {
if (data.status) return data.status;
if (data.action === 'downloading') return 'fetching';
if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing';
if (data.action === 'parsing_channels' || data.action === 'parsing_programs')
return 'parsing';
if (data.progress === 100) return 'success';
return currentEpg?.status || 'idle';
};
@ -118,23 +119,26 @@ const useEPGsStore = create((set) => ({
// Only update epgs object if status or last_message actually changed
// This prevents unnecessary re-renders on every progress update
const lastMessage = data.status === 'error'
? (data.error || 'Unknown error')
: state.epgs[data.source]?.last_message;
const lastMessage =
data.status === 'error'
? data.error || 'Unknown error'
: state.epgs[data.source]?.last_message;
const currentEpg = state.epgs[data.source];
const shouldUpdateEpg = currentEpg &&
(currentEpg.status !== status || currentEpg.last_message !== lastMessage);
const shouldUpdateEpg =
currentEpg &&
(currentEpg.status !== status ||
currentEpg.last_message !== lastMessage);
const epgs = shouldUpdateEpg
? {
...state.epgs,
[data.source]: {
...currentEpg,
status,
last_message: lastMessage,
},
}
...state.epgs,
[data.source]: {
...currentEpg,
status,
last_message: lastMessage,
},
}
: state.epgs;
return { refreshProgress, epgs };

View file

@ -2,10 +2,8 @@ import { create } from 'zustand';
import api from '../api';
const getLogosArray = (response) => {
return Array.isArray(response)
? response
: response.results || [];
}
return Array.isArray(response) ? response : response.results || [];
};
const useLogosStore = create((set, get) => ({
logos: {},

View file

@ -38,4 +38,4 @@ export const usePluginStore = create((set, get) => ({
set({ plugins: [] });
get().fetchPlugins();
},
}));
}));

View file

@ -7,8 +7,7 @@ const useStreamsTableStore = create((set) => ({
sorting: [{ id: 'name', desc: false }],
pagination: {
pageIndex: 0,
pageSize:
JSON.parse(localStorage.getItem('streams-page-size')) || 50,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
},
selectedStreamIds: [],
allQueryIds: [],

View file

@ -14,7 +14,7 @@ const getFetchContentParams = (state) => {
params.append('category', state.filters.category);
}
return params;
}
};
const getMovieDetails = (response, movieId) => {
return {
@ -35,7 +35,7 @@ const getMovieDetails = (response, movieId) => {
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
}
};
const getMovieDetailsWithProvider = (response, movieId) => {
return {
@ -65,7 +65,7 @@ const getMovieDetailsWithProvider = (response, movieId) => {
video: response.video || {},
audio: response.audio || {},
};
}
};
const getSeriesDetails = (response, seriesId) => {
return {
@ -92,7 +92,7 @@ const getSeriesDetails = (response, seriesId) => {
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
}
};
const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
return {
@ -117,7 +117,7 @@ const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
}
};
const useVODStore = create((set, get) => ({
content: {}, // Store for individual content details (when fetching movie/series details)

View file

@ -256,7 +256,7 @@ describe('dateTimeUtils', () => {
const setTimeZone = vi.fn();
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
useSettingsStore.mockReturnValue({
'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
system_settings: { value: { time_zone: 'America/Los_Angeles' } },
});
renderHook(() => dateTimeUtils.useUserTimeZone());
@ -321,18 +321,25 @@ describe('dateTimeUtils', () => {
});
it('should start with Sunday', () => {
expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' });
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);
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()]);
useLocalStorage
.mockReturnValueOnce(['12h', vi.fn()])
.mockReturnValueOnce(['mdy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@ -341,7 +348,9 @@ describe('dateTimeUtils', () => {
});
it('should return 24h format when set', () => {
useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
useLocalStorage
.mockReturnValueOnce(['24h', vi.fn()])
.mockReturnValueOnce(['mdy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@ -349,7 +358,9 @@ describe('dateTimeUtils', () => {
});
it('should return dmy date format when set', () => {
useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]);
useLocalStorage
.mockReturnValueOnce(['12h', vi.fn()])
.mockReturnValueOnce(['dmy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@ -428,26 +439,28 @@ describe('dateTimeUtils', () => {
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);
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'));
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);
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');
const utcOptions = result.filter((opt) => opt.value === 'UTC');
expect(utcOptions).toHaveLength(1);
});
});

View file

@ -8,7 +8,9 @@ describe('networkUtils', () => {
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);
expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(
true
);
});
it('should not match invalid IPv4 CIDR notation', () => {
@ -29,7 +31,11 @@ describe('networkUtils', () => {
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);
expect(
networkUtils.IPV6_CIDR_REGEX.test(
'2001:0db8:85a3:0000:0000:8a2e:0370:7334/64'
)
).toBe(true);
});
it('should match compressed IPv6 CIDR notation', () => {
@ -38,7 +44,9 @@ describe('networkUtils', () => {
});
it('should match IPv6 with embedded IPv4', () => {
expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(
true
);
});
it('should not match invalid IPv6 CIDR notation', () => {

View file

@ -74,7 +74,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
notificationObject
);
expect(notifications.update).toHaveBeenCalledTimes(1);
});
@ -82,7 +85,9 @@ describe('notificationUtils', () => {
const mockReturnValue = { success: true };
notifications.update.mockReturnValue(mockReturnValue);
const result = notificationUtils.updateNotification('id', { message: 'test' });
const result = notificationUtils.updateNotification('id', {
message: 'test',
});
expect(result).toBe(mockReturnValue);
});
@ -98,7 +103,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
updateObject
);
});
it('should handle loading to error transition', () => {
@ -112,7 +120,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
updateObject
);
});
it('should handle partial updates', () => {
@ -123,7 +134,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
updateObject
);
});
it('should handle empty notification id', () => {
@ -139,7 +153,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(null, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(null, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(
null,
notificationObject
);
});
});
});

View file

@ -89,4 +89,4 @@ export const getSeriesInfo = (customProps) => {
const cp = customProps || {};
const pr = cp.program || {};
return { tvg_id: pr.tvg_id, title: pr.title };
};
};

View file

@ -24,7 +24,7 @@ export const formatTime = (seconds) => {
export const getMovieDisplayTitle = (vodContent) => {
return vodContent.content_name;
}
};
export const getEpisodeDisplayTitle = (metadata) => {
const season = metadata.season_number
@ -34,18 +34,18 @@ export const getEpisodeDisplayTitle = (metadata) => {
? `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) {
@ -92,7 +92,7 @@ export const calculateProgress = (connection, duration_secs) => {
currentTime: Math.max(0, currentTime), // Don't go negative
totalTime: totalSeconds,
};
}
};
export const calculateConnectionDuration = (connection) => {
// If duration is provided by API, use it
@ -115,9 +115,12 @@ export const calculateConnectionDuration = (connection) => {
}
return 'Unknown duration';
}
};
export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => {
export const calculateConnectionStartTime = (
connection,
fullDateTimeFormat
) => {
if (connection.connected_at) {
return format(connection.connected_at * 1000, fullDateTimeFormat);
}
@ -136,4 +139,4 @@ export const calculateConnectionStartTime = (connection, fullDateTimeFormat) =>
}
return 'Unknown';
}
};

View file

@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
getConfirmationDetails,
} from '../PluginCardUtils';
import { getConfirmationDetails } from '../PluginCardUtils';
describe('PluginCardUtils', () => {
describe('getConfirmationDetails', () => {
@ -13,7 +11,8 @@ describe('PluginCardUtils', () => {
expect(result).toEqual({
requireConfirm: true,
confirmTitle: 'Run Test Action?',
confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".',
confirmMessage:
'You\'re about to run "Test Action" from "Test Plugin".',
});
});

View file

@ -156,7 +156,9 @@ describe('RecordingCardUtils', () => {
const channel = { uuid: 'channel-123' };
const result = getShowVideoUrl(channel, 'dev');
expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/);
expect(result).toMatch(
/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/
);
});
});
@ -208,7 +210,9 @@ describe('RecordingCardUtils', () => {
it('handles bulk remove error gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation();
API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed'));
API.bulkRemoveSeriesRecordings.mockRejectedValue(
new Error('Bulk remove failed')
);
API.deleteSeriesRule.mockResolvedValue();
const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };

View file

@ -14,31 +14,43 @@ describe('StreamConnectionCardUtils', () => {
describe('getBufferingSpeedThreshold', () => {
it('should return parsed buffering_speed from proxy settings', () => {
const proxySetting = {
value: { buffering_speed: 2.5 }
value: { buffering_speed: 2.5 },
};
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(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);
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' })
value: JSON.stringify({ buffering_speed: 'not a number' }),
};
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
expect(
StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
).toBe(1.0);
});
it('should return 1.0 when proxySetting is null', () => {
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0);
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(
1.0
);
});
it('should return 1.0 when value is missing', () => {
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0);
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(
1.0
);
});
});
@ -60,17 +72,14 @@ describe('StreamConnectionCardUtils', () => {
it('should create map from m3u accounts array', () => {
const m3uAccounts = [
{ id: 1, name: 'Account 1' },
{ id: 2, name: 'Account 2' }
{ 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 m3uAccounts = [{ name: 'Account 1' }, { id: 2, name: 'Account 2' }];
const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
expect(result).toEqual({ 2: 'Account 2' });
});
@ -100,7 +109,7 @@ describe('StreamConnectionCardUtils', () => {
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' }
{ id: 2, url: 'http://example.com/stream2' },
];
const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
streamData,
@ -111,7 +120,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream when stream url includes channelUrl', () => {
const streamData = [
{ id: 1, url: 'http://example.com/stream1/playlist.m3u8' }
{ id: 1, url: 'http://example.com/stream1/playlist.m3u8' },
];
const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
streamData,
@ -134,7 +143,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream by id as string', () => {
const streams = [
{ id: 1, name: 'Stream 1' },
{ id: 2, name: 'Stream 2' }
{ id: 2, name: 'Stream 2' },
];
const result = StreamConnectionCardUtils.getSelectedStream(streams, '2');
expect(result).toEqual(streams[1]);
@ -167,11 +176,20 @@ describe('StreamConnectionCardUtils', () => {
dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY, HH:mm:ss');
const accessor = StreamConnectionCardUtils.connectedAccessor(
'MM/DD/YYYY, HH:mm:ss'
);
const result = accessor({ connected_since: 7200 });
expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second');
expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY, HH:mm:ss');
expect(dateTimeUtils.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');
});
@ -181,7 +199,8 @@ describe('StreamConnectionCardUtils', () => {
dateTimeUtils.initializeTime.mockReturnValue(mockTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const accessor =
StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const result = accessor({ connected_at: 1704103200 });
expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
@ -189,7 +208,8 @@ describe('StreamConnectionCardUtils', () => {
});
it('should return Unknown when no time data available', () => {
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const accessor =
StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const result = accessor({});
expect(result).toBe('Unknown');
});
@ -202,7 +222,10 @@ describe('StreamConnectionCardUtils', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connected_since: 9000 });
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
9000,
'seconds'
);
expect(result).toBe('2h 30m');
});
@ -212,7 +235,10 @@ describe('StreamConnectionCardUtils', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connection_duration: 4500 });
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
4500,
'seconds'
);
expect(result).toBe('1h 15m');
});
@ -226,15 +252,23 @@ describe('StreamConnectionCardUtils', () => {
describe('getLogoUrl', () => {
it('should return cache_url from logos map when logoId exists', () => {
const logos = {
'logo-123': { cache_url: '/api/logos/logo-123/cache/' }
'logo-123': { cache_url: '/api/logos/logo-123/cache/' },
};
const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null);
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);
const result = StreamConnectionCardUtils.getLogoUrl(
'logo-456',
{},
previewedStream
);
expect(result).toBe('http://example.com/logo.png');
});
@ -260,15 +294,18 @@ describe('StreamConnectionCardUtils', () => {
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 }
{ id: 2, name: 'Stream 2', m3u_account: 200 },
];
const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' };
const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
const result = StreamConnectionCardUtils.getStreamOptions(
streams,
accountsMap
);
expect(result).toEqual([
{ value: '1', label: 'Stream 1 [Premium Account]' },
{ value: '2', label: 'Stream 2 [Basic Account]' }
{ value: '2', label: 'Stream 2 [Basic Account]' },
]);
});
@ -284,7 +321,10 @@ describe('StreamConnectionCardUtils', () => {
const streams = [{ id: 5, m3u_account: 100 }];
const accountsMap = { 100: 'Account' };
const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
const result = StreamConnectionCardUtils.getStreamOptions(
streams,
accountsMap
);
expect(result[0].label).toBe('Stream #5 [Account]');
});

View file

@ -96,7 +96,7 @@ describe('VodConnectionCardUtils', () => {
const metadata = {
series_name: 'Breaking Bad',
season_number: 1,
episode_number: 5
episode_number: 5,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Breaking Bad - S01E05');
@ -106,7 +106,7 @@ describe('VodConnectionCardUtils', () => {
const metadata = {
series_name: 'The Office',
season_number: 3,
episode_number: 9
episode_number: 9,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('The Office - S03E09');
@ -115,7 +115,7 @@ describe('VodConnectionCardUtils', () => {
it('should use S?? when season_number is missing', () => {
const metadata = {
series_name: 'Lost',
episode_number: 5
episode_number: 5,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Lost - S??E05');
@ -124,7 +124,7 @@ describe('VodConnectionCardUtils', () => {
it('should use E?? when episode_number is missing', () => {
const metadata = {
series_name: 'Friends',
season_number: 2
season_number: 2,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Friends - S02E??');
@ -167,7 +167,7 @@ describe('VodConnectionCardUtils', () => {
it('should calculate progress from last_seek_percentage', () => {
const connection = {
last_seek_percentage: 50,
last_seek_timestamp: 990 // 10 seconds ago
last_seek_timestamp: 990, // 10 seconds ago
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -179,7 +179,7 @@ describe('VodConnectionCardUtils', () => {
it('should cap currentTime at duration when seeking', () => {
const connection = {
last_seek_percentage: 95,
last_seek_timestamp: 900 // 100 seconds ago
last_seek_timestamp: 900, // 100 seconds ago
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -189,7 +189,7 @@ describe('VodConnectionCardUtils', () => {
it('should fallback to position_seconds when seek data unavailable', () => {
const connection = {
position_seconds: 75
position_seconds: 75,
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -218,7 +218,7 @@ describe('VodConnectionCardUtils', () => {
it('should ensure currentTime is not negative', () => {
const connection = {
last_seek_percentage: 10,
last_seek_timestamp: 2000 // In the future somehow
last_seek_timestamp: 2000, // In the future somehow
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -231,9 +231,13 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m');
const connection = { duration: 5400 };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
5400,
'seconds'
);
expect(result).toBe('1h 30m');
});
@ -242,22 +246,28 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
const connection = { client_id: 'vod_900000_abc' };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds');
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);
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);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(result).toBe('Unknown duration');
});
@ -267,7 +277,8 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
const connection = { client_id: 'vod_invalid_abc' };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
// If parseInt fails, the code should still handle it
expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation
@ -279,9 +290,15 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
const connection = { connected_at: 1705329000 };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY, HH:mm:ss'
);
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY, HH:mm:ss');
expect(dateTimeUtils.format).toHaveBeenCalledWith(
1705329000000,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/15/2024 14:30:00');
});
@ -289,22 +306,34 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
const connection = { client_id: 'vod_1705323600000_abc' };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY, HH:mm:ss'
);
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY, HH:mm:ss');
expect(dateTimeUtils.format).toHaveBeenCalledWith(
1705323600000,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/15/2024 13:00:00');
});
it('should return Unknown when no timestamp data available', () => {
const connection = {};
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY, HH:mm:ss'
);
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');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY'
);
expect(result).toBe('Unknown');
});
@ -313,11 +342,13 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
const connection = { client_id: 'vod_notanumber_abc' };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
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
});
});
});

View file

@ -40,7 +40,8 @@ export const format = (dateTime, formatStr) =>
export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize();
export const toFriendlyDuration = (dateTime, unit) =>
dayjs.duration(dateTime, unit).humanize();
export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
@ -113,7 +114,8 @@ export const useDateTimeFormat = () => {
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
// Full format strings for detailed date-time displays
const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullDateFormat =
dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss';
const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`;
@ -278,4 +280,4 @@ export const getDefaultTimeZone = () => {
} catch (error) {
return 'UTC';
}
};
};

View file

@ -33,7 +33,7 @@ const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
const st = toUserTime(r.start_time);
return st.isAfter(userNow());
});
}
};
const dedupeByProgram = (filtered) => {
// Deduplicate by program.id if present, else by time+title
@ -62,7 +62,7 @@ const dedupeByProgram = (filtered) => {
deduped.push(r);
}
return deduped;
}
};
export const getUpcomingEpisodes = (
isSeriesGroup,

View file

@ -63,4 +63,4 @@ export const deleteRecurringRuleById = async (ruleId) => {
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
await API.updateRecurringRule(ruleId, { enabled: checked });
};
};

View file

@ -15,7 +15,7 @@ describe('RecordingDetailsModalUtils', () => {
audio_codec: 'AAC',
audio_channels: 2,
sample_rate: 48000,
audio_bitrate: 128
audio_bitrate: 128,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
@ -28,49 +28,45 @@ describe('RecordingDetailsModalUtils', () => {
['Audio Codec', 'AAC'],
['Audio Channels', 2],
['Sample Rate', '48000 Hz'],
['Audio Bitrate', '128 kb/s']
['Audio Bitrate', '128 kb/s'],
]);
});
it('should use width x height when resolution is not present', () => {
const stats = {
width: 1280,
height: 720
height: 720,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Resolution', '1280x720']
]);
expect(result).toEqual([['Resolution', '1280x720']]);
});
it('should prefer resolution over width/height', () => {
const stats = {
resolution: '1920x1080',
width: 1280,
height: 720
height: 720,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Resolution', '1920x1080']
]);
expect(result).toEqual([['Resolution', '1920x1080']]);
});
it('should filter out null values', () => {
const stats = {
video_codec: 'H.264',
resolution: null,
source_fps: 30
source_fps: 30,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264'],
['FPS', 30]
['FPS', 30],
]);
});
@ -78,74 +74,68 @@ describe('RecordingDetailsModalUtils', () => {
const stats = {
video_codec: 'H.264',
source_fps: undefined,
audio_codec: 'AAC'
audio_codec: 'AAC',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264'],
['Audio Codec', 'AAC']
['Audio Codec', 'AAC'],
]);
});
it('should filter out empty strings', () => {
const stats = {
video_codec: '',
audio_codec: 'AAC'
audio_codec: 'AAC',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Audio Codec', 'AAC']
]);
expect(result).toEqual([['Audio Codec', 'AAC']]);
});
it('should handle missing width or height gracefully', () => {
const stats = {
width: 1920,
video_codec: 'H.264'
video_codec: 'H.264',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264']
]);
expect(result).toEqual([['Video Codec', 'H.264']]);
});
it('should format bitrates correctly', () => {
const stats = {
video_bitrate: 2500,
audio_bitrate: 192
audio_bitrate: 192,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Bitrate', '2500 kb/s'],
['Audio Bitrate', '192 kb/s']
['Audio Bitrate', '192 kb/s'],
]);
});
it('should format sample rate correctly', () => {
const stats = {
sample_rate: 44100
sample_rate: 44100,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Sample Rate', '44100 Hz']
]);
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: ''
source_fps: '',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
@ -193,7 +183,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should return rating from program custom_properties', () => {
const customProps = {};
const program = {
custom_properties: { rating: 'TV-14' }
custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@ -204,7 +194,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should prefer customProps rating over program rating', () => {
const customProps = { rating: 'TV-MA' };
const program = {
custom_properties: { rating: 'TV-14' }
custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@ -215,7 +205,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should prefer rating_value over program rating', () => {
const customProps = { rating_value: 'PG-13' };
const program = {
custom_properties: { rating: 'TV-14' }
custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@ -294,16 +284,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' }
}
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' }
}
}
program: { tvg_id: 'show2', title: 'Other Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -325,16 +315,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2023-12-31T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' }
}
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' }
}
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -358,8 +348,8 @@ describe('RecordingDetailsModalUtils', () => {
custom_properties: {
season: 1,
episode: 5,
program: { tvg_id: 'show1', title: 'Test Show' }
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
{
start_time: '2024-01-02T18:00:00',
@ -367,9 +357,9 @@ describe('RecordingDetailsModalUtils', () => {
custom_properties: {
season: 1,
episode: 5,
program: { tvg_id: 'show1', title: 'Test Show' }
}
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -391,17 +381,17 @@ describe('RecordingDetailsModalUtils', () => {
channel: 'ch1',
custom_properties: {
onscreen_episode: 'S01E05',
program: { tvg_id: 'show1', title: 'Test Show' }
}
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' }
}
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -425,9 +415,9 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
sub_title: 'The Beginning'
}
}
sub_title: 'The Beginning',
},
},
},
{
start_time: '2024-01-02T18:00:00',
@ -436,10 +426,10 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
sub_title: 'The Beginning'
}
}
}
sub_title: 'The Beginning',
},
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -460,16 +450,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
}
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 }
}
}
program: { tvg_id: 'show1', title: 'Test Show', id: 123 },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -491,25 +481,25 @@ describe('RecordingDetailsModalUtils', () => {
end_time: '2024-01-03T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 3 }
}
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 }
}
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 }
}
}
program: { tvg_id: 'show1', title: 'Test Show', id: 4 },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -533,9 +523,9 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
}
}
program: { tvg_id: 'show1', title: 'Test Show', id: 1 },
},
},
};
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -556,9 +546,9 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'test show' }
}
}
program: { tvg_id: 'show1', title: 'test show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -582,9 +572,9 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
custom_properties: { season: 2, episode: 3 }
}
}
custom_properties: { season: 2, episode: 3 },
},
},
},
{
start_time: '2024-01-02T18:00:00',
@ -593,10 +583,10 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
custom_properties: { season: 2, episode: 3 }
}
}
}
custom_properties: { season: 2, episode: 3 },
},
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -615,8 +605,8 @@ describe('RecordingDetailsModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
channel: 'ch1'
}
channel: 'ch1',
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(

View file

@ -6,8 +6,8 @@ import dayjs from 'dayjs';
vi.mock('../../../api.js', () => ({
default: {
updateRecurringRule: vi.fn(),
deleteRecurringRule: vi.fn()
}
deleteRecurringRule: vi.fn(),
},
}));
describe('RecurringRuleModalUtils', () => {
@ -20,7 +20,7 @@ describe('RecurringRuleModalUtils', () => {
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' }
ch3: { id: 3, channel_number: '15', name: 'CBS' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -28,7 +28,7 @@ describe('RecurringRuleModalUtils', () => {
expect(result).toEqual([
{ value: '2', label: 'NBC' },
{ value: '1', label: 'ABC' },
{ value: '3', label: 'CBS' }
{ value: '3', label: 'CBS' },
]);
});
@ -36,7 +36,7 @@ describe('RecurringRuleModalUtils', () => {
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' }
ch3: { id: 3, channel_number: '10', name: 'MBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -44,35 +44,35 @@ describe('RecurringRuleModalUtils', () => {
expect(result).toEqual([
{ value: '2', label: 'ABC' },
{ value: '3', label: 'MBC' },
{ value: '1', label: 'ZBC' }
{ 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' }
ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '1', label: 'ABC' },
{ value: '2', label: 'NBC' }
{ 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: '' }
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' }
{ value: '1', label: 'Channel 1' },
]);
});
@ -96,7 +96,7 @@ describe('RecurringRuleModalUtils', () => {
it('should convert channel id to string value', () => {
const channels = {
ch1: { id: 123, channel_number: '10', name: 'ABC' }
ch1: { id: 123, channel_number: '10', name: 'ABC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -108,7 +108,7 @@ describe('RecurringRuleModalUtils', () => {
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' }
ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -131,16 +131,16 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-03T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-04T12:00:00',
custom_properties: { rule: { id: 2 } }
}
custom_properties: { rule: { id: 2 } },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -159,12 +159,12 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2023-12-31T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
}
custom_properties: { rule: { id: 1 } },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -182,16 +182,16 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-04T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-03T12:00:00',
custom_properties: { rule: { id: 1 } }
}
custom_properties: { rule: { id: 1 } },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -211,12 +211,12 @@ describe('RecurringRuleModalUtils', () => {
const recordings = {
rec1: {
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
rec2: {
start_time: '2024-01-03T12:00:00',
custom_properties: { rule: { id: 1 } }
}
custom_properties: { rule: { id: 1 } },
},
};
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -254,8 +254,8 @@ describe('RecurringRuleModalUtils', () => {
it('should handle recordings without custom_properties', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00'
}
start_time: '2024-01-02T12:00:00',
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -272,8 +272,8 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
custom_properties: {}
}
custom_properties: {},
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -290,8 +290,8 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: null }
}
custom_properties: { rule: null },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -315,7 +315,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-01-01',
end_date: '2024-12-31',
rule_name: 'My Rule',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -328,7 +328,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-01-01',
end_date: '2024-12-31',
name: 'My Rule',
enabled: true
enabled: true,
});
});
@ -338,7 +338,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: ['0', '6'],
start_time: '10:00',
end_time: '11:00',
enabled: false
enabled: false,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -351,7 +351,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: false
enabled: false,
});
});
@ -360,7 +360,7 @@ describe('RecurringRuleModalUtils', () => {
channel_id: '5',
start_time: '10:00',
end_time: '11:00',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -373,7 +373,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
@ -385,7 +385,7 @@ describe('RecurringRuleModalUtils', () => {
end_time: '11:00',
start_date: dayjs('2024-06-15'),
end_date: dayjs('2024-12-25'),
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -398,7 +398,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-06-15',
end_date: '2024-12-25',
name: '',
enabled: true
enabled: true,
});
});
@ -410,7 +410,7 @@ describe('RecurringRuleModalUtils', () => {
end_time: '11:00',
start_date: null,
end_date: null,
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -423,7 +423,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
@ -434,7 +434,7 @@ describe('RecurringRuleModalUtils', () => {
start_time: '10:00',
end_time: '11:00',
rule_name: ' Trimmed Name ',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -447,7 +447,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: 'Trimmed Name',
enabled: true
enabled: true,
});
});
@ -457,7 +457,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -470,7 +470,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
@ -480,7 +480,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
enabled: 'true'
enabled: 'true',
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -493,7 +493,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
});
@ -518,7 +518,7 @@ describe('RecurringRuleModalUtils', () => {
await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true);
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
enabled: true
enabled: true,
});
});
@ -526,7 +526,7 @@ describe('RecurringRuleModalUtils', () => {
await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false);
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
enabled: false
enabled: false,
});
});
});

View file

@ -10,13 +10,13 @@ export const uploadComskipIni = async (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,
tv_template: '',
movie_template: '',
tv_fallback_template: '',
movie_fallback_template: '',
comskip_enabled: false,
comskip_custom_path: '',
pre_offset_minutes: 0,
post_offset_minutes: 0,
};
};
};

View file

@ -2,7 +2,8 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
const M3U_EPG_DEFAULTS = '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
const M3U_EPG_DEFAULTS =
'127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
export const getNetworkAccessFormInitialValues = () => {
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
@ -39,4 +40,4 @@ export const getNetworkAccessDefaults = () => {
XC_API: '0.0.0.0/0,::/0',
UI: '0.0.0.0/0,::/0',
};
};
};

View file

@ -15,4 +15,4 @@ export const getProxySettingDefaults = () => {
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
};
};
};

View file

@ -16,4 +16,4 @@ export const getStreamSettingsFormValidation = () => {
default_stream_profile: isNotEmpty('Select a stream profile'),
preferred_region: isNotEmpty('Select a region'),
};
};
};

View file

@ -14,4 +14,4 @@ export const saveTimeZoneSetting = async (tzValue, settings) => {
value: newValue,
});
}
};
};

View file

@ -13,7 +13,7 @@ describe('DvrSettingsFormUtils', () => {
it('should call API.getComskipConfig and return result', async () => {
const mockConfig = {
enabled: true,
custom_path: '/path/to/comskip'
custom_path: '/path/to/comskip',
};
API.getComskipConfig.mockResolvedValue(mockConfig);
@ -27,13 +27,17 @@ describe('DvrSettingsFormUtils', () => {
const error = new Error('API Error');
API.getComskipConfig.mockRejectedValue(error);
await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API 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 mockFile = new File(['content'], 'comskip.ini', {
type: 'text/plain',
});
const mockResponse = { success: true };
API.uploadComskipIni.mockResolvedValue(mockResponse);
@ -44,11 +48,15 @@ describe('DvrSettingsFormUtils', () => {
});
it('should handle API errors', async () => {
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
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');
await expect(
DvrSettingsFormUtils.uploadComskipIni(mockFile)
).rejects.toThrow('Upload failed');
});
});
@ -57,14 +65,14 @@ describe('DvrSettingsFormUtils', () => {
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,
tv_template: '',
movie_template: '',
tv_fallback_template: '',
movie_fallback_template: '',
comskip_enabled: false,
comskip_custom_path: '',
pre_offset_minutes: 0,
post_offset_minutes: 0,
});
});

View file

@ -3,12 +3,12 @@ import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils';
import * as constants from '../../../../constants.js';
vi.mock('../../../../constants.js', () => ({
NETWORK_ACCESS_OPTIONS: {}
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}$/
IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/,
}));
describe('NetworkAccessFormUtils', () => {
@ -21,7 +21,7 @@ describe('NetworkAccessFormUtils', () => {
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
'network-access-admin': 'Admin Access',
'network-access-api': 'API Access',
'network-access-streaming': 'Streaming Access'
'network-access-streaming': 'Streaming Access',
};
const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
@ -29,7 +29,7 @@ describe('NetworkAccessFormUtils', () => {
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'
'network-access-streaming': '0.0.0.0/0,::/0',
});
});
@ -43,11 +43,13 @@ describe('NetworkAccessFormUtils', () => {
it('should return a new object each time', () => {
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
'network-access-admin': 'Admin Access'
'network-access-admin': 'Admin Access',
};
const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
const result1 =
NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
const result2 =
NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
expect(result1).toEqual(result2);
expect(result1).not.toBe(result2);
@ -58,20 +60,24 @@ describe('NetworkAccessFormUtils', () => {
beforeEach(() => {
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
'network-access-admin': 'Admin Access',
'network-access-api': 'API 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(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 validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24')).toBeNull();
@ -80,7 +86,8 @@ describe('NetworkAccessFormUtils', () => {
});
it('should validate valid IPv6 CIDR ranges', () => {
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('2001:db8::/32')).toBeNull();
@ -88,7 +95,8 @@ describe('NetworkAccessFormUtils', () => {
});
it('should validate multiple CIDR ranges separated by commas', () => {
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
@ -97,7 +105,8 @@ describe('NetworkAccessFormUtils', () => {
});
it('should return error for invalid IPv4 CIDR ranges', () => {
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
@ -106,16 +115,20 @@ describe('NetworkAccessFormUtils', () => {
});
it('should return error when any CIDR in comma-separated list is invalid', () => {
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
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');
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 validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('')).toBe('Invalid CIDR range');

View file

@ -3,7 +3,7 @@ import * as ProxySettingsFormUtils from '../ProxySettingsFormUtils';
import * as constants from '../../../../constants.js';
vi.mock('../../../../constants.js', () => ({
PROXY_SETTINGS_OPTIONS: {}
PROXY_SETTINGS_OPTIONS: {},
}));
describe('ProxySettingsFormUtils', () => {
@ -16,7 +16,7 @@ describe('ProxySettingsFormUtils', () => {
vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
'proxy-buffering-timeout': 'Buffering Timeout',
'proxy-buffering-speed': 'Buffering Speed',
'proxy-redis-chunk-ttl': 'Redis Chunk TTL'
'proxy-redis-chunk-ttl': 'Redis Chunk TTL',
};
const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
@ -24,7 +24,7 @@ describe('ProxySettingsFormUtils', () => {
expect(result).toEqual({
'proxy-buffering-timeout': '',
'proxy-buffering-speed': '',
'proxy-redis-chunk-ttl': ''
'proxy-redis-chunk-ttl': '',
});
});
@ -38,11 +38,13 @@ describe('ProxySettingsFormUtils', () => {
it('should return a new object each time', () => {
vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
'proxy-setting': 'Proxy Setting'
'proxy-setting': 'Proxy Setting',
};
const result1 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
const result2 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
const result1 =
ProxySettingsFormUtils.getProxySettingsFormInitialValues();
const result2 =
ProxySettingsFormUtils.getProxySettingsFormInitialValues();
expect(result1).toEqual(result2);
expect(result1).not.toBe(result2);

View file

@ -3,7 +3,7 @@ import * as StreamSettingsFormUtils from '../StreamSettingsFormUtils';
import { isNotEmpty } from '@mantine/form';
vi.mock('@mantine/form', () => ({
isNotEmpty: vi.fn((message) => message)
isNotEmpty: vi.fn((message) => message),
}));
describe('StreamSettingsFormUtils', () => {
@ -13,42 +13,49 @@ describe('StreamSettingsFormUtils', () => {
describe('getStreamSettingsFormInitialValues', () => {
it('should return initial values with correct defaults', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
const result =
StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result).toEqual({
'default_user_agent': '',
'default_stream_profile': '',
'preferred_region': '',
'auto_import_mapped_files': true,
'm3u_hash_key': []
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();
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();
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();
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();
const result1 =
StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
const result2 =
StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']);
});
@ -61,7 +68,7 @@ describe('StreamSettingsFormUtils', () => {
expect(Object.keys(result)).toEqual([
'default_user_agent',
'default_stream_profile',
'preferred_region'
'preferred_region',
]);
});

View file

@ -4,30 +4,35 @@ import * as SystemSettingsFormUtils from '../SystemSettingsFormUtils';
describe('SystemSettingsFormUtils', () => {
describe('getSystemSettingsFormInitialValues', () => {
it('should return initial values with correct defaults', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
const result =
SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toEqual({
'max_system_events': 100
max_system_events: 100,
});
});
it('should return number value for max-system-events', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
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();
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();
const result =
SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toHaveProperty('max_system_events');
});

View file

@ -4,7 +4,7 @@ import * as SettingsUtils from '../../../pages/SettingsUtils.js';
vi.mock('../../../pages/SettingsUtils.js', () => ({
createSetting: vi.fn(),
updateSetting: vi.fn()
updateSetting: vi.fn(),
}));
describe('UiSettingsFormUtils', () => {
@ -16,12 +16,12 @@ describe('UiSettingsFormUtils', () => {
it('should update existing setting when id is present', async () => {
const tzValue = 'America/New_York';
const settings = {
'system_settings': {
system_settings: {
id: 123,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC' }
}
value: { time_zone: 'UTC' },
},
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@ -31,7 +31,7 @@ describe('UiSettingsFormUtils', () => {
id: 123,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York' }
value: { time_zone: 'America/New_York' },
});
expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
});
@ -39,11 +39,11 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when existing setting has no id', async () => {
const tzValue = 'Europe/London';
const settings = {
'system_settings': {
system_settings: {
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC' }
}
value: { time_zone: 'UTC' },
},
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@ -52,7 +52,7 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'Europe/London' }
value: { time_zone: 'Europe/London' },
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@ -67,7 +67,7 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'Asia/Tokyo' }
value: { time_zone: 'Asia/Tokyo' },
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@ -75,7 +75,7 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when system_settings is null', async () => {
const tzValue = 'Pacific/Auckland';
const settings = {
'system_settings': null
system_settings: null,
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@ -84,7 +84,7 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'Pacific/Auckland' }
value: { time_zone: 'Pacific/Auckland' },
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@ -92,11 +92,11 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when id is undefined', async () => {
const tzValue = 'America/Los_Angeles';
const settings = {
'system_settings': {
system_settings: {
id: undefined,
key: 'system_settings',
value: { time_zone: 'UTC' }
}
value: { time_zone: 'UTC' },
},
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@ -108,13 +108,13 @@ describe('UiSettingsFormUtils', () => {
it('should preserve existing properties when updating', async () => {
const tzValue = 'UTC';
const settings = {
'system_settings': {
system_settings: {
id: 456,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York', some_other_setting: 'value' },
extraProp: 'should be preserved'
}
extraProp: 'should be preserved',
},
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@ -124,19 +124,19 @@ describe('UiSettingsFormUtils', () => {
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC', some_other_setting: 'value' },
extraProp: 'should be preserved'
extraProp: 'should be preserved',
});
});
it('should handle empty string timezone value', async () => {
const tzValue = '';
const settings = {
'system_settings': {
system_settings: {
id: 789,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York' }
}
value: { time_zone: 'America/New_York' },
},
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@ -145,7 +145,7 @@ describe('UiSettingsFormUtils', () => {
id: 789,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: '' }
value: { time_zone: '' },
});
});
});

View file

@ -1,5 +1,6 @@
// 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])$/;
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 =
@ -21,4 +22,4 @@ export function formatSpeed(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
}

View file

@ -6,4 +6,4 @@ export function showNotification(notificationObject) {
export function updateNotification(notificationId, notificationObject) {
return notifications.update(notificationId, notificationObject);
}
}

View file

@ -40,7 +40,7 @@ const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
else completed.push(rec);
}
}
}
};
export const categorizeRecordings = (recordings, toUserTime, now) => {
const inProgress = [];
@ -87,4 +87,4 @@ export const categorizeRecordings = (recordings, toUserTime, now) => {
upcoming: upcomingGrouped,
completed,
};
}
};

View file

@ -27,12 +27,39 @@ export const saveChangedSettings = async (settings, changedSettings) => {
};
// 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 epgFields = ['epg_match_mode', 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
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 streamFields = [
'default_user_agent',
'default_stream_profile',
'm3u_hash_key',
'preferred_region',
'auto_import_mapped_files',
];
const epgFields = [
'epg_match_mode',
'epg_match_ignore_prefixes',
'epg_match_ignore_suffixes',
'epg_match_ignore_custom',
];
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) {
@ -44,7 +71,11 @@ export const saveChangedSettings = async (settings, changedSettings) => {
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value });
await createSetting({
key: 'proxy_settings',
name: 'Proxy Settings',
value,
});
}
continue;
}
@ -54,7 +85,11 @@ export const saveChangedSettings = async (settings, changedSettings) => {
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
await createSetting({ key: 'network_access', name: 'Network Access', value });
await createSetting({
key: 'network_access',
name: 'Network Access',
value,
});
}
continue;
}
@ -65,16 +100,29 @@ export const saveChangedSettings = async (settings, changedSettings) => {
value = value.join(',');
}
if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) {
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'];
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'];
const booleanFields = [
'comskip_enabled',
'schedule_enabled',
'auto_import_mapped_files',
];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
}
@ -107,8 +155,15 @@ export const saveChangedSettings = async (settings, changedSettings) => {
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 });
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}`);
}
@ -120,7 +175,11 @@ export const getChangedSettings = (values, settings) => {
const changedSettings = {};
// EPG fields that should be kept as arrays
const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
const epgFields = [
'epg_match_ignore_prefixes',
'epg_match_ignore_suffixes',
'epg_match_ignore_custom',
];
for (const settingKey in values) {
// Skip grouped settings that are handled by their own dedicated forms
@ -181,8 +240,14 @@ export const parseSettings = (settings) => {
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.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;
@ -200,9 +265,18 @@ export const parseSettings = (settings) => {
// EPG settings - direct mapping with underscore keys
const epgSettings = settings['epg_settings']?.value;
// Always set EPG fields (even if settings don't exist yet)
parsed.epg_match_ignore_prefixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)) ? epgSettings.epg_match_ignore_prefixes : [];
parsed.epg_match_ignore_suffixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)) ? epgSettings.epg_match_ignore_suffixes : [];
parsed.epg_match_ignore_custom = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)) ? epgSettings.epg_match_ignore_custom : [];
parsed.epg_match_ignore_prefixes =
epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)
? epgSettings.epg_match_ignore_prefixes
: [];
parsed.epg_match_ignore_suffixes =
epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)
? epgSettings.epg_match_ignore_suffixes
: [];
parsed.epg_match_ignore_custom =
epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)
? epgSettings.epg_match_ignore_custom
: [];
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;
@ -212,29 +286,52 @@ export const parseSettings = (settings) => {
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_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.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_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 || '');
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;
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
@ -246,4 +343,4 @@ export const parseSettings = (settings) => {
}
return parsed;
};
};

View file

@ -24,9 +24,11 @@ export const getCurrentPrograms = async (channelHistory, channelsByUUID) => {
try {
// Get all active channel IDs that have actual channels (not just streams)
const activeChannelIds = Object.values(channelHistory)
.filter(ch => ch.name && channelsByUUID && channelsByUUID[ch.channel_id])
.map(ch => channelsByUUID[ch.channel_id])
.filter(id => id !== undefined);
.filter(
(ch) => ch.name && channelsByUUID && channelsByUUID[ch.channel_id]
)
.map((ch) => channelsByUUID[ch.channel_id])
.filter((id) => id !== undefined);
if (activeChannelIds.length === 0) {
return {};
@ -37,7 +39,7 @@ export const getCurrentPrograms = async (channelHistory, channelsByUUID) => {
// Convert array to map keyed by channel UUID for easy lookup
const programsMap = {};
if (programs && Array.isArray(programs)) {
programs.forEach(program => {
programs.forEach((program) => {
// Find the channel UUID from the channel ID
const channelEntry = Object.entries(channelsByUUID).find(
([uuid, id]) => id === program.channel_id

View file

@ -20,8 +20,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -39,8 +39,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -58,8 +58,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T10:00:00',
end_time: '2024-01-01T11:00:00',
channel: 'ch1',
custom_properties: { status: 'completed' }
}
custom_properties: { status: 'completed' },
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -77,8 +77,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: { status: 'interrupted' }
}
custom_properties: { status: 'interrupted' },
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -95,8 +95,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T09:00:00',
end_time: '2024-01-01T10:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -114,8 +114,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
program: { id: 100 }
}
program: { id: 100 },
},
},
{
id: 2,
@ -123,9 +123,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch2',
custom_properties: {
program: { id: 100 }
}
}
program: { id: 100 },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -141,8 +141,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
program: { title: 'Show A' }
}
program: { title: 'Show A' },
},
},
{
id: 2,
@ -150,9 +150,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
program: { title: 'Show A' }
}
}
program: { title: 'Show A' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -168,8 +168,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
program: { title: 'Show A' }
}
program: { title: 'Show A' },
},
},
{
id: 2,
@ -177,9 +177,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch2',
custom_properties: {
program: { title: 'Show A' }
}
}
program: { title: 'Show A' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -194,22 +194,22 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T10:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: { program: { id: 1 } }
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 } }
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 } }
}
custom_properties: { program: { id: 3 } },
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -227,8 +227,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Show A' }
}
program: { tvg_id: 'show1', title: 'Show A' },
},
},
{
id: 2,
@ -236,8 +236,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Show A' }
}
program: { tvg_id: 'show1', title: 'Show A' },
},
},
{
id: 3,
@ -245,9 +245,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T17:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Show A' }
}
}
program: { tvg_id: 'show1', title: 'Show A' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -265,8 +265,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Show A' }
}
program: { tvg_id: 'show1', title: 'Show A' },
},
},
{
id: 2,
@ -274,9 +274,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'show a' }
}
}
program: { tvg_id: 'show1', title: 'show a' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -293,8 +293,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Show A' }
}
program: { tvg_id: 'show1', title: 'Show A' },
},
},
{
id: 2,
@ -302,9 +302,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show2', title: 'Show A' }
}
}
program: { tvg_id: 'show2', title: 'Show A' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -321,22 +321,28 @@ describe('DVRUtils', () => {
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' } }
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' } }
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' } }
}
custom_properties: {
program: { id: 3, tvg_id: 'show3', title: 'Show C' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -346,7 +352,6 @@ describe('DVRUtils', () => {
expect(result.upcoming[2].id).toBe(1);
});
it('should sort completed by end_time descending', () => {
const recordings = [
{
@ -354,22 +359,22 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T08:00:00',
end_time: '2024-01-01T09:00:00',
channel: 'ch1',
custom_properties: { status: 'completed' }
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' }
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' }
}
custom_properties: { status: 'completed' },
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -386,8 +391,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
};
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -418,15 +423,15 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {}
custom_properties: {},
},
{
id: 1,
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -440,8 +445,8 @@ describe('DVRUtils', () => {
id: 1,
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1'
}
channel: 'ch1',
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -456,8 +461,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -472,8 +477,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {}
}
custom_properties: {},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -489,8 +494,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
program: { id: 100, tvg_id: 'show1', title: 'Show A' }
}
program: { id: 100, tvg_id: 'show1', title: 'Show A' },
},
},
{
id: 2,
@ -498,8 +503,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch2',
custom_properties: {
program: { id: 100, tvg_id: 'show1', title: 'Show A' }
}
program: { id: 100, tvg_id: 'show1', title: 'Show A' },
},
},
{
id: 3,
@ -507,9 +512,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
program: { id: 101, tvg_id: 'show1', title: 'Show A' }
}
}
program: { id: 101, tvg_id: 'show1', title: 'Show A' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@ -526,9 +531,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Show A' }
}
}
program: { tvg_id: 'show1', title: 'Show A' },
},
},
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);

View file

@ -9,8 +9,8 @@ vi.mock('../../../api.js', () => ({
setPluginEnabled: vi.fn(),
importPlugin: vi.fn(),
reloadPlugins: vi.fn(),
deletePlugin: vi.fn()
}
deletePlugin: vi.fn(),
},
}));
describe('PluginsUtils', () => {
@ -66,7 +66,9 @@ describe('PluginsUtils', () => {
API.updatePluginSettings.mockRejectedValue(error);
await expect(PluginsUtils.updatePluginSettings(key, settings)).rejects.toThrow('API error');
await expect(
PluginsUtils.updatePluginSettings(key, settings)
).rejects.toThrow('API error');
});
});
@ -109,7 +111,9 @@ describe('PluginsUtils', () => {
API.runPluginAction.mockRejectedValue(error);
await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow('Action not found');
await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow(
'Action not found'
);
});
});
@ -187,13 +191,17 @@ describe('PluginsUtils', () => {
API.setPluginEnabled.mockRejectedValue(error);
await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow('Plugin not found');
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' });
const importFile = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
await PluginsUtils.importPlugin(importFile);
@ -202,7 +210,9 @@ describe('PluginsUtils', () => {
});
it('should return API response', async () => {
const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const importFile = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
const mockResponse = { key: 'imported-plugin', success: true };
API.importPlugin.mockResolvedValue(mockResponse);
@ -230,12 +240,16 @@ describe('PluginsUtils', () => {
});
it('should propagate API errors', async () => {
const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
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');
await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow(
'Invalid plugin format'
);
});
});

View file

@ -7,8 +7,8 @@ vi.mock('../../../api.js', () => ({
checkSetting: vi.fn(),
updateSetting: vi.fn(),
createSetting: vi.fn(),
rehashStreams: vi.fn()
}
rehashStreams: vi.fn(),
},
}));
describe('SettingsUtils', () => {
@ -36,7 +36,11 @@ describe('SettingsUtils', () => {
describe('createSetting', () => {
it('should call API createSetting with values', async () => {
const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
const values = {
key: 'new-setting',
name: 'New Setting',
value: 'value',
};
await SettingsUtils.createSetting(values);
expect(API.createSetting).toHaveBeenCalledWith(values);
expect(API.createSetting).toHaveBeenCalledTimes(1);
@ -59,13 +63,13 @@ describe('SettingsUtils', () => {
key: 'stream_settings',
value: {
default_user_agent: 5,
m3u_hash_key: 'channel_name'
}
}
m3u_hash_key: 'channel_name',
},
},
};
const changedSettings = {
default_user_agent: 7,
preferred_region: 'UK'
preferred_region: 'UK',
};
API.updateSetting.mockResolvedValue({});
@ -78,8 +82,8 @@ describe('SettingsUtils', () => {
value: {
default_user_agent: 7,
m3u_hash_key: 'channel_name',
preferred_region: 'UK'
}
preferred_region: 'UK',
},
});
});
@ -88,11 +92,11 @@ describe('SettingsUtils', () => {
stream_settings: {
id: 1,
key: 'stream_settings',
value: {}
}
value: {},
},
};
const changedSettings = {
m3u_hash_key: ['channel_name', 'channel_number']
m3u_hash_key: ['channel_name', 'channel_number'],
};
API.updateSetting.mockResolvedValue({});
@ -103,8 +107,8 @@ describe('SettingsUtils', () => {
id: 1,
key: 'stream_settings',
value: {
m3u_hash_key: 'channel_name,channel_number'
}
m3u_hash_key: 'channel_name,channel_number',
},
});
});
@ -113,12 +117,12 @@ describe('SettingsUtils', () => {
stream_settings: {
id: 1,
key: 'stream_settings',
value: {}
}
value: {},
},
};
const changedSettings = {
default_user_agent: '5',
default_stream_profile: '3'
default_stream_profile: '3',
};
API.updateSetting.mockResolvedValue({});
@ -130,8 +134,8 @@ describe('SettingsUtils', () => {
key: 'stream_settings',
value: {
default_user_agent: 5,
default_stream_profile: 3
}
default_stream_profile: 3,
},
});
});
@ -140,17 +144,17 @@ describe('SettingsUtils', () => {
dvr_settings: {
id: 2,
key: 'dvr_settings',
value: {}
value: {},
},
stream_settings: {
id: 1,
key: 'stream_settings',
value: {}
}
value: {},
},
};
const changedSettings = {
comskip_enabled: true,
auto_import_mapped_files: false
auto_import_mapped_files: false,
};
API.updateSetting.mockResolvedValue({});
@ -166,15 +170,15 @@ describe('SettingsUtils', () => {
id: 5,
key: 'proxy_settings',
value: {
buffering_speed: 1.0
}
}
buffering_speed: 1.0,
},
},
};
const changedSettings = {
proxy_settings: {
buffering_speed: 2.5,
buffering_timeout: 15
}
buffering_timeout: 15,
},
};
API.updateSetting.mockResolvedValue({});
@ -186,8 +190,8 @@ describe('SettingsUtils', () => {
key: 'proxy_settings',
value: {
buffering_speed: 2.5,
buffering_timeout: 15
}
buffering_timeout: 15,
},
});
});
@ -195,8 +199,8 @@ describe('SettingsUtils', () => {
const settings = {};
const changedSettings = {
proxy_settings: {
buffering_speed: 2.5
}
buffering_speed: 2.5,
},
};
API.createSetting.mockResolvedValue({});
@ -207,8 +211,8 @@ describe('SettingsUtils', () => {
key: 'proxy_settings',
name: 'Proxy Settings',
value: {
buffering_speed: 2.5
}
buffering_speed: 2.5,
},
});
});
@ -217,11 +221,11 @@ describe('SettingsUtils', () => {
network_access: {
id: 6,
key: 'network_access',
value: []
}
value: [],
},
};
const changedSettings = {
network_access: ['192.168.1.0/24', '10.0.0.0/8']
network_access: ['192.168.1.0/24', '10.0.0.0/8'],
};
API.updateSetting.mockResolvedValue({});
@ -231,7 +235,7 @@ describe('SettingsUtils', () => {
expect(API.updateSetting).toHaveBeenCalledWith({
id: 6,
key: 'network_access',
value: ['192.168.1.0/24', '10.0.0.0/8']
value: ['192.168.1.0/24', '10.0.0.0/8'],
});
});
});
@ -239,7 +243,7 @@ describe('SettingsUtils', () => {
describe('parseSettings', () => {
it('should parse grouped settings correctly', () => {
const mockSettings = {
'stream_settings': {
stream_settings: {
id: 1,
key: 'stream_settings',
value: {
@ -247,19 +251,19 @@ describe('SettingsUtils', () => {
default_stream_profile: 3,
m3u_hash_key: 'channel_name,channel_number',
preferred_region: 'US',
auto_import_mapped_files: true
}
auto_import_mapped_files: true,
},
},
'dvr_settings': {
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
}
}
post_offset_minutes: 5,
},
},
};
const result = SettingsUtils.parseSettings(mockSettings);
@ -280,13 +284,13 @@ describe('SettingsUtils', () => {
it('should handle empty m3u_hash_key', () => {
const mockSettings = {
'stream_settings': {
stream_settings: {
id: 1,
key: 'stream_settings',
value: {
m3u_hash_key: ''
}
}
m3u_hash_key: '',
},
},
};
const result = SettingsUtils.parseSettings(mockSettings);
@ -295,30 +299,30 @@ describe('SettingsUtils', () => {
it('should handle proxy_settings', () => {
const mockSettings = {
'proxy_settings': {
proxy_settings: {
id: 5,
key: 'proxy_settings',
value: {
buffering_speed: 2.5,
buffering_timeout: 15
}
}
buffering_timeout: 15,
},
},
};
const result = SettingsUtils.parseSettings(mockSettings);
expect(result.proxy_settings).toEqual({
buffering_speed: 2.5,
buffering_timeout: 15
buffering_timeout: 15,
});
});
it('should handle network_access', () => {
const mockSettings = {
'network_access': {
network_access: {
id: 6,
key: 'network_access',
value: ['192.168.1.0/24', '10.0.0.0/8']
}
value: ['192.168.1.0/24', '10.0.0.0/8'],
},
};
const result = SettingsUtils.parseSettings(mockSettings);
@ -331,12 +335,12 @@ describe('SettingsUtils', () => {
const values = {
time_zone: 'America/New_York',
max_system_events: 2000,
comskip_enabled: true
comskip_enabled: true,
};
const settings = {
time_zone: { value: 'UTC' },
max_system_events: { value: 1000 },
comskip_enabled: { value: false }
comskip_enabled: { value: false },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@ -344,18 +348,18 @@ describe('SettingsUtils', () => {
expect(changes).toEqual({
time_zone: 'America/New_York',
max_system_events: 2000,
comskip_enabled: true
comskip_enabled: true,
});
});
it('should not detect unchanged values', () => {
const values = {
time_zone: 'UTC',
max_system_events: 1000
max_system_events: 1000,
};
const settings = {
time_zone: { value: 'UTC' },
max_system_events: { value: 1000 }
max_system_events: { value: 1000 },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@ -364,10 +368,10 @@ describe('SettingsUtils', () => {
it('should preserve type of numeric values', () => {
const values = {
max_system_events: 2000
max_system_events: 2000,
};
const settings = {
max_system_events: { value: 1000 }
max_system_events: { value: 1000 },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@ -377,16 +381,16 @@ describe('SettingsUtils', () => {
it('should detect changes in array values', () => {
const values = {
m3u_hash_key: ['channel_name', 'channel_number']
m3u_hash_key: ['channel_name', 'channel_number'],
};
const settings = {
m3u_hash_key: { value: 'channel_name' }
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'
m3u_hash_key: 'channel_name,channel_number',
});
});
@ -394,12 +398,12 @@ describe('SettingsUtils', () => {
const values = {
time_zone: 'America/New_York',
proxy_settings: {
buffering_speed: 2.5
buffering_speed: 2.5,
},
network_access: ['192.168.1.0/24']
network_access: ['192.168.1.0/24'],
};
const settings = {
time_zone: { value: 'UTC' }
time_zone: { value: 'UTC' },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@ -456,8 +460,8 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: [],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
}
}
},
},
};
const changedSettings = {
epg_match_mode: 'advanced',
@ -476,7 +480,7 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
}
},
});
});
@ -497,7 +501,7 @@ describe('SettingsUtils', () => {
value: {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['Sling:'],
}
},
});
});
@ -511,8 +515,8 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [' 4K'],
epg_match_ignore_custom: ['Plus'],
}
}
},
},
};
const changedSettings = {
epg_match_mode: 'default',
@ -530,7 +534,7 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [' 4K'],
epg_match_ignore_custom: ['Plus'],
}
},
});
});
});

View file

@ -8,8 +8,8 @@ vi.mock('../../../api.js', () => ({
stopClient: vi.fn(),
stopVODClient: vi.fn(),
fetchActiveChannelStats: vi.fn(),
getVODStats: vi.fn()
}
getVODStats: vi.fn(),
},
}));
describe('StatsUtils', () => {
@ -41,7 +41,9 @@ describe('StatsUtils', () => {
API.stopChannel.mockRejectedValue(error);
await expect(StatsUtils.stopChannel(id)).rejects.toThrow('Failed to stop channel');
await expect(StatsUtils.stopChannel(id)).rejects.toThrow(
'Failed to stop channel'
);
});
});
@ -72,7 +74,9 @@ describe('StatsUtils', () => {
API.stopClient.mockRejectedValue(error);
await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow('Failed to stop client');
await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow(
'Failed to stop client'
);
});
});
@ -100,7 +104,9 @@ describe('StatsUtils', () => {
API.stopVODClient.mockRejectedValue(error);
await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow('Failed to stop VOD client');
await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow(
'Failed to stop VOD client'
);
});
});
@ -122,7 +128,9 @@ describe('StatsUtils', () => {
API.fetchActiveChannelStats.mockRejectedValue(error);
await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow('Failed to fetch stats');
await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow(
'Failed to fetch stats'
);
});
});
@ -144,26 +152,29 @@ describe('StatsUtils', () => {
API.getVODStats.mockRejectedValue(error);
await expect(StatsUtils.getVODStats()).rejects.toThrow('Failed to fetch VOD stats');
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 }
ch1: { channel_id: 'ch1', uptime: 100 },
};
const vodConnections = [
{
content_type: 'movie',
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 50 }
]
}
connections: [{ client_id: 'client1', connected_at: 50 }],
},
];
const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
const result = StatsUtils.getCombinedConnections(
channelHistory,
vodConnections
);
expect(result).toHaveLength(2);
expect(result[0].type).toBe('stream');
@ -172,19 +183,20 @@ describe('StatsUtils', () => {
it('should sort by sortKey descending (newest first)', () => {
const channelHistory = {
'ch1': { channel_id: 'ch1', uptime: 50 }
ch1: { channel_id: 'ch1', uptime: 50 },
};
const vodConnections = [
{
content_type: 'movie',
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 100 }
]
}
connections: [{ client_id: 'client1', connected_at: 100 }],
},
];
const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
const result = StatsUtils.getCombinedConnections(
channelHistory,
vodConnections
);
expect(result[0].sortKey).toBe(100);
expect(result[1].sortKey).toBe(50);
@ -197,9 +209,9 @@ describe('StatsUtils', () => {
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 100 },
{ client_id: 'client2', connected_at: 200 }
]
}
{ client_id: 'client2', connected_at: 200 },
],
},
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@ -218,9 +230,9 @@ describe('StatsUtils', () => {
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 100 },
{ client_id: 'client2', connected_at: 200 }
]
}
{ client_id: 'client2', connected_at: 200 },
],
},
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@ -231,7 +243,7 @@ describe('StatsUtils', () => {
it('should use uptime for stream sortKey', () => {
const channelHistory = {
'ch1': { channel_id: 'ch1', uptime: 150 }
ch1: { channel_id: 'ch1', uptime: 150 },
};
const result = StatsUtils.getCombinedConnections(channelHistory, []);
@ -241,7 +253,7 @@ describe('StatsUtils', () => {
it('should default to 0 for missing uptime', () => {
const channelHistory = {
'ch1': { channel_id: 'ch1' }
ch1: { channel_id: 'ch1' },
};
const result = StatsUtils.getCombinedConnections(channelHistory, []);
@ -254,10 +266,8 @@ describe('StatsUtils', () => {
{
content_type: 'movie',
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 250 }
]
}
connections: [{ client_id: 'client1', connected_at: 250 }],
},
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@ -270,8 +280,8 @@ describe('StatsUtils', () => {
{
content_type: 'movie',
content_uuid: 'uuid1',
connections: []
}
connections: [],
},
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@ -290,8 +300,8 @@ describe('StatsUtils', () => {
{
content_type: 'movie',
content_uuid: 'uuid1',
connections: null
}
connections: null,
},
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@ -303,13 +313,10 @@ describe('StatsUtils', () => {
describe('getClientStats', () => {
it('should extract clients from channel stats', () => {
const stats = {
'ch1': {
ch1: {
channel_id: 'ch1',
clients: [
{ client_id: 'client1' },
{ client_id: 'client2' }
]
}
clients: [{ client_id: 'client1' }, { client_id: 'client2' }],
},
};
const result = StatsUtils.getClientStats(stats);
@ -321,13 +328,11 @@ describe('StatsUtils', () => {
it('should attach channel reference to each client', () => {
const stats = {
'ch1': {
ch1: {
channel_id: 'ch1',
name: 'Channel 1',
clients: [
{ client_id: 'client1' }
]
}
clients: [{ client_id: 'client1' }],
},
};
const result = StatsUtils.getClientStats(stats);
@ -335,14 +340,14 @@ describe('StatsUtils', () => {
expect(result[0].channel).toEqual({
channel_id: 'ch1',
name: 'Channel 1',
clients: [{ client_id: 'client1' }]
clients: [{ client_id: 'client1' }],
});
});
it('should handle channels without clients array', () => {
const stats = {
'ch1': { channel_id: 'ch1' },
'ch2': { channel_id: 'ch2', clients: null }
ch1: { channel_id: 'ch1' },
ch2: { channel_id: 'ch2', clients: null },
};
const result = StatsUtils.getClientStats(stats);
@ -352,10 +357,10 @@ describe('StatsUtils', () => {
it('should handle empty clients array', () => {
const stats = {
'ch1': {
ch1: {
channel_id: 'ch1',
clients: []
}
clients: [],
},
};
const result = StatsUtils.getClientStats(stats);
@ -365,14 +370,14 @@ describe('StatsUtils', () => {
it('should combine clients from multiple channels', () => {
const stats = {
'ch1': {
ch1: {
channel_id: 'ch1',
clients: [{ client_id: 'client1' }]
clients: [{ client_id: 'client1' }],
},
'ch2': {
ch2: {
channel_id: 'ch2',
clients: [{ client_id: 'client2' }]
}
clients: [{ client_id: 'client2' }],
},
};
const result = StatsUtils.getClientStats(stats);
@ -392,9 +397,7 @@ describe('StatsUtils', () => {
describe('getStatsByChannelId', () => {
it('should create stats indexed by channel_id', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', total_bytes: 1000 }
]
channels: [{ channel_id: 'ch1', total_bytes: 1000 }],
};
const prevChannelHistory = {};
const channelsByUUID = {};
@ -415,15 +418,13 @@ describe('StatsUtils', () => {
it('should calculate bitrates from previous history', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', total_bytes: 2000 }
]
channels: [{ channel_id: 'ch1', total_bytes: 2000 }],
};
const prevChannelHistory = {
'ch1': {
ch1: {
total_bytes: 1000,
bitrates: [500]
}
bitrates: [500],
},
};
const result = StatsUtils.getStatsByChannelId(
@ -440,15 +441,13 @@ describe('StatsUtils', () => {
it('should limit bitrates array to 15 entries', () => {
const prevBitrates = new Array(15).fill(100);
const channelStats = {
channels: [
{ channel_id: 'ch1', total_bytes: 2000 }
]
channels: [{ channel_id: 'ch1', total_bytes: 2000 }],
};
const prevChannelHistory = {
'ch1': {
ch1: {
total_bytes: 1000,
bitrates: prevBitrates
}
bitrates: prevBitrates,
},
};
const result = StatsUtils.getStatsByChannelId(
@ -466,15 +465,13 @@ describe('StatsUtils', () => {
it('should skip negative bitrates', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', total_bytes: 500 }
]
channels: [{ channel_id: 'ch1', total_bytes: 500 }],
};
const prevChannelHistory = {
'ch1': {
ch1: {
total_bytes: 1000,
bitrates: []
}
bitrates: [],
},
};
const result = StatsUtils.getStatsByChannelId(
@ -490,18 +487,16 @@ describe('StatsUtils', () => {
it('should merge channel data from channelsByUUID', () => {
const channelStats = {
channels: [
{ channel_id: 'uuid1', total_bytes: 1000 }
]
channels: [{ channel_id: 'uuid1', total_bytes: 1000 }],
};
const channelsByUUID = {
'uuid1': 'channel-key-1'
uuid1: 'channel-key-1',
};
const channels = {
'channel-key-1': {
name: 'Channel 1',
logo: 'logo.png'
}
logo: 'logo.png',
},
};
const result = StatsUtils.getStatsByChannelId(
@ -518,13 +513,11 @@ describe('StatsUtils', () => {
it('should find and attach stream profile', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', stream_profile: '1' }
]
channels: [{ channel_id: 'ch1', stream_profile: '1' }],
};
const streamProfiles = [
{ id: 1, name: 'HD Profile' },
{ id: 2, name: 'SD Profile' }
{ id: 2, name: 'SD Profile' },
];
const result = StatsUtils.getStatsByChannelId(
@ -540,13 +533,9 @@ describe('StatsUtils', () => {
it('should default to Unknown for missing stream profile', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', stream_profile: '999' }
]
channels: [{ channel_id: 'ch1', stream_profile: '999' }],
};
const streamProfiles = [
{ id: 1, name: 'HD Profile' }
];
const streamProfiles = [{ id: 1, name: 'HD Profile' }];
const result = StatsUtils.getStatsByChannelId(
channelStats,
@ -561,9 +550,7 @@ describe('StatsUtils', () => {
it('should preserve stream_id from channel stats', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', stream_id: 'stream-123' }
]
channels: [{ channel_id: 'ch1', stream_id: 'stream-123' }],
};
const result = StatsUtils.getStatsByChannelId(
@ -579,9 +566,7 @@ describe('StatsUtils', () => {
it('should set stream_id to null if missing', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1' }
]
channels: [{ channel_id: 'ch1' }],
};
const result = StatsUtils.getStatsByChannelId(
@ -600,8 +585,8 @@ describe('StatsUtils', () => {
const channelStats = {
channels: [
{ total_bytes: 1000 },
{ channel_id: 'ch1', total_bytes: 2000 }
]
{ channel_id: 'ch1', total_bytes: 2000 },
],
};
const result = StatsUtils.getStatsByChannelId(
@ -614,7 +599,10 @@ describe('StatsUtils', () => {
expect(result).not.toHaveProperty('undefined');
expect(result).toHaveProperty('ch1');
expect(consoleSpy).toHaveBeenCalledWith('Found channel without channel_id:', { total_bytes: 1000 });
expect(consoleSpy).toHaveBeenCalledWith(
'Found channel without channel_id:',
{ total_bytes: 1000 }
);
consoleSpy.mockRestore();
});
@ -635,9 +623,7 @@ describe('StatsUtils', () => {
it('should initialize empty bitrates array for new channels', () => {
const channelStats = {
channels: [
{ channel_id: 'ch1', total_bytes: 1000 }
]
channels: [{ channel_id: 'ch1', total_bytes: 1000 }],
};
const result = StatsUtils.getStatsByChannelId(

View file

@ -5,8 +5,8 @@ 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' }
cat1: { name: 'Action', category_type: 'movie' },
cat2: { name: 'Drama', category_type: 'series' },
};
const filters = { type: 'all' };
@ -14,15 +14,21 @@ describe('VODsUtils', () => {
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)' });
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' }
cat1: { name: 'Action', category_type: 'movie' },
cat2: { name: 'Drama', category_type: 'series' },
cat3: { name: 'Comedy', category_type: 'movie' },
};
const filters = { type: 'movies' };
@ -36,9 +42,9 @@ describe('VODsUtils', () => {
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' }
cat1: { name: 'Action', category_type: 'movie' },
cat2: { name: 'Drama', category_type: 'series' },
cat3: { name: 'Sitcom', category_type: 'series' },
};
const filters = { type: 'series' };
@ -52,8 +58,8 @@ describe('VODsUtils', () => {
it('should show all categories when type is all', () => {
const categories = {
'cat1': { name: 'Action', category_type: 'movie' },
'cat2': { name: 'Drama', category_type: 'series' }
cat1: { name: 'Action', category_type: 'movie' },
cat2: { name: 'Drama', category_type: 'series' },
};
const filters = { type: 'all' };
@ -74,7 +80,7 @@ describe('VODsUtils', () => {
it('should create value with name and category_type separated by pipe', () => {
const categories = {
'cat1': { name: 'Action', category_type: 'movie' }
cat1: { name: 'Action', category_type: 'movie' },
};
const filters = { type: 'all' };
@ -85,8 +91,8 @@ describe('VODsUtils', () => {
it('should handle undefined type filter', () => {
const categories = {
'cat1': { name: 'Action', category_type: 'movie' },
'cat2': { name: 'Drama', category_type: 'series' }
cat1: { name: 'Action', category_type: 'movie' },
cat2: { name: 'Drama', category_type: 'series' },
};
const filters = {};
@ -97,9 +103,9 @@ describe('VODsUtils', () => {
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' }
cat1: { name: 'Action', category_type: 'movie' },
cat2: { name: 'Drama', category_type: 'series' },
cat3: { name: 'Comedy', category_type: 'movie' },
};
const filters = { type: 'series' };
@ -113,18 +119,14 @@ describe('VODsUtils', () => {
describe('filterCategoriesToEnabled', () => {
it('should return only categories with enabled m3u_accounts', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: true }
]
m3u_accounts: [{ id: 1, enabled: true }],
},
'cat2': {
cat2: {
name: 'Drama',
m3u_accounts: [
{ id: 2, enabled: false }
]
}
m3u_accounts: [{ id: 2, enabled: false }],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -135,14 +137,14 @@ describe('VODsUtils', () => {
it('should include category if any m3u_account is enabled', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: false },
{ id: 2, enabled: true },
{ id: 3, enabled: false }
]
}
{ id: 3, enabled: false },
],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -152,13 +154,13 @@ describe('VODsUtils', () => {
it('should exclude category if all m3u_accounts are disabled', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: false },
{ id: 2, enabled: false }
]
}
{ id: 2, enabled: false },
],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -168,10 +170,10 @@ describe('VODsUtils', () => {
it('should exclude category with empty m3u_accounts array', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: []
}
m3u_accounts: [],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -181,13 +183,11 @@ describe('VODsUtils', () => {
it('should preserve original category data', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
category_type: 'movie',
m3u_accounts: [
{ id: 1, enabled: true }
]
}
m3u_accounts: [{ id: 1, enabled: true }],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -203,18 +203,18 @@ describe('VODsUtils', () => {
it('should filter multiple categories correctly', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: [{ id: 1, enabled: true }]
m3u_accounts: [{ id: 1, enabled: true }],
},
'cat2': {
cat2: {
name: 'Drama',
m3u_accounts: [{ id: 2, enabled: false }]
m3u_accounts: [{ id: 2, enabled: false }],
},
'cat3': {
cat3: {
name: 'Comedy',
m3u_accounts: [{ id: 3, enabled: true }]
}
m3u_accounts: [{ id: 3, enabled: true }],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -227,10 +227,10 @@ describe('VODsUtils', () => {
it('should handle category with null m3u_accounts', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: null
}
m3u_accounts: null,
},
};
expect(() => {
@ -240,13 +240,13 @@ describe('VODsUtils', () => {
it('should handle truthy enabled values', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: 1 },
{ id: 2, enabled: false }
]
}
{ id: 2, enabled: false },
],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@ -256,12 +256,10 @@ describe('VODsUtils', () => {
it('should only match strict true for enabled', () => {
const allCategories = {
'cat1': {
cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: 'true' }
]
}
m3u_accounts: [{ id: 1, enabled: 'true' }],
},
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);