Currently Recording
@@ -1537,14 +170,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {inProgress.map((rec) => (
-
- ))}
+ {}
{inProgress.length === 0 && (
Nothing recording right now.
@@ -1566,14 +196,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {upcoming.map((rec) => (
-
- ))}
+ {}
{upcoming.length === 0 && (
No upcoming recordings.
@@ -1595,14 +222,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {completed.map((rec) => (
-
- ))}
+ {}
{completed.length === 0 && (
No completed recordings yet.
@@ -1635,67 +259,31 @@ const DVRPage = () => {
{/* Details Modal */}
{detailsRecording && (
- {
- const rec = detailsRecording;
- const now = userNow();
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
- // call into child RecordingCard behavior by constructing a URL like there
- const channel = channels[rec.channel];
- if (!channel) return;
- let url = `/proxy/ts/stream/${channel.uuid}`;
- if (useSettingsStore.getState().environment.env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- useVideoStore.getState().showVideo(url, 'live');
- }
- }}
- onWatchRecording={() => {
- let fileUrl =
- detailsRecording.custom_properties?.file_url ||
- detailsRecording.custom_properties?.output_file_url;
- if (!fileUrl) return;
- if (
- useSettingsStore.getState().environment.env_mode === 'dev' &&
- fileUrl.startsWith('/')
- ) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- useVideoStore.getState().showVideo(fileUrl, 'vod', {
- name:
- detailsRecording.custom_properties?.program?.title ||
- 'Recording',
- logo: {
- url:
- (detailsRecording.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/`
- : channels[detailsRecording.channel]?.logo?.cache_url) ||
- '/logo.png',
- },
- });
- }}
- onEdit={(rec) => {
- setEditRecording(rec);
- closeDetails();
- }}
- />
+
+ Loading...}>
+ {
+ setEditRecording(rec);
+ closeDetails();
+ }}
+ />
+
+
)}
);
};
-export default DVRPage;
+export default DVRPage;
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
deleted file mode 100644
index c3c0fb61..00000000
--- a/frontend/src/pages/Dashboard.jsx
+++ /dev/null
@@ -1,27 +0,0 @@
-// src/components/Dashboard.js
-import React, { useState } from 'react';
-
-const Dashboard = () => {
- const [newStream, setNewStream] = useState('');
-
- return (
-
-
Dashboard Page
-
setNewStream(e.target.value)}
- placeholder="Enter Stream"
- />
-
-
Streams:
-
- {state.streams.map((stream, index) => (
- - {stream}
- ))}
-
-
- );
-};
-
-export default Dashboard;
diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx
index dbeaf431..ac0fdf82 100644
--- a/frontend/src/pages/Guide.jsx
+++ b/frontend/src/pages/Guide.jsx
@@ -5,248 +5,94 @@ import React, {
useEffect,
useRef,
useCallback,
+ Suspense,
} from 'react';
-import dayjs from 'dayjs';
-import API from '../api';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
-import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
-import { notifications } from '@mantine/notifications';
import useSettingsStore from '../store/settings';
import {
- Title,
- Box,
- Flex,
- Button,
- Text,
- Paper,
- Group,
- TextInput,
- Select,
ActionIcon,
+ Box,
+ Button,
+ Flex,
+ Group,
+ LoadingOverlay,
+ Paper,
+ Select,
+ Text,
+ TextInput,
+ Title,
Tooltip,
- Transition,
- Modal,
- Stack,
} from '@mantine/core';
-import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react';
+import { Calendar, Clock, Search, Video, X } from 'lucide-react';
import './guide.css';
import useEPGsStore from '../store/epgs';
-import useLocalStorage from '../hooks/useLocalStorage';
import { useElementSize } from '@mantine/hooks';
import { VariableSizeList } from 'react-window';
import {
- PROGRAM_HEIGHT,
- EXPANDED_PROGRAM_HEIGHT,
buildChannelIdMap,
- mapProgramsByChannel,
+ calculateDesiredScrollPosition,
+ calculateEarliestProgramStart,
+ calculateEnd,
+ calculateHourTimeline,
+ calculateLatestProgramEnd,
+ calculateLeftScrollPosition,
+ calculateNowPosition,
+ calculateScrollPosition,
+ calculateScrollPositionByTimeClick,
+ calculateStart,
+ CHANNEL_WIDTH,
computeRowHeights,
+ createRecording,
+ createSeriesRule,
+ evaluateSeriesRule,
+ EXPANDED_PROGRAM_HEIGHT,
+ fetchPrograms,
+ fetchRules,
+ filterGuideChannels,
+ formatTime,
+ getGroupOptions,
+ getProfileOptions,
+ getRuleByProgram,
+ HOUR_WIDTH,
+ mapChannelsById,
+ mapProgramsByChannel,
+ mapRecordingsByProgramId,
+ matchChannelByTvgId,
+ MINUTE_BLOCK_WIDTH,
+ MINUTE_INCREMENT,
+ PROGRAM_HEIGHT,
+ sortChannels,
} from './guideUtils';
-
-/** Layout constants */
-const CHANNEL_WIDTH = 120; // Width of the channel/logo column
-const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
-const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
-const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
-
-const GuideRow = React.memo(({ index, style, data }) => {
- const {
- filteredChannels,
- programsByChannelId,
- expandedProgramId,
- rowHeights,
- logos,
- hoveredChannelId,
- setHoveredChannelId,
- renderProgram,
- handleLogoClick,
- contentWidth,
- } = data;
-
- const channel = filteredChannels[index];
- if (!channel) {
- return null;
- }
-
- const channelPrograms = programsByChannelId.get(channel.id) || [];
- const rowHeight =
- rowHeights[index] ??
- (channelPrograms.some((program) => program.id === expandedProgramId)
- ? EXPANDED_PROGRAM_HEIGHT
- : PROGRAM_HEIGHT);
-
- return (
-
-
- handleLogoClick(channel, event)}
- onMouseEnter={() => setHoveredChannelId(channel.id)}
- onMouseLeave={() => setHoveredChannelId(null)}
- >
- {hoveredChannelId === channel.id && (
-
-
-
- )}
-
-
-
-
-
-
-
- {channel.channel_number || '-'}
-
-
-
-
-
- {channelPrograms.length > 0 ? (
- channelPrograms.map((program) =>
- renderProgram(program, undefined, channel)
- )
- ) : (
- <>
- {Array.from({ length: Math.ceil(24 / 2) }).map(
- (_, placeholderIndex) => (
-
- No program data
-
- )
- )}
- >
- )}
-
-
-
- );
-});
+import {
+ getShowVideoUrl,
+} from '../utils/cards/RecordingCardUtils.js';
+import {
+ add,
+ convertToMs,
+ format,
+ getNow,
+ initializeTime,
+ startOfDay,
+ useDateTimeFormat,
+} from '../utils/dateTimeUtils.js';
+import GuideRow from '../components/GuideRow.jsx';
+import HourTimeline from '../components/HourTimeline';
+const ProgramRecordingModal = React.lazy(() =>
+ import('../components/forms/ProgramRecordingModal'));
+const SeriesRecordingModal = React.lazy(() =>
+ import('../components/forms/SeriesRecordingModal'));
+import { showNotification } from '../utils/notificationUtils.js';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
export default function TVChannelGuide({ startDate, endDate }) {
const channels = useChannelsStore((s) => s.channels);
const recordings = useChannelsStore((s) => s.recordings);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
+ const isLoading = useChannelsStore((s) => s.isLoading);
+ const [isProgramsLoading, setIsProgramsLoading] = useState(true);
const logos = useLogosStore((s) => s.logos);
const tvgsById = useEPGsStore((s) => s.tvgsById);
@@ -254,8 +100,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
- const [filteredChannels, setFilteredChannels] = useState([]);
- const [now, setNow] = useState(dayjs());
+ const [now, setNow] = useState(getNow());
const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
const [recordingForProgram, setRecordingForProgram] = useState(null);
const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
@@ -290,81 +135,38 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Load program data once
useEffect(() => {
- if (!Object.keys(channels).length === 0) {
+ if (Object.keys(channels).length === 0) {
console.warn('No channels provided or empty channels array');
- notifications.show({ title: 'No channels available', color: 'red.5' });
+ showNotification({ title: 'No channels available', color: 'red.5' });
+ setIsProgramsLoading(false);
return;
}
- const fetchPrograms = async () => {
- console.log('Fetching program grid...');
- const fetched = await API.getGrid(); // GETs your EPG grid
- console.log(`Received ${fetched.length} programs`);
+ const sortedChannels = sortChannels(channels);
+ setGuideChannels(sortedChannels);
- // Include ALL channels, sorted by channel number - don't filter by EPG data
- const sortedChannels = Object.values(channels).sort(
- (a, b) =>
- (a.channel_number || Infinity) - (b.channel_number || Infinity)
- );
-
- console.log(`Using all ${sortedChannels.length} available channels`);
-
- const processedPrograms = fetched.map((program) => {
- const start = dayjs(program.start_time);
- const end = dayjs(program.end_time);
- return {
- ...program,
- startMs: start.valueOf(),
- endMs: end.valueOf(),
- };
+ fetchPrograms()
+ .then((data) => {
+ setPrograms(data);
+ setIsProgramsLoading(false);
+ })
+ .catch((error) => {
+ console.error('Failed to fetch programs:', error);
+ setIsProgramsLoading(false);
});
-
- setGuideChannels(sortedChannels);
- setFilteredChannels(sortedChannels); // Initialize filtered channels
- setPrograms(processedPrograms);
- };
-
- fetchPrograms();
}, [channels]);
// Apply filters when search, group, or profile changes
- useEffect(() => {
- if (!guideChannels.length) return;
+ const filteredChannels = useMemo(() => {
+ if (!guideChannels.length) return [];
- let result = [...guideChannels];
-
- // Apply search filter
- if (searchQuery) {
- const query = searchQuery.toLowerCase();
- result = result.filter((channel) =>
- channel.name.toLowerCase().includes(query)
- );
- }
-
- // Apply channel group filter
- if (selectedGroupId !== 'all') {
- result = result.filter(
- (channel) => channel.channel_group_id === parseInt(selectedGroupId)
- );
- }
-
- // Apply profile filter
- if (selectedProfileId !== 'all') {
- // Get the profile's enabled channels
- const profileChannels = profiles[selectedProfileId]?.channels || [];
- // Check if channels is a Set (from the error message, it likely is)
- const enabledChannelIds = Array.isArray(profileChannels)
- ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
- : profiles[selectedProfileId]?.channels instanceof Set
- ? Array.from(profiles[selectedProfileId].channels)
- : [];
-
- result = result.filter((channel) =>
- enabledChannelIds.includes(channel.id)
- );
- }
-
- setFilteredChannels(result);
+ return filterGuideChannels(
+ guideChannels,
+ searchQuery,
+ selectedGroupId,
+ selectedProfileId,
+ profiles
+ );
}, [
searchQuery,
selectedGroupId,
@@ -374,61 +176,44 @@ export default function TVChannelGuide({ startDate, endDate }) {
]);
// Use start/end from props or default to "today at midnight" +24h
- const defaultStart = dayjs(startDate || dayjs().startOf('day'));
- const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
+ const defaultStart = initializeTime(startDate || startOfDay(getNow()));
+ const defaultEnd = endDate
+ ? initializeTime(endDate)
+ : add(defaultStart, 24, 'hour');
// Expand timeline if needed based on actual earliest/ latest program
- const earliestProgramStart = useMemo(() => {
- if (!programs.length) return defaultStart;
- return programs.reduce((acc, p) => {
- const s = dayjs(p.start_time);
- return s.isBefore(acc) ? s : acc;
- }, defaultStart);
- }, [programs, defaultStart]);
+ const earliestProgramStart = useMemo(
+ () => calculateEarliestProgramStart(programs, defaultStart),
+ [programs, defaultStart]
+ );
- const latestProgramEnd = useMemo(() => {
- if (!programs.length) return defaultEnd;
- return programs.reduce((acc, p) => {
- const e = dayjs(p.end_time);
- return e.isAfter(acc) ? e : acc;
- }, defaultEnd);
- }, [programs, defaultEnd]);
+ const latestProgramEnd = useMemo(
+ () => calculateLatestProgramEnd(programs, defaultEnd),
+ [programs, defaultEnd]
+ );
- const start = earliestProgramStart.isBefore(defaultStart)
- ? earliestProgramStart
- : defaultStart;
- const end = latestProgramEnd.isAfter(defaultEnd)
- ? latestProgramEnd
- : defaultEnd;
+ const start = calculateStart(earliestProgramStart, defaultStart);
+ const end = calculateEnd(latestProgramEnd, defaultEnd);
const channelIdByTvgId = useMemo(
() => buildChannelIdMap(guideChannels, tvgsById, epgs),
[guideChannels, tvgsById, epgs]
);
- const channelById = useMemo(() => {
- const map = new Map();
- guideChannels.forEach((channel) => {
- map.set(channel.id, channel);
- });
- return map;
- }, [guideChannels]);
+ const channelById = useMemo(
+ () => mapChannelsById(guideChannels),
+ [guideChannels]
+ );
const programsByChannelId = useMemo(
() => mapProgramsByChannel(programs, channelIdByTvgId),
[programs, channelIdByTvgId]
);
- const recordingsByProgramId = useMemo(() => {
- const map = new Map();
- (recordings || []).forEach((recording) => {
- const programId = recording?.custom_properties?.program?.id;
- if (programId != null) {
- map.set(programId, recording);
- }
- });
- return map;
- }, [recordings]);
+ const recordingsByProgramId = useMemo(
+ () => mapRecordingsByProgramId(recordings),
+ [recordings]
+ );
const rowHeights = useMemo(
() =>
@@ -445,62 +230,19 @@ export default function TVChannelGuide({ startDate, endDate }) {
[rowHeights]
);
- const [timeFormatSetting] = useLocalStorage('time-format', '12h');
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- // Use user preference for time format
- const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm';
- const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM';
+ const [timeFormat, dateFormat] = useDateTimeFormat();
// Format day label using relative terms when possible (Today, Tomorrow, etc)
const formatDayLabel = useCallback(
- (time) => {
- const today = dayjs().startOf('day');
- const tomorrow = today.add(1, 'day');
- const weekLater = today.add(7, 'day');
-
- const day = time.startOf('day');
-
- if (day.isSame(today, 'day')) {
- return 'Today';
- } else if (day.isSame(tomorrow, 'day')) {
- return 'Tomorrow';
- } else if (day.isBefore(weekLater)) {
- // Within a week, show day name
- return time.format('dddd');
- } else {
- // Beyond a week, show month and day
- return time.format(dateFormat);
- }
- },
+ (time) => formatTime(time, dateFormat),
[dateFormat]
);
// Hourly marks with day labels
- const hourTimeline = useMemo(() => {
- const hours = [];
- let current = start;
- let currentDay = null;
-
- while (current.isBefore(end)) {
- // Check if we're entering a new day
- const day = current.startOf('day');
- const isNewDay = !currentDay || !day.isSame(currentDay, 'day');
-
- if (isNewDay) {
- currentDay = day;
- }
-
- // Add day information to our hour object
- hours.push({
- time: current,
- isNewDay,
- dayLabel: formatDayLabel(current),
- });
-
- current = current.add(1, 'hour');
- }
- return hours;
- }, [start, end, formatDayLabel]);
+ const hourTimeline = useMemo(
+ () => calculateHourTimeline(start, end, formatDayLabel),
+ [start, end, formatDayLabel]
+ );
useEffect(() => {
const node = guideRef.current;
@@ -542,17 +284,16 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Update "now" every second
useEffect(() => {
const interval = setInterval(() => {
- setNow(dayjs());
+ setNow(getNow());
}, 1000);
return () => clearInterval(interval);
}, []);
// Pixel offset for the "now" vertical line
- const nowPosition = useMemo(() => {
- if (now.isBefore(start) || now.isAfter(end)) return -1;
- const minutesSinceStart = now.diff(start, 'minute');
- return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
- }, [now, start, end]);
+ const nowPosition = useMemo(
+ () => calculateNowPosition(now, start, end),
+ [now, start, end]
+ );
useEffect(() => {
const tvGuide = tvGuideRef.current;
@@ -765,31 +506,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Scroll to the nearest half-hour mark ONLY on initial load
useEffect(() => {
if (programs.length > 0 && !initialScrollComplete) {
- const roundedNow =
- now.minute() < 30
- ? now.startOf('hour')
- : now.startOf('hour').add(30, 'minute');
- const nowOffset = roundedNow.diff(start, 'minute');
- const scrollPosition =
- (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
- MINUTE_BLOCK_WIDTH;
-
- const scrollPos = Math.max(scrollPosition, 0);
- syncScrollLeft(scrollPos);
+ syncScrollLeft(calculateScrollPosition(now, start));
setInitialScrollComplete(true);
}
}, [programs, start, now, initialScrollComplete, syncScrollLeft]);
const findChannelByTvgId = useCallback(
- (tvgId) => {
- const channelIds = channelIdByTvgId.get(String(tvgId));
- if (!channelIds || channelIds.length === 0) {
- return null;
- }
- // Return the first channel that matches this TVG ID
- return channelById.get(channelIds[0]) || null;
- },
+ (tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId),
[channelById, channelIdByTvgId]
);
@@ -798,19 +522,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
setRecordChoiceProgram(program);
setRecordChoiceOpen(true);
try {
- const rules = await API.listSeriesRules();
- const rule = (rules || []).find(
- (r) =>
- String(r.tvg_id) === String(program.tvg_id) &&
- (!r.title || r.title === program.title)
- );
+ const rules = await fetchRules();
+ const rule = getRuleByProgram(rules, program);
setExistingRuleMode(rule ? rule.mode : null);
} catch (error) {
console.warn('Failed to fetch series rules metadata', error);
}
- const existingRecording = recordingsByProgramId.get(program.id) || null;
- setRecordingForProgram(existingRecording);
+ setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
},
[recordingsByProgramId]
);
@@ -819,7 +538,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
async (program) => {
const channel = findChannelByTvgId(program.tvg_id);
if (!channel) {
- notifications.show({
+ showNotification({
title: 'Unable to schedule recording',
message: 'No channel found for this program.',
color: 'red.6',
@@ -827,24 +546,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
- await API.createRecording({
- channel: `${channel.id}`,
- start_time: program.start_time,
- end_time: program.end_time,
- custom_properties: { program },
- });
- notifications.show({ title: 'Recording scheduled' });
+ await createRecording(channel, program);
+ showNotification({ title: 'Recording scheduled' });
},
[findChannelByTvgId]
);
const saveSeriesRule = useCallback(async (program, mode) => {
- await API.createSeriesRule({
- tvg_id: program.tvg_id,
- mode,
- title: program.title,
- });
- await API.evaluateSeriesRules(program.tvg_id);
+ await createSeriesRule(program, mode);
+ await evaluateSeriesRule(program);
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
@@ -853,7 +563,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
error
);
}
- notifications.show({
+ showNotification({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
});
}, []);
@@ -861,7 +571,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const openRules = useCallback(async () => {
setRulesOpen(true);
try {
- const r = await API.listSeriesRules();
+ const r = await fetchRules();
setRules(r);
} catch (error) {
console.warn('Failed to load series rules', error);
@@ -878,12 +588,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
- let vidUrl = `/proxy/ts/stream/${matched.uuid}`;
- if (env_mode === 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
-
- showVideo(vidUrl);
+ showVideo(getShowVideoUrl(matched, env_mode));
},
[env_mode, findChannelByTvgId, showVideo]
);
@@ -892,12 +597,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
(channel, event) => {
event.stopPropagation();
- let vidUrl = `/proxy/ts/stream/${channel.uuid}`;
- if (env_mode === 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
-
- showVideo(vidUrl);
+ showVideo(getShowVideoUrl(channel, env_mode));
},
[env_mode, showVideo]
);
@@ -906,13 +606,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
(program, event) => {
event.stopPropagation();
- const programStartMs =
- program.startMs ?? dayjs(program.start_time).valueOf();
- const startOffsetMinutes = (programStartMs - start.valueOf()) / 60000;
- const leftPx =
- (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
- const desiredScrollPosition = Math.max(0, leftPx - 20);
-
if (expandedProgramId === program.id) {
setExpandedProgramId(null);
setRecordingForProgram(null);
@@ -921,6 +614,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
}
+ const leftPx = calculateLeftScrollPosition(program, start);
+ const desiredScrollPosition = calculateDesiredScrollPosition(leftPx);
+
const guideNode = guideRef.current;
if (guideNode) {
const currentScrollPosition = guideNode.scrollLeft;
@@ -948,16 +644,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
- const roundedNow =
- now.minute() < 30
- ? now.startOf('hour')
- : now.startOf('hour').add(30, 'minute');
- const nowOffset = roundedNow.diff(start, 'minute');
- const scrollPosition =
- (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
-
- const scrollPos = Math.max(scrollPosition, 0);
- syncScrollLeft(scrollPos, 'smooth');
+ syncScrollLeft(calculateScrollPosition(now, start), 'smooth');
}, [now, nowPosition, start, syncScrollLeft]);
const handleTimelineScroll = useCallback(() => {
@@ -1000,44 +687,26 @@ export default function TVChannelGuide({ startDate, endDate }) {
const handleTimeClick = useCallback(
(clickedTime, event) => {
- const rect = event.currentTarget.getBoundingClientRect();
- const clickPositionX = event.clientX - rect.left;
- const percentageAcross = clickPositionX / rect.width;
- const minuteWithinHour = Math.floor(percentageAcross * 60);
-
- let snappedMinute;
- if (minuteWithinHour < 7.5) {
- snappedMinute = 0;
- } else if (minuteWithinHour < 22.5) {
- snappedMinute = 15;
- } else if (minuteWithinHour < 37.5) {
- snappedMinute = 30;
- } else if (minuteWithinHour < 52.5) {
- snappedMinute = 45;
- } else {
- snappedMinute = 0;
- clickedTime = clickedTime.add(1, 'hour');
- }
-
- const snappedTime = clickedTime.minute(snappedMinute);
- const snappedOffset = snappedTime.diff(start, 'minute');
- const scrollPosition =
- (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
-
- syncScrollLeft(scrollPosition, 'smooth');
+ syncScrollLeft(
+ calculateScrollPositionByTimeClick(event, clickedTime, start),
+ 'smooth'
+ );
},
[start, syncScrollLeft]
);
const renderProgram = useCallback(
(program, channelStart = start, channel = null) => {
- const programStartMs =
- program.startMs ?? dayjs(program.start_time).valueOf();
- const programEndMs = program.endMs ?? dayjs(program.end_time).valueOf();
- const programStart = dayjs(programStartMs);
- const programEnd = dayjs(programEndMs);
+ const {
+ programStart,
+ programEnd,
+ startMs: programStartMs,
+ endMs: programEndMs,
+ isLive,
+ isPast,
+ } = program;
const startOffsetMinutes =
- (programStartMs - channelStart.valueOf()) / 60000;
+ (programStartMs - convertToMs(channelStart)) / 60000;
const durationMinutes = (programEndMs - programStartMs) / 60000;
const leftPx =
(startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@@ -1048,10 +717,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const recording = recordingsByProgramId.get(program.id);
- const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
- const isPast = now.isAfter(programEnd);
const isExpanded = expandedProgramId === program.id;
-
const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
const MIN_EXPANDED_WIDTH = 450;
const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH);
@@ -1069,36 +735,61 @@ export default function TVChannelGuide({ startDate, endDate }) {
textOffsetLeft = Math.min(visibleStart, maxOffset);
}
+ const RecordButton = () => {
+ return (
+ }
+ variant="filled"
+ color="red"
+ size="xs"
+ onClick={(event) => {
+ event.stopPropagation();
+ openRecordChoice(program);
+ }}
+ >
+ Record
+
+ );
+ };
+ const WatchNow = () => {
+ return (
+ }
+ variant="filled"
+ color="blue"
+ size="xs"
+ onClick={(event) => {
+ event.stopPropagation();
+ handleWatchStream(program);
+ }}
+ >
+ Watch Now
+
+ );
+ };
return (
handleProgramClick(program, event)}
>
{recording && (
@@ -1154,8 +850,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
overflow: 'hidden',
}}
>
- {programStart.format(timeFormat)} -{' '}
- {programEnd.format(timeFormat)}
+ {format(programStart, timeFormat)} -{' '}
+ {format(programEnd, timeFormat)}
@@ -1169,13 +865,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
{program.description}
@@ -1183,37 +879,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
)}
{isExpanded && (
-
+
- {!isPast && (
- }
- variant="filled"
- color="red"
- size="xs"
- onClick={(event) => {
- event.stopPropagation();
- openRecordChoice(program);
- }}
- >
- Record
-
- )}
+ {!isPast && }
- {isLive && (
- }
- variant="filled"
- color="blue"
- size="xs"
- onClick={(event) => {
- event.stopPropagation();
- handleWatchStream(program);
- }}
- >
- Watch Now
-
- )}
+ {isLive && }
)}
@@ -1296,49 +966,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
}, [searchQuery, selectedGroupId, selectedProfileId]);
// Create group options for dropdown - but only include groups used by guide channels
- const groupOptions = useMemo(() => {
- const options = [{ value: 'all', label: 'All Channel Groups' }];
-
- if (channelGroups && guideChannels.length > 0) {
- // Get unique channel group IDs from the channels that have program data
- const usedGroupIds = new Set();
- guideChannels.forEach((channel) => {
- if (channel.channel_group_id) {
- usedGroupIds.add(channel.channel_group_id);
- }
- });
- // Only add groups that are actually used by channels in the guide
- Object.values(channelGroups)
- .filter((group) => usedGroupIds.has(group.id))
- .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
- .forEach((group) => {
- options.push({
- value: group.id.toString(),
- label: group.name,
- });
- });
- }
- return options;
- }, [channelGroups, guideChannels]);
+ const groupOptions = useMemo(
+ () => getGroupOptions(channelGroups, guideChannels),
+ [channelGroups, guideChannels]
+ );
// Create profile options for dropdown
- const profileOptions = useMemo(() => {
- const options = [{ value: 'all', label: 'All Profiles' }];
-
- if (profiles) {
- Object.values(profiles).forEach((profile) => {
- if (profile.id !== '0') {
- // Skip the 'All' default profile
- options.push({
- value: profile.id.toString(),
- label: profile.name,
- });
- }
- });
- }
-
- return options;
- }, [profiles]);
+ const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]);
// Clear all filters
const clearFilters = () => {
@@ -1357,40 +991,45 @@ export default function TVChannelGuide({ startDate, endDate }) {
setSelectedProfileId(value || 'all');
};
+ const handleClearSearchQuery = () => {
+ setSearchQuery('');
+ };
+ const handleChangeSearchQuery = (e) => {
+ setSearchQuery(e.target.value);
+ };
+
return (
{/* Sticky top bar */}
{/* Title and current time */}
-
+
TV Guide
- {now.format(`dddd, ${dateFormat}, YYYY • ${timeFormat}`)}
+ {format(now, `dddd, ${dateFormat}, YYYY • ${timeFormat}`)}
setSearchQuery(e.target.value)}
- style={{ width: '250px' }} // Reduced width from flex: 1
+ onChange={handleChangeSearchQuery}
+ w={'250px'} // Reduced width from flex: 1
leftSection={}
rightSection={
searchQuery ? (
setSearchQuery('')}
+ onClick={handleClearSearchQuery}
variant="subtle"
color="gray"
size="sm"
@@ -1433,7 +1072,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
data={groupOptions}
value={selectedGroupId}
onChange={handleGroupChange} // Use the new handler
- style={{ width: '220px' }}
+ w={'220px'}
clearable={true} // Allow clearing the selection
/>
@@ -1442,7 +1081,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
data={profileOptions}
value={selectedProfileId}
onChange={handleProfileChange} // Use the new handler
- style={{ width: '180px' }}
+ w={'180px'}
clearable={true} // Allow clearing the selection
/>
@@ -1460,14 +1099,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
onClick={openRules}
style={{
backgroundColor: '#245043',
- border: '1px solid #3BA882',
- color: '#FFFFFF',
}}
+ bd={'1px solid #3BA882'}
+ color='#FFFFFF'
>
Series Rules
-
+
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
@@ -1477,34 +1116,34 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Guide container with headers and scrollable content */}
{/* Logo header - Sticky, non-scrollable */}
{/* Logo header cell - sticky in both directions */}
{/* Timeline header with its own scrollbar */}
@@ -1512,122 +1151,33 @@ export default function TVChannelGuide({ startDate, endDate }) {
style={{
flex: 1,
overflow: 'hidden',
- position: 'relative',
}}
+ pos='relative'
>
- {' '}
- {hourTimeline.map((hourData) => {
- const { time, isNewDay } = hourData;
-
- return (
- handleTimeClick(time, e)}
- >
- {/* Remove the special day label for new days since we'll show day for all hours */}
-
- {/* Position time label at the left border of each hour block */}
-
- {/* Show day above time for every hour using the same format */}
-
- {formatDayLabel(time)}{' '}
- {/* Use same formatDayLabel function for all hours */}
-
- {time.format(timeFormat)}
-
- {/*time.format('A')*/}
-
-
-
- {/* Hour boundary marker - more visible */}
-
-
- {/* Quarter hour tick marks */}
-
- {[15, 30, 45].map((minute) => (
-
- ))}
-
-
- );
- })}
+
@@ -1638,22 +1188,23 @@ export default function TVChannelGuide({ startDate, endDate }) {
ref={guideContainerRef}
style={{
flex: 1,
- position: 'relative',
overflow: 'hidden',
}}
+ pos='relative'
>
+
{nowPosition >= 0 && (
)}
@@ -1674,13 +1225,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
{GuideRow}
) : (
-
+
No channels match your filters
{/* Record choice modal */}
{recordChoiceOpen && recordChoiceProgram && (
- setRecordChoiceOpen(false)}
- title={`Record: ${recordChoiceProgram.title}`}
- centered
- radius="md"
- zIndex={9999}
- overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
- styles={{
- content: { backgroundColor: '#18181B', color: 'white' },
- header: { backgroundColor: '#18181B', color: 'white' },
- title: { color: 'white' },
- }}
- >
-
-
-
-
- {recordingForProgram && (
- <>
-
-
- >
- )}
- {existingRuleMode && (
-
- )}
-
-
+
+ }>
+ setRecordChoiceOpen(false)}
+ program={recordChoiceProgram}
+ recording={recordingForProgram}
+ existingRuleMode={existingRuleMode}
+ onRecordOne={() => recordOne(recordChoiceProgram)}
+ onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')}
+ onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')}
+ onExistingRuleModeChange={setExistingRuleMode}
+ />
+
+
)}
{/* Series rules modal */}
{rulesOpen && (
- setRulesOpen(false)}
- title="Series Recording Rules"
- centered
- radius="md"
- zIndex={9999}
- overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
- styles={{
- content: { backgroundColor: '#18181B', color: 'white' },
- header: { backgroundColor: '#18181B', color: 'white' },
- title: { color: 'white' },
- }}
- >
-
- {(!rules || rules.length === 0) && (
-
- No series rules configured
-
- )}
- {rules &&
- rules.map((r) => (
-
-
- {r.title || r.tvg_id} —{' '}
- {r.mode === 'new' ? 'New episodes' : 'Every episode'}
-
-
-
-
-
-
- ))}
-
-
+
+ }>
+ setRulesOpen(false)}
+ rules={rules}
+ onRulesUpdate={setRules}
+ />
+
+
)}
);
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
deleted file mode 100644
index e9751d8d..00000000
--- a/frontend/src/pages/Home.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-// src/components/Home.js
-import React, { useState } from 'react';
-
-const Home = () => {
- const [newChannel, setNewChannel] = useState('');
-
- return (
-
-
Home Page
-
- );
-};
-
-export default Home;
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index 262d4c35..3c2cf869 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -1,13 +1,21 @@
-import React from 'react';
+import React, { lazy, Suspense } from 'react';
import LoginForm from '../components/forms/LoginForm';
-import SuperuserForm from '../components/forms/SuperuserForm';
+const SuperuserForm = lazy(() => import('../components/forms/SuperuserForm'));
import useAuthStore from '../store/auth';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import { Text } from '@mantine/core';
const Login = ({}) => {
const superuserExists = useAuthStore((s) => s.superuserExists);
if (!superuserExists) {
- return ;
+ return (
+
+ Loading...}>
+
+
+
+ );
}
return ;
diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx
index 889e32c9..f95212d6 100644
--- a/frontend/src/pages/Logos.jsx
+++ b/frontend/src/pages/Logos.jsx
@@ -1,34 +1,34 @@
import React, { useEffect, useCallback, useState } from 'react';
-import { Box, Tabs, Flex, Text } from '@mantine/core';
-import { notifications } from '@mantine/notifications';
+import { Box, Tabs, Flex, Text, TabsList, TabsTab } from '@mantine/core';
import useLogosStore from '../store/logos';
import useVODLogosStore from '../store/vodLogos';
import LogosTable from '../components/tables/LogosTable';
import VODLogosTable from '../components/tables/VODLogosTable';
+import { showNotification } from '../utils/notificationUtils.js';
const LogosPage = () => {
- const { fetchAllLogos, needsAllLogos, logos } = useLogosStore();
- const { totalCount } = useVODLogosStore();
+ const logos = useLogosStore(s => s.logos);
+ const totalCount = useVODLogosStore(s => s.totalCount);
const [activeTab, setActiveTab] = useState('channel');
-
- const channelLogosCount = Object.keys(logos).length;
- const vodLogosCount = totalCount;
+ const logoCount = activeTab === 'channel'
+ ? Object.keys(logos).length
+ : totalCount;
const loadChannelLogos = useCallback(async () => {
try {
// Only fetch all logos if we haven't loaded them yet
- if (needsAllLogos()) {
- await fetchAllLogos();
+ if (useLogosStore.getState().needsAllLogos()) {
+ await useLogosStore.getState().fetchAllLogos();
}
} catch (err) {
- notifications.show({
+ showNotification({
title: 'Error',
message: 'Failed to load channel logos',
color: 'red',
});
console.error('Failed to load channel logos:', err);
}
- }, [fetchAllLogos, needsAllLogos]);
+ }, []);
useEffect(() => {
// Always load channel logos on mount
@@ -39,51 +39,41 @@ const LogosPage = () => {
{/* Header with title and tabs */}
Logos
- ({activeTab === 'channel' ? channelLogosCount : vodLogosCount}{' '}
- logo
- {(activeTab === 'channel' ? channelLogosCount : vodLogosCount) !==
- 1
- ? 's'
- : ''}
- )
+ ({logoCount} {logoCount !== 1 ? 'logos' : 'logo'})
-
- Channel Logos
- VOD Logos
-
+
+ Channel Logos
+ VOD Logos
+
diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx
index f2902523..21df7faf 100644
--- a/frontend/src/pages/Plugins.jsx
+++ b/frontend/src/pages/Plugins.jsx
@@ -1,353 +1,108 @@
-import React, { useEffect, useState } from 'react';
+import React, {
+ Suspense,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import {
- AppShell,
- Box,
+ ActionIcon,
Alert,
+ AppShellMain,
+ Box,
Button,
- Card,
+ Divider,
+ FileInput,
Group,
Loader,
+ Modal,
+ SimpleGrid,
Stack,
Switch,
Text,
- TextInput,
- NumberInput,
- Select,
- Divider,
- ActionIcon,
- SimpleGrid,
- Modal,
- FileInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
-import { RefreshCcw, Trash2 } from 'lucide-react';
-import API from '../api';
-import { notifications } from '@mantine/notifications';
+import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
+import { usePluginStore } from '../store/plugins.jsx';
+import {
+ deletePluginByKey,
+ importPlugin,
+ runPluginAction,
+ setPluginEnabled,
+ updatePluginSettings,
+} from '../utils/pages/PluginsUtils.js';
+import { RefreshCcw } from 'lucide-react';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+const PluginCard = React.lazy(() =>
+ import('../components/cards/PluginCard.jsx'));
-const Field = ({ field, value, onChange }) => {
- const common = { label: field.label, description: field.help_text };
- const effective = value ?? field.default;
- switch (field.type) {
- case 'boolean':
- return (
- onChange(field.id, e.currentTarget.checked)}
- label={field.label}
- description={field.help_text}
- />
- );
- case 'number':
- return (
- onChange(field.id, v)}
- {...common}
- />
- );
- case 'select':
- return (
-
)}
-
- {!missing && plugin.fields && plugin.fields.length > 0 && (
-
- {plugin.fields.map((f) => (
-
- ))}
-
-
-
-
- )}
-
- {!missing && plugin.actions && plugin.actions.length > 0 && (
- <>
-
-
- {plugin.actions.map((a) => (
-
-
- {a.label}
- {a.description && (
-
- {a.description}
-
- )}
-
-
-
- ))}
- {running && (
-
- Running action… please wait
-
- )}
- {!running && lastResult?.file && (
-
- Output: {lastResult.file}
-
- )}
- {!running && lastResult?.error && (
-
- Error: {String(lastResult.error)}
-
- )}
-
- >
- )}
- {
- setConfirmOpen(false);
- setConfirmConfig({ title: '', message: '', onConfirm: null });
- }}
- title={confirmConfig.title}
- centered
- >
-
- {confirmConfig.message}
-
-
-
-
-
-
-
+ >
);
};
export default function PluginsPage() {
- const [loading, setLoading] = useState(true);
- const [plugins, setPlugins] = useState([]);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
@@ -358,118 +113,172 @@ export default function PluginsPage() {
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
- const [uploadNoticeId, setUploadNoticeId] = useState(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [confirmConfig, setConfirmConfig] = useState({
+ title: '',
+ message: '',
+ resolve: null,
+ });
- const load = async () => {
- setLoading(true);
- try {
- const list = await API.getPlugins();
- setPlugins(list);
- } finally {
- setLoading(false);
- }
+ const handleReload = () => {
+ usePluginStore.getState().invalidatePlugins();
};
- useEffect(() => {
- load();
+ const handleRequestDelete = useCallback((pl) => {
+ setDeleteTarget(pl);
+ setDeleteOpen(true);
}, []);
- const requireTrust = (plugin) => {
+ const requireTrust = useCallback((plugin) => {
return new Promise((resolve) => {
setTrustResolve(() => resolve);
setTrustOpen(true);
});
+ }, []);
+
+ const showImportForm = useCallback(() => {
+ setImportOpen(true);
+ setImported(null);
+ setImportFile(null);
+ setEnableAfterImport(false);
+ }, []);
+
+ const requestConfirm = useCallback((title, message) => {
+ return new Promise((resolve) => {
+ setConfirmConfig({ title, message, resolve });
+ setConfirmOpen(true);
+ });
+ }, []);
+
+ const handleImportPlugin = () => {
+ return async () => {
+ setImporting(true);
+ const id = showNotification({
+ title: 'Uploading plugin',
+ message: 'Backend may restart; please wait…',
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ });
+ try {
+ const resp = await importPlugin(importFile);
+ if (resp?.success && resp.plugin) {
+ setImported(resp.plugin);
+ usePluginStore.getState().invalidatePlugins();
+
+ updateNotification({
+ id,
+ loading: false,
+ color: 'green',
+ title: 'Imported',
+ message:
+ 'Plugin imported. If the app briefly disconnected, it should be back now.',
+ autoClose: 3000,
+ });
+ } else {
+ updateNotification({
+ id,
+ loading: false,
+ color: 'red',
+ title: 'Import failed',
+ message: resp?.error || 'Unknown error',
+ autoClose: 5000,
+ });
+ }
+ } catch (e) {
+ // API.importPlugin already showed a concise error; just update the loading notice
+ updateNotification({
+ id,
+ loading: false,
+ color: 'red',
+ title: 'Import failed',
+ message:
+ (e?.body && (e.body.error || e.body.detail)) ||
+ e?.message ||
+ 'Failed',
+ autoClose: 5000,
+ });
+ } finally {
+ setImporting(false);
+ }
+ };
};
+ const handleEnablePlugin = () => {
+ return async () => {
+ if (!imported) return;
+
+ const proceed = imported.ever_enabled || (await requireTrust(imported));
+ if (proceed) {
+ const resp = await setPluginEnabled(imported.key, true);
+ if (resp?.success) {
+ usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true });
+
+ showNotification({
+ title: imported.name,
+ message: 'Plugin enabled',
+ color: 'green',
+ });
+ }
+ setImportOpen(false);
+ setImported(null);
+ setEnableAfterImport(false);
+ }
+ };
+ };
+
+ const handleDeletePlugin = () => {
+ return async () => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ const resp = await deletePluginByKey(deleteTarget.key);
+ if (resp?.success) {
+ usePluginStore.getState().removePlugin(deleteTarget.key);
+
+ showNotification({
+ title: deleteTarget.name,
+ message: 'Plugin deleted',
+ color: 'green',
+ });
+ }
+ setDeleteOpen(false);
+ setDeleteTarget(null);
+ } finally {
+ setDeleting(false);
+ }
+ };
+ };
+
+ const handleConfirm = useCallback((confirmed) => {
+ const resolver = confirmConfig.resolve;
+ setConfirmOpen(false);
+ setConfirmConfig({ title: '', message: '', resolve: null });
+ if (resolver) resolver(confirmed);
+ }, [confirmConfig.resolve]);
+
return (
-
+
Plugins
-
- {loading ? (
-
- ) : (
- <>
-
- {plugins.map((p) => (
- {
- const resp = await API.setPluginEnabled(key, next);
- if (resp?.ever_enabled !== undefined) {
- setPlugins((prev) =>
- prev.map((pl) =>
- pl.key === key
- ? {
- ...pl,
- ever_enabled: resp.ever_enabled,
- enabled: resp.enabled,
- }
- : pl
- )
- );
- } else {
- setPlugins((prev) =>
- prev.map((pl) =>
- pl.key === key ? { ...pl, enabled: next } : pl
- )
- );
- }
- return resp;
- }}
- onRequireTrust={requireTrust}
- onRequestDelete={(plugin) => {
- setDeleteTarget(plugin);
- setDeleteOpen(true);
- }}
- />
- ))}
-
- {plugins.length === 0 && (
-
-
- No plugins found. Drop a plugin into /data/plugins{' '}
- and reload.
-
-
- )}
- >
- )}
+
+
{/* Import Plugin Modal */}
{
- setImporting(true);
- const id = notifications.show({
- title: 'Uploading plugin',
- message: 'Backend may restart; please wait…',
- loading: true,
- autoClose: false,
- withCloseButton: false,
- });
- setUploadNoticeId(id);
- try {
- const resp = await API.importPlugin(importFile);
- if (resp?.success && resp.plugin) {
- setImported(resp.plugin);
- setPlugins((prev) => [
- resp.plugin,
- ...prev.filter((p) => p.key !== resp.plugin.key),
- ]);
- notifications.update({
- id,
- loading: false,
- color: 'green',
- title: 'Imported',
- message:
- 'Plugin imported. If the app briefly disconnected, it should be back now.',
- autoClose: 3000,
- });
- } else {
- notifications.update({
- id,
- loading: false,
- color: 'red',
- title: 'Import failed',
- message: resp?.error || 'Unknown error',
- autoClose: 5000,
- });
- }
- } catch (e) {
- // API.importPlugin already showed a concise error; just update the loading notice
- notifications.update({
- id,
- loading: false,
- color: 'red',
- title: 'Import failed',
- message:
- (e?.body && (e.body.error || e.body.detail)) ||
- e?.message ||
- 'Failed',
- autoClose: 5000,
- });
- } finally {
- setImporting(false);
- setUploadNoticeId(null);
- }
- }}
+ onClick={handleImportPlugin()}
>
Upload
@@ -612,36 +367,7 @@ export default function PluginsPage() {
{
- if (!imported) return;
- let proceed = true;
- if (!imported.ever_enabled) {
- proceed = await requireTrust(imported);
- }
- if (proceed) {
- const resp = await API.setPluginEnabled(
- imported.key,
- true
- );
- if (resp?.success) {
- setPlugins((prev) =>
- prev.map((p) =>
- p.key === imported.key
- ? { ...p, enabled: true, ever_enabled: true }
- : p
- )
- );
- notifications.show({
- title: imported.name,
- message: 'Plugin enabled',
- color: 'green',
- });
- }
- setImportOpen(false);
- setImported(null);
- setEnableAfterImport(false);
- }
- }}
+ onClick={handleEnablePlugin()}
>
Enable
@@ -727,33 +453,37 @@ export default function PluginsPage() {
size="xs"
color="red"
loading={deleting}
- onClick={async () => {
- if (!deleteTarget) return;
- setDeleting(true);
- try {
- const resp = await API.deletePlugin(deleteTarget.key);
- if (resp?.success) {
- setPlugins((prev) =>
- prev.filter((p) => p.key !== deleteTarget.key)
- );
- notifications.show({
- title: deleteTarget.name,
- message: 'Plugin deleted',
- color: 'green',
- });
- }
- setDeleteOpen(false);
- setDeleteTarget(null);
- } finally {
- setDeleting(false);
- }
- }}
+ onClick={handleDeletePlugin()}
>
Delete
-
+
+ {/* Confirmation modal */}
+
handleConfirm(false)}
+ title={confirmConfig.title}
+ centered
+ >
+
+ {confirmConfig.message}
+
+ handleConfirm(false)}
+ >
+ Cancel
+
+ handleConfirm(true)}>
+ Confirm
+
+
+
+
+
);
}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index e6d6378c..4ce519a3 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,1332 +1,166 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import API from '../api';
-import useSettingsStore from '../store/settings';
-import useUserAgentsStore from '../store/userAgents';
-import useStreamProfilesStore from '../store/streamProfiles';
+import React, { Suspense, useState } from 'react';
import {
Accordion,
- Alert,
+ AccordionControl,
+ AccordionItem,
+ AccordionPanel,
Box,
- Button,
Center,
- Flex,
- Group,
- FileInput,
- MultiSelect,
- Select,
- Stack,
- Switch,
Text,
- TextInput,
- NumberInput,
+ Loader
} from '@mantine/core';
-import { isNotEmpty, useForm } from '@mantine/form';
-import { notifications } from '@mantine/notifications';
-import UserAgentsTable from '../components/tables/UserAgentsTable';
-import StreamProfilesTable from '../components/tables/StreamProfilesTable';
-import useLocalStorage from '../hooks/useLocalStorage';
+const UserAgentsTable = React.lazy(() =>
+ import('../components/tables/UserAgentsTable.jsx'));
+const StreamProfilesTable = React.lazy(() =>
+ import('../components/tables/StreamProfilesTable.jsx'));
+const BackupManager = React.lazy(() =>
+ import('../components/backups/BackupManager.jsx'));
import useAuthStore from '../store/auth';
-import {
- USER_LEVELS,
- NETWORK_ACCESS_OPTIONS,
- PROXY_SETTINGS_OPTIONS,
- REGION_CHOICES,
-} from '../constants';
-import ConfirmationDialog from '../components/ConfirmationDialog';
-import useWarningsStore from '../store/warnings';
-
-const TIMEZONE_FALLBACKS = [
- 'UTC',
- 'America/New_York',
- 'America/Chicago',
- 'America/Denver',
- 'America/Los_Angeles',
- 'America/Phoenix',
- 'America/Anchorage',
- 'Pacific/Honolulu',
- 'Europe/London',
- 'Europe/Paris',
- 'Europe/Berlin',
- 'Europe/Madrid',
- 'Europe/Warsaw',
- 'Europe/Moscow',
- 'Asia/Dubai',
- 'Asia/Kolkata',
- 'Asia/Shanghai',
- 'Asia/Tokyo',
- 'Asia/Seoul',
- 'Australia/Sydney',
-];
-
-const getSupportedTimeZones = () => {
- try {
- if (typeof Intl.supportedValuesOf === 'function') {
- return Intl.supportedValuesOf('timeZone');
- }
- } catch (error) {
- console.warn('Unable to enumerate supported time zones:', error);
- }
- return TIMEZONE_FALLBACKS;
-};
-
-const getTimeZoneOffsetMinutes = (date, timeZone) => {
- try {
- const dtf = new Intl.DateTimeFormat('en-US', {
- timeZone,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hourCycle: 'h23',
- });
- const parts = dtf.formatToParts(date).reduce((acc, part) => {
- if (part.type !== 'literal') acc[part.type] = part.value;
- return acc;
- }, {});
- const asUTC = Date.UTC(
- Number(parts.year),
- Number(parts.month) - 1,
- Number(parts.day),
- Number(parts.hour),
- Number(parts.minute),
- Number(parts.second)
- );
- return (asUTC - date.getTime()) / 60000;
- } catch (error) {
- console.warn(`Failed to compute offset for ${timeZone}:`, error);
- return 0;
- }
-};
-
-const formatOffset = (minutes) => {
- const rounded = Math.round(minutes);
- const sign = rounded < 0 ? '-' : '+';
- const absolute = Math.abs(rounded);
- const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
- const mins = String(absolute % 60).padStart(2, '0');
- return `UTC${sign}${hours}:${mins}`;
-};
-
-const buildTimeZoneOptions = (preferredZone) => {
- const zones = getSupportedTimeZones();
- const referenceYear = new Date().getUTCFullYear();
- const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
- const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
-
- const options = zones
- .map((zone) => {
- const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
- const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
- const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
- const minOffset = Math.min(janOffset, julOffset);
- const maxOffset = Math.max(janOffset, julOffset);
- const usesDst = minOffset !== maxOffset;
- const labelParts = [`now ${formatOffset(currentOffset)}`];
- if (usesDst) {
- labelParts.push(
- `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
- );
- }
- return {
- value: zone,
- label: `${zone} (${labelParts.join(' | ')})`,
- numericOffset: minOffset,
- };
- })
- .sort((a, b) => {
- if (a.numericOffset !== b.numericOffset) {
- return a.numericOffset - b.numericOffset;
- }
- return a.value.localeCompare(b.value);
- });
- if (
- preferredZone &&
- !options.some((option) => option.value === preferredZone)
- ) {
- const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
- options.push({
- value: preferredZone,
- label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
- numericOffset: currentOffset,
- });
- options.sort((a, b) => {
- if (a.numericOffset !== b.numericOffset) {
- return a.numericOffset - b.numericOffset;
- }
- return a.value.localeCompare(b.value);
- });
- }
- return options;
-};
-
-const getDefaultTimeZone = () => {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
- } catch (error) {
- return 'UTC';
- }
-};
+import { USER_LEVELS } from '../constants';
+import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+const NetworkAccessForm = React.lazy(() =>
+ import('../components/forms/settings/NetworkAccessForm.jsx'));
+const ProxySettingsForm = React.lazy(() =>
+ import('../components/forms/settings/ProxySettingsForm.jsx'));
+const StreamSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/StreamSettingsForm.jsx'));
+const DvrSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/DvrSettingsForm.jsx'));
+const SystemSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/SystemSettingsForm.jsx'));
const SettingsPage = () => {
- const settings = useSettingsStore((s) => s.settings);
- const userAgents = useUserAgentsStore((s) => s.userAgents);
- const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
- const suppressWarning = useWarningsStore((s) => s.suppressWarning);
- const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [accordianValue, setAccordianValue] = useState(null);
- const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
- const [networkAccessError, setNetworkAccessError] = useState(null);
- const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] =
- useState(false);
- const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] =
- useState([]);
-
- const [proxySettingsSaved, setProxySettingsSaved] = useState(false);
- const [generalSettingsSaved, setGeneralSettingsSaved] = useState(false);
- const [rehashingStreams, setRehashingStreams] = useState(false);
- const [rehashSuccess, setRehashSuccess] = useState(false);
- const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false);
-
- // Add a new state to track the dialog type
- const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
-
- // Store pending changed settings when showing the dialog
- const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
- const [comskipFile, setComskipFile] = useState(null);
- const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
- const [comskipConfig, setComskipConfig] = useState({
- path: '',
- exists: false,
- });
-
- // UI / local storage settings
- const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
- const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
- const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
- const [timeZone, setTimeZone] = useLocalStorage(
- 'time-zone',
- getDefaultTimeZone()
- );
- const timeZoneOptions = useMemo(
- () => buildTimeZoneOptions(timeZone),
- [timeZone]
- );
- const timeZoneSyncedRef = useRef(false);
-
- const persistTimeZoneSetting = useCallback(
- async (tzValue) => {
- try {
- const existing = settings['system-time-zone'];
- if (existing && existing.id) {
- await API.updateSetting({ ...existing, value: tzValue });
- } else {
- await API.createSetting({
- key: 'system-time-zone',
- name: 'System Time Zone',
- value: tzValue,
- });
- }
- } catch (error) {
- console.error('Failed to persist time zone setting', error);
- notifications.show({
- title: 'Failed to update time zone',
- message: 'Could not save the selected time zone. Please try again.',
- color: 'red',
- });
- }
- },
- [settings]
- );
-
- const regionChoices = REGION_CHOICES;
-
- const form = useForm({
- mode: 'controlled',
- initialValues: {
- 'default-user-agent': '',
- 'default-stream-profile': '',
- 'preferred-region': '',
- 'auto-import-mapped-files': true,
- 'm3u-hash-key': [],
- 'dvr-tv-template': '',
- 'dvr-movie-template': '',
- 'dvr-tv-fallback-template': '',
- 'dvr-movie-fallback-template': '',
- 'dvr-comskip-enabled': false,
- 'dvr-comskip-custom-path': '',
- 'dvr-pre-offset-minutes': 0,
- 'dvr-post-offset-minutes': 0,
- },
-
- validate: {
- 'default-user-agent': isNotEmpty('Select a user agent'),
- 'default-stream-profile': isNotEmpty('Select a stream profile'),
- 'preferred-region': isNotEmpty('Select a region'),
- },
- });
-
- const networkAccessForm = useForm({
- mode: 'controlled',
- initialValues: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = '0.0.0.0/0,::0/0';
- return acc;
- }, {}),
- validate: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = (value) => {
- const cidrs = value.split(',');
- const ipv4CidrRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
- const ipv6CidrRegex = /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
- for (const cidr of cidrs) {
- if (cidr.match(ipv4CidrRegex) || cidr.match(ipv6CidrRegex)) {
- continue;
- }
-
- return 'Invalid CIDR range';
- }
-
- return null;
- };
- return acc;
- }, {}),
- });
-
- const proxySettingsForm = useForm({
- mode: 'controlled',
- initialValues: Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => {
- acc[key] = '';
- return acc;
- }, {}),
- });
-
- useEffect(() => {
- if (settings) {
- const formValues = Object.entries(settings).reduce(
- (acc, [key, value]) => {
- // Modify each value based on its own properties
- switch (value.value) {
- case 'true':
- value.value = true;
- break;
- case 'false':
- value.value = false;
- break;
- }
-
- let val = null;
- switch (key) {
- case 'm3u-hash-key':
- // Split comma-separated string, filter out empty strings
- val = value.value ? value.value.split(',').filter((v) => v) : [];
- break;
- case 'dvr-pre-offset-minutes':
- case 'dvr-post-offset-minutes':
- val = Number.parseInt(value.value || '0', 10);
- if (Number.isNaN(val)) val = 0;
- break;
- default:
- val = value.value;
- break;
- }
-
- acc[key] = val;
- return acc;
- },
- {}
- );
-
- form.setValues(formValues);
- if (formValues['dvr-comskip-custom-path']) {
- setComskipConfig((prev) => ({
- path: formValues['dvr-comskip-custom-path'],
- exists: prev.exists,
- }));
- }
-
- const networkAccessSettings = JSON.parse(
- settings['network-access'].value || '{}'
- );
- networkAccessForm.setValues(
- Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::0/0';
- return acc;
- }, {})
- );
-
- if (settings['proxy-settings']?.value) {
- try {
- const proxySettings = JSON.parse(settings['proxy-settings'].value);
- proxySettingsForm.setValues(proxySettings);
- } catch (error) {
- console.error('Error parsing proxy settings:', error);
- }
- }
-
- const tzSetting = settings['system-time-zone'];
- if (tzSetting?.value) {
- timeZoneSyncedRef.current = true;
- setTimeZone((prev) =>
- prev === tzSetting.value ? prev : tzSetting.value
- );
- } else if (!timeZoneSyncedRef.current && timeZone) {
- timeZoneSyncedRef.current = true;
- persistTimeZoneSetting(timeZone);
- }
- }
- }, [settings, timeZone, setTimeZone, persistTimeZoneSetting]);
-
- useEffect(() => {
- const loadComskipConfig = async () => {
- try {
- const response = await API.getComskipConfig();
- if (response) {
- setComskipConfig({
- path: response.path || '',
- exists: Boolean(response.exists),
- });
- if (response.path) {
- form.setFieldValue('dvr-comskip-custom-path', response.path);
- }
- }
- } catch (error) {
- console.error('Failed to load comskip config', error);
- }
- };
- loadComskipConfig();
- }, []);
-
- // Clear success states when switching accordion panels
- useEffect(() => {
- setGeneralSettingsSaved(false);
- setProxySettingsSaved(false);
- setNetworkAccessSaved(false);
- setRehashSuccess(false);
- }, [accordianValue]);
-
- const onSubmit = async () => {
- setGeneralSettingsSaved(false);
-
- const values = form.getValues();
- const changedSettings = {};
- let m3uHashKeyChanged = false;
-
- for (const settingKey in values) {
- // Only compare against existing value if the setting exists
- const existing = settings[settingKey];
-
- // Convert array values (like m3u-hash-key) to comma-separated strings
- let stringValue;
- if (Array.isArray(values[settingKey])) {
- stringValue = values[settingKey].join(',');
- } else {
- stringValue = `${values[settingKey]}`;
- }
-
- // Skip empty values to avoid validation errors
- if (!stringValue) {
- continue;
- }
-
- if (!existing) {
- // Create new setting on save
- changedSettings[settingKey] = stringValue;
- } else if (stringValue !== String(existing.value)) {
- // If the user changed the setting's value from what's in the DB:
- changedSettings[settingKey] = stringValue;
-
- // Check if M3U hash key was changed
- if (settingKey === 'm3u-hash-key') {
- m3uHashKeyChanged = true;
- }
- }
- }
-
- // If M3U hash key changed, show warning (unless suppressed)
- if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
- // Store the changed settings before showing dialog
- setPendingChangedSettings(changedSettings);
- setRehashDialogType('save'); // Set dialog type to save
- setRehashConfirmOpen(true);
- return;
- }
-
- // Update each changed setting in the backend (create if missing)
- try {
- for (const updatedKey in changedSettings) {
- const existing = settings[updatedKey];
- if (existing && existing.id) {
- const result = await API.updateSetting({
- ...existing,
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to update setting');
- }
- } else {
- const result = await API.createSetting({
- key: updatedKey,
- name: updatedKey.replace(/-/g, ' '),
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to create setting');
- }
- }
- }
-
- setGeneralSettingsSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- }
- };
-
- const onNetworkAccessSubmit = async () => {
- setNetworkAccessSaved(false);
- setNetworkAccessError(null);
- const check = await API.checkSetting({
- ...settings['network-access'],
- value: JSON.stringify(networkAccessForm.getValues()),
- });
-
- if (check.error && check.message) {
- setNetworkAccessError(`${check.message}: ${check.data}`);
- return;
- }
-
- // For now, only warn if we're blocking the UI
- const blockedAccess = check.UI;
- if (blockedAccess.length == 0) {
- return saveNetworkAccess();
- }
-
- setNetNetworkAccessConfirmCIDRs(blockedAccess);
- setNetworkAccessConfirmOpen(true);
- };
-
- const onProxySettingsSubmit = async () => {
- setProxySettingsSaved(false);
-
- try {
- const result = await API.updateSetting({
- ...settings['proxy-settings'],
- value: JSON.stringify(proxySettingsForm.getValues()),
- });
- // API functions return undefined on error
- if (result) {
- setProxySettingsSaved(true);
- }
- } catch (error) {
- // Error notifications are already shown by API functions
- console.error('Error saving proxy settings:', error);
- }
- };
-
- const onComskipUpload = async () => {
- if (!comskipFile) {
- return;
- }
-
- setComskipUploadLoading(true);
- try {
- const response = await API.uploadComskipIni(comskipFile);
- if (response?.path) {
- notifications.show({
- title: 'comskip.ini uploaded',
- message: response.path,
- autoClose: 3000,
- color: 'green',
- });
- form.setFieldValue('dvr-comskip-custom-path', response.path);
- useSettingsStore.getState().updateSetting({
- ...(settings['dvr-comskip-custom-path'] || {
- key: 'dvr-comskip-custom-path',
- name: 'DVR Comskip Custom Path',
- }),
- value: response.path,
- });
- setComskipConfig({ path: response.path, exists: true });
- }
- } catch (error) {
- console.error('Failed to upload comskip.ini', error);
- } finally {
- setComskipUploadLoading(false);
- setComskipFile(null);
- }
- };
-
- const resetProxySettingsToDefaults = () => {
- const defaultValues = {
- buffering_timeout: 15,
- buffering_speed: 1.0,
- redis_chunk_ttl: 60,
- channel_shutdown_delay: 0,
- channel_init_grace_period: 5,
- };
-
- proxySettingsForm.setValues(defaultValues);
- };
-
- const saveNetworkAccess = async () => {
- setNetworkAccessSaved(false);
- try {
- await API.updateSetting({
- ...settings['network-access'],
- value: JSON.stringify(networkAccessForm.getValues()),
- });
- setNetworkAccessSaved(true);
- setNetworkAccessConfirmOpen(false);
- } catch (e) {
- const errors = {};
- for (const key in e.body.value) {
- errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`;
- }
- networkAccessForm.setErrors(errors);
- }
- };
-
- const onUISettingsChange = (name, value) => {
- switch (name) {
- case 'table-size':
- if (value) setTableSize(value);
- break;
- case 'time-format':
- if (value) setTimeFormat(value);
- break;
- case 'date-format':
- if (value) setDateFormat(value);
- break;
- case 'time-zone':
- if (value) {
- setTimeZone(value);
- persistTimeZoneSetting(value);
- }
- break;
- }
- };
-
- const executeSettingsSaveAndRehash = async () => {
- setRehashConfirmOpen(false);
- setGeneralSettingsSaved(false);
-
- // Use the stored pending values that were captured before the dialog was shown
- const changedSettings = pendingChangedSettings || {};
-
- // Update each changed setting in the backend (create if missing)
- try {
- for (const updatedKey in changedSettings) {
- const existing = settings[updatedKey];
- if (existing && existing.id) {
- const result = await API.updateSetting({
- ...existing,
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to update setting');
- }
- } else {
- const result = await API.createSetting({
- key: updatedKey,
- name: updatedKey.replace(/-/g, ' '),
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to create setting');
- }
- }
- }
-
- // Clear the pending values
- setPendingChangedSettings(null);
- setGeneralSettingsSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- setPendingChangedSettings(null);
- }
- };
-
- const executeRehashStreamsOnly = async () => {
- setRehashingStreams(true);
- setRehashSuccess(false);
- setRehashConfirmOpen(false);
-
- try {
- await API.rehashStreams();
- setRehashSuccess(true);
- setTimeout(() => setRehashSuccess(false), 5000);
- } catch (error) {
- console.error('Error rehashing streams:', error);
- } finally {
- setRehashingStreams(false);
- }
- };
-
- const onRehashStreams = async () => {
- // Skip warning if it's been suppressed
- if (isWarningSuppressed('rehash-streams')) {
- return executeRehashStreamsOnly();
- }
-
- setRehashDialogType('rehash'); // Set dialog type to rehash
- setRehashConfirmOpen(true);
- };
-
- // Create a function to handle the confirmation based on dialog type
- const handleRehashConfirm = () => {
- if (rehashDialogType === 'save') {
- executeSettingsSaveAndRehash();
- } else {
- executeRehashStreamsOnly();
- }
- };
return (
-
-
+
+
-
- UI Settings
-
-
-
+
+ UI Settings
+
+
+
+
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
-
- DVR
-
-
-
-
-
- Stream Settings
-
-
-
-
+
+ Stream Profiles
+
+
+ }>
+
+
+
+
+
-
- User-Agents
-
-
-
-
-
-
- Stream Profiles
-
-
-
-
-
-
-
+
+
Network Access
- {accordianValue == 'network-access' && (
+ {accordianValue === 'network-access' && (
Comma-Delimited CIDR ranges
)}
-
-
-
-
-
-
-
-
+
+
Proxy Settings
-
-
-
-
-
+
+ Backup & Restore
+
+
+ }>
+
+
+
+
+
>
)}
-
- {
- setRehashConfirmOpen(false);
- setRehashDialogType(null);
- // Clear pending values when dialog is cancelled
- setPendingChangedSettings(null);
- }}
- onConfirm={handleRehashConfirm}
- title={
- rehashDialogType === 'save'
- ? 'Save Settings and Rehash Streams'
- : 'Confirm Stream Rehash'
- }
- message={
-
- {`Are you sure you want to rehash all streams?
-
-This process may take a while depending on the number of streams.
-Do not shut down Dispatcharr until the rehashing is complete.
-M3U refreshes will be blocked until this process finishes.
-
-Please ensure you have time to let this complete before proceeding.`}
-
- }
- confirmLabel={
- rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
- }
- cancelLabel="Cancel"
- actionKey="rehash-streams"
- onSuppressChange={suppressWarning}
- size="md"
- />
-
- setNetworkAccessConfirmOpen(false)}
- onConfirm={saveNetworkAccess}
- title={`Confirm Network Access Blocks`}
- message={
- <>
-
- Your client is not included in the allowed networks for the web
- UI. Are you sure you want to proceed?
-
-
-
- {netNetworkAccessConfirmCIDRs.map((cidr) => (
- - {cidr}
- ))}
-
- >
- }
- confirmLabel="Save"
- cancelLabel="Cancel"
- size="md"
- />
);
};
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index 52c31656..19702ae6 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -1,1276 +1,83 @@
-import React, { useMemo, useState, useEffect, useCallback } from 'react';
-import {
- ActionIcon,
- Box,
- Button,
- Card,
- Center,
- Container,
- Flex,
- Group,
- Progress,
- SimpleGrid,
- Stack,
- Text,
- Title,
- Tooltip,
- Select,
- Badge,
- NumberInput,
-} from '@mantine/core';
-import { TableHelper } from '../helpers';
-import API from '../api';
+import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { Box, Button, Group, LoadingOverlay, NumberInput, Text, Title, } from '@mantine/core';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
-import logo from '../images/logo.png';
-import {
- ChevronDown,
- Gauge,
- HardDriveDownload,
- HardDriveUpload,
- SquareX,
- Timer,
- Users,
- Video,
-} from 'lucide-react';
-import dayjs from 'dayjs';
-import duration from 'dayjs/plugin/duration';
-import relativeTime from 'dayjs/plugin/relativeTime';
-import { Sparkline } from '@mantine/charts';
import useStreamProfilesStore from '../store/streamProfiles';
-import usePlaylistsStore from '../store/playlists'; // Add this import
-import useSettingsStore from '../store/settings';
-import { useLocation } from 'react-router-dom';
-import { notifications } from '@mantine/notifications';
-import { CustomTable, useTable } from '../components/tables/CustomTable';
import useLocalStorage from '../hooks/useLocalStorage';
-
-dayjs.extend(duration);
-dayjs.extend(relativeTime);
-
-function formatBytes(bytes) {
- if (bytes === 0) return '0 Bytes';
-
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
-
- return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
-}
-
-function formatSpeed(bytes) {
- if (bytes === 0) return '0 Bytes';
-
- const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
-
- return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
-}
-
-const getStartDate = (uptime) => {
- // Get the current date and time
- const currentDate = new Date();
- // Calculate the start date by subtracting uptime (in milliseconds)
- const startDate = new Date(currentDate.getTime() - uptime * 1000);
- // Format the date as a string (you can adjust the format as needed)
- return startDate.toLocaleString({
- weekday: 'short', // optional, adds day of the week
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: true, // 12-hour format with AM/PM
- });
-};
-
-// Create a VOD Card component similar to ChannelCard
-const VODCard = ({ vodContent }) => {
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
- const [isClientExpanded, setIsClientExpanded] = useState(false);
- const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
-
- // Get metadata from the VOD content
- const metadata = vodContent.content_metadata || {};
- const contentType = vodContent.content_type;
- const isMovie = contentType === 'movie';
- const isEpisode = contentType === 'episode';
-
- // Set up timer to update progress every second
- useEffect(() => {
- const interval = setInterval(() => {
- setUpdateTrigger((prev) => prev + 1);
- }, 1000);
-
- return () => clearInterval(interval);
- }, []);
-
- // Get the individual connection (since we now separate cards per connection)
- const connection =
- vodContent.individual_connection ||
- (vodContent.connections && vodContent.connections[0]);
-
- // Get poster/logo URL
- const posterUrl = metadata.logo_url || logo;
-
- // Format duration for content length
- const formatDuration = (seconds) => {
- if (!seconds) return 'Unknown';
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
- };
-
- // Get display title
- const getDisplayTitle = () => {
- if (isMovie) {
- return vodContent.content_name;
- } else if (isEpisode) {
- const season = metadata.season_number
- ? `S${metadata.season_number.toString().padStart(2, '0')}`
- : 'S??';
- const episode = metadata.episode_number
- ? `E${metadata.episode_number.toString().padStart(2, '0')}`
- : 'E??';
- return `${metadata.series_name} - ${season}${episode}`;
- }
- return vodContent.content_name;
- };
-
- // Get subtitle info
- const getSubtitle = () => {
- if (isMovie) {
- const parts = [];
- if (metadata.genre) parts.push(metadata.genre);
- // We'll handle rating separately as a badge now
- return parts;
- } else if (isEpisode) {
- return [metadata.episode_name || 'Episode'];
- }
- return [];
- };
-
- // Render subtitle
- const renderSubtitle = () => {
- const subtitleParts = getSubtitle();
- if (subtitleParts.length === 0) return null;
-
- return (
-
- {subtitleParts.join(' • ')}
-
- );
- };
-
- // Calculate progress percentage and time
- const calculateProgress = useCallback(() => {
- if (!connection || !metadata.duration_secs) {
- return {
- percentage: 0,
- currentTime: 0,
- totalTime: metadata.duration_secs || 0,
- };
- }
-
- const totalSeconds = metadata.duration_secs;
- let percentage = 0;
- let currentTime = 0;
- const now = Date.now() / 1000; // Current time in seconds
-
- // Priority 1: Use last_seek_percentage if available (most accurate from range requests)
- if (
- connection.last_seek_percentage &&
- connection.last_seek_percentage > 0 &&
- connection.last_seek_timestamp
- ) {
- // Calculate the position at the time of seek
- const seekPosition = Math.round(
- (connection.last_seek_percentage / 100) * totalSeconds
- );
-
- // Add elapsed time since the seek
- const elapsedSinceSeek = now - connection.last_seek_timestamp;
- currentTime = seekPosition + Math.floor(elapsedSinceSeek);
-
- // Don't exceed the total duration
- currentTime = Math.min(currentTime, totalSeconds);
-
- percentage = (currentTime / totalSeconds) * 100;
- }
- // Priority 2: Use position_seconds if available
- else if (connection.position_seconds && connection.position_seconds > 0) {
- currentTime = connection.position_seconds;
- percentage = (currentTime / totalSeconds) * 100;
- }
-
- return {
- percentage: Math.min(percentage, 100), // Cap at 100%
- currentTime: Math.max(0, currentTime), // Don't go negative
- totalTime: totalSeconds,
- };
- }, [connection, metadata.duration_secs]);
-
- // Format time for display (e.g., "1:23:45" or "23:45")
- const formatTime = (seconds) => {
- if (!seconds || seconds === 0) return '0:00';
-
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
- } else {
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
- }
- };
-
- // Calculate duration for connection
- const calculateConnectionDuration = useCallback((connection) => {
- // If duration is provided by API, use it
- if (connection.duration && connection.duration > 0) {
- return dayjs.duration(connection.duration, 'seconds').humanize();
- }
-
- // Fallback: try to extract from client_id timestamp
- if (connection.client_id && connection.client_id.startsWith('vod_')) {
- try {
- const parts = connection.client_id.split('_');
- if (parts.length >= 2) {
- const clientStartTime = parseInt(parts[1]) / 1000; // Convert ms to seconds
- const currentTime = Date.now() / 1000;
- const duration = currentTime - clientStartTime;
- return dayjs.duration(duration, 'seconds').humanize();
- }
- } catch {
- // Ignore parsing errors
- }
- }
-
- return 'Unknown duration';
- }, []);
-
- // Get connection start time for tooltip
- const getConnectionStartTime = useCallback(
- (connection) => {
- if (connection.connected_at) {
- return dayjs(connection.connected_at * 1000).format(
- `${dateFormat} HH:mm:ss`
- );
- }
-
- // Fallback: calculate from client_id timestamp
- if (connection.client_id && connection.client_id.startsWith('vod_')) {
- try {
- const parts = connection.client_id.split('_');
- if (parts.length >= 2) {
- const clientStartTime = parseInt(parts[1]);
- return dayjs(clientStartTime).format(`${dateFormat} HH:mm:ss`);
- }
- } catch {
- // Ignore parsing errors
- }
- }
-
- return 'Unknown';
- },
- [dateFormat]
- );
-
- return (
-
-
- {/* Header with poster and basic info */}
-
-
-
-
-
-
- {connection && (
-
-
-
- {calculateConnectionDuration(connection)}
-
-
- )}
-
-
-
- {/* Title and type */}
-
-
- {getDisplayTitle()}
-
-
-
-
-
- {isMovie ? 'Movie' : 'TV Episode'}
-
-
-
-
- {/* Display M3U profile information - matching channel card style */}
- {connection &&
- connection.m3u_profile &&
- (connection.m3u_profile.profile_name ||
- connection.m3u_profile.account_name) && (
-
-
-
-
-
-
- {connection.m3u_profile.account_name || 'Unknown Account'}
-
-
-
-
- {connection.m3u_profile.profile_name || 'Default Profile'}
-
-
-
-
-
- )}
-
- {/* Subtitle/episode info */}
- {getSubtitle().length > 0 && (
-
- {renderSubtitle()}
-
- )}
-
- {/* Content information badges - streamlined to avoid duplication */}
-
- {metadata.year && (
-
-
- {metadata.year}
-
-
- )}
-
- {metadata.duration_secs && (
-
-
- {formatDuration(metadata.duration_secs)}
-
-
- )}
-
- {metadata.rating && (
-
-
- {parseFloat(metadata.rating).toFixed(1)}/10
-
-
- )}
-
-
- {/* Progress bar - show current position in content */}
- {connection &&
- metadata.duration_secs &&
- (() => {
- const progress = calculateProgress();
- return progress.totalTime > 0 ? (
-
-
-
- Progress
-
-
- {formatTime(progress.currentTime)} /{' '}
- {formatTime(progress.totalTime)}
-
-
-
-
- {progress.percentage.toFixed(1)}% watched
-
-
- ) : null;
- })()}
-
- {/* Client information section - collapsible like channel cards */}
- {connection && (
-
- {/* Client summary header - always visible */}
- setIsClientExpanded(!isClientExpanded)}
- >
-
-
- Client:
-
-
- {connection.client_ip || 'Unknown IP'}
-
-
-
-
-
- {isClientExpanded ? 'Hide Details' : 'Show Details'}
-
-
-
-
-
- {/* Expanded client details */}
- {isClientExpanded && (
-
- {connection.user_agent &&
- connection.user_agent !== 'Unknown' && (
-
-
- User Agent:
-
-
- {connection.user_agent.length > 100
- ? `${connection.user_agent.substring(0, 100)}...`
- : connection.user_agent}
-
-
- )}
-
-
-
- Client ID:
-
-
- {connection.client_id || 'Unknown'}
-
-
-
- {connection.connected_at && (
-
-
- Connected:
-
- {getConnectionStartTime(connection)}
-
- )}
-
- {connection.duration && connection.duration > 0 && (
-
-
- Watch Duration:
-
-
- {dayjs
- .duration(connection.duration, 'seconds')
- .humanize()}
-
-
- )}
-
- {/* Seek/Position Information */}
- {(connection.last_seek_percentage > 0 ||
- connection.last_seek_byte > 0) && (
- <>
-
-
- Last Seek:
-
-
- {connection.last_seek_percentage?.toFixed(1)}%
- {connection.total_content_size > 0 && (
-
- {' '}
- (
- {Math.round(
- connection.last_seek_byte / (1024 * 1024)
- )}
- MB /{' '}
- {Math.round(
- connection.total_content_size / (1024 * 1024)
- )}
- MB)
-
- )}
-
-
-
- {Number(connection.last_seek_timestamp) > 0 && (
-
-
- Seek Time:
-
-
- {dayjs
- .unix(Number(connection.last_seek_timestamp))
- .fromNow()}
-
-
- )}
- >
- )}
-
- {connection.bytes_sent > 0 && (
-
-
- Data Sent:
-
-
- {(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
-
-
- )}
-
- )}
-
- )}
-
-
- );
-};
-
-// Create a separate component for each channel card to properly handle the hook
-const ChannelCard = ({
- channel,
- clients,
- stopClient,
+import SystemEvents from '../components/SystemEvents';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ getStatsByChannelId,
+ getVODStats,
stopChannel,
- logos,
+ stopClient,
+ stopVODClient,
+} from '../utils/pages/StatsUtils.js';
+const VodConnectionCard = React.lazy(() =>
+ import('../components/cards/VodConnectionCard.jsx'));
+const StreamConnectionCard = React.lazy(() =>
+ import('../components/cards/StreamConnectionCard.jsx'));
+
+const Connections = ({
+ combinedConnections,
+ clients,
channelsByUUID,
+ handleStopVODClient,
}) => {
- const location = useLocation();
- const [availableStreams, setAvailableStreams] = useState([]);
- const [isLoadingStreams, setIsLoadingStreams] = useState(false);
- const [activeStreamId, setActiveStreamId] = useState(null);
- const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
- const [data, setData] = useState([]);
- const [previewedStream, setPreviewedStream] = useState(null);
+ const logos = useLogosStore((s) => s.logos);
- // Get Date-format from localStorage
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
- // Get M3U account data from the playlists store
- const m3uAccounts = usePlaylistsStore((s) => s.playlists);
- const [tableSize] = useLocalStorage('table-size', 'default');
- // Get settings for speed threshold
- const settings = useSettingsStore((s) => s.settings);
-
- // Parse proxy settings to get buffering_speed
- const getBufferingSpeedThreshold = () => {
- try {
- if (settings['proxy-settings']?.value) {
- const proxySettings = JSON.parse(settings['proxy-settings'].value);
- return parseFloat(proxySettings.buffering_speed) || 1.0;
- }
- } catch (error) {
- console.error('Error parsing proxy settings:', error);
- }
- return 1.0; // Default fallback
- };
-
- // Create a map of M3U account IDs to names for quick lookup
- const m3uAccountsMap = useMemo(() => {
- const map = {};
- if (m3uAccounts && Array.isArray(m3uAccounts)) {
- m3uAccounts.forEach((account) => {
- if (account.id) {
- map[account.id] = account.name;
- }
- });
- }
- return map;
- }, [m3uAccounts]);
-
- // Update M3U profile information when channel data changes
- useEffect(() => {
- // If the channel data includes M3U profile information, update our state
- if (channel.m3u_profile || channel.m3u_profile_name) {
- setCurrentM3UProfile({
- name:
- channel.m3u_profile?.name ||
- channel.m3u_profile_name ||
- 'Default M3U',
- });
- }
- }, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]);
-
- // Fetch available streams for this channel
- useEffect(() => {
- const fetchStreams = async () => {
- setIsLoadingStreams(true);
- try {
- // Get channel ID from UUID
- const channelId = channelsByUUID[channel.channel_id];
- if (channelId) {
- const streamData = await API.getChannelStreams(channelId);
-
- // Use streams in the order returned by the API without sorting
- setAvailableStreams(streamData);
-
- // If we have a channel URL, try to find the matching stream
- if (channel.url && streamData.length > 0) {
- // Try to find matching stream based on URL
- const matchingStream = streamData.find(
- (stream) =>
- channel.url.includes(stream.url) ||
- stream.url.includes(channel.url)
- );
-
- if (matchingStream) {
- setActiveStreamId(matchingStream.id.toString());
-
- // If the stream has M3U profile info, save it
- if (matchingStream.m3u_profile) {
- setCurrentM3UProfile(matchingStream.m3u_profile);
- }
- }
- }
- }
- } catch (error) {
- console.error('Error fetching streams:', error);
- } finally {
- setIsLoadingStreams(false);
- }
- };
-
- fetchStreams();
- }, [channel.channel_id, channel.url, channelsByUUID]);
-
- useEffect(() => {
- setData(
- clients
- .filter((client) => client.channel.channel_id === channel.channel_id)
- .map((client) => ({
- id: client.client_id,
- ...client,
- }))
- );
- }, [clients, channel.channel_id]);
-
- const renderHeaderCell = (header) => {
- switch (header.id) {
- default:
- return (
-
-
- {header.column.columnDef.header}
-
-
- );
- }
- };
-
- const renderBodyCell = ({ cell, row }) => {
- switch (cell.column.id) {
- case 'actions':
- return (
-
-
-
-
- stopClient(
- row.original.channel.uuid,
- row.original.client_id
- )
- }
- >
-
-
-
-
-
- );
- }
- };
-
- // Handle stream switching
- const handleStreamChange = async (streamId) => {
- try {
- console.log('Switching to stream ID:', streamId);
- // Find the selected stream in availableStreams for debugging
- const selectedStream = availableStreams.find(
- (s) => s.id.toString() === streamId
- );
- console.log('Selected stream details:', selectedStream);
-
- // Make sure we're passing the correct ID to the API
- const response = await API.switchStream(channel.channel_id, streamId);
- console.log('Stream switch API response:', response);
-
- // Update the local active stream ID immediately
- setActiveStreamId(streamId);
-
- // Update M3U profile information if available in the response
- if (response && response.m3u_profile) {
- setCurrentM3UProfile(response.m3u_profile);
- } else if (selectedStream && selectedStream.m3u_profile) {
- // Fallback to the profile from the selected stream
- setCurrentM3UProfile(selectedStream.m3u_profile);
- }
-
- // Show detailed notification with stream name
- notifications.show({
- title: 'Stream switching',
- message: `Switching to "${selectedStream?.name}" for ${channel.name}`,
- color: 'blue.5',
- });
-
- // After a short delay, fetch streams again to confirm the switch
- setTimeout(async () => {
- try {
- const channelId = channelsByUUID[channel.channel_id];
- if (channelId) {
- const updatedStreamData = await API.getChannelStreams(channelId);
- console.log('Channel streams after switch:', updatedStreamData);
-
- // Update current stream information with fresh data
- const updatedStream = updatedStreamData.find(
- (s) => s.id.toString() === streamId
- );
- if (updatedStream && updatedStream.m3u_profile) {
- setCurrentM3UProfile(updatedStream.m3u_profile);
- }
- }
- } catch (error) {
- console.error('Error checking streams after switch:', error);
- }
- }, 2000);
- } catch (error) {
- console.error('Stream switch error:', error);
- notifications.show({
- title: 'Error switching stream',
- message: error.toString(),
- color: 'red.5',
- });
- }
- };
- console.log(data);
-
- const clientsColumns = useMemo(
- () => [
- {
- id: 'expand',
- size: 20,
- },
- {
- header: 'IP Address',
- accessorKey: 'ip_address',
- },
- // Updated Connected column with tooltip
- {
- id: 'connected',
- header: 'Connected',
- accessorFn: (row) => {
- // Check for connected_since (which is seconds since connection)
- if (row.connected_since) {
- // Calculate the actual connection time by subtracting the seconds from current time
- const currentTime = dayjs();
- const connectedTime = currentTime.subtract(
- row.connected_since,
- 'second'
- );
- return connectedTime.format(`${dateFormat} HH:mm:ss`);
- }
-
- // Fallback to connected_at if it exists
- if (row.connected_at) {
- const connectedTime = dayjs(row.connected_at * 1000);
- return connectedTime.format(`${dateFormat} HH:mm:ss`);
- }
-
- return 'Unknown';
- },
- cell: ({ cell }) => (
-
- {cell.getValue()}
-
- ),
- },
- // Update Duration column with tooltip showing exact seconds
- {
- id: 'duration',
- header: 'Duration',
- accessorFn: (row) => {
- if (row.connected_since) {
- return dayjs.duration(row.connected_since, 'seconds').humanize();
- }
-
- if (row.connection_duration) {
- return dayjs
- .duration(row.connection_duration, 'seconds')
- .humanize();
- }
-
- return '-';
- },
- cell: ({ cell, row }) => {
- const exactDuration =
- row.original.connected_since || row.original.connection_duration;
- return (
-
- {cell.getValue()}
-
- );
- },
- },
- {
- id: 'actions',
- header: 'Actions',
- size: tableSize == 'compact' ? 75 : 100,
- },
- ],
- []
- );
-
- // This hook is now at the top level of this component
- const channelClientsTable = useTable({
- ...TableHelper.defaultProperties,
- columns: clientsColumns,
- data,
- allRowIds: data.map((client) => client.id),
- tableCellProps: () => ({
- padding: 4,
- borderColor: '#444',
- color: '#E0E0E0',
- fontSize: '0.85rem',
- }),
- headerCellRenderFns: {
- ip_address: renderHeaderCell,
- connected: renderHeaderCell,
- duration: renderHeaderCell,
- actions: renderHeaderCell,
- },
- bodyCellRenderFns: {
- actions: renderBodyCell,
- },
- getExpandedRowHeight: (row) => {
- return 20 + 28 * row.original.streams.length;
- },
- expandedRowRenderer: ({ row }) => {
- return (
-
-
-
- User Agent:
-
- {row.original.user_agent || 'Unknown'}
-
-
- );
- },
- mantineExpandButtonProps: ({ row, table }) => ({
- size: 'xs',
- style: {
- transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
- transition: 'transform 0.2s',
- },
- }),
- displayColumnDefOptions: {
- 'mrt-row-expand': {
- size: 15,
- header: '',
- },
- 'mrt-row-actions': {
- size: 74,
- },
- },
- });
-
- // Get logo URL from the logos object if available
- const logoUrl =
- (channel.logo_id && logos && logos[channel.logo_id]
- ? logos[channel.logo_id].cache_url
- : null) ||
- (previewedStream && previewedStream.logo_url) ||
- null;
-
- useEffect(() => {
- let isMounted = true;
- // Only fetch if we have a stream_id and NO channel.name
- if (!channel.name && channel.stream_id) {
- API.getStreamsByIds([channel.stream_id]).then((streams) => {
- if (isMounted && streams && streams.length > 0) {
- setPreviewedStream(streams[0]);
- }
- });
- }
- return () => {
- isMounted = false;
- };
- }, [channel.name, channel.stream_id]);
-
- const channelName =
- channel.name || previewedStream?.name || 'Unnamed Channel';
- const uptime = channel.uptime || 0;
- const bitrates = channel.bitrates || [];
- const totalBytes = channel.total_bytes || 0;
- const clientCount = channel.client_count || 0;
- const avgBitrate = channel.avg_bitrate || '0 Kbps';
- const streamProfileName = channel.stream_profile?.name || 'Unknown Profile';
-
- // Use currentM3UProfile if available, otherwise fall back to channel data
- const m3uProfileName =
- currentM3UProfile?.name ||
- channel.m3u_profile?.name ||
- channel.m3u_profile_name ||
- 'Unknown M3U Profile';
-
- // Create select options for available streams
- const streamOptions = availableStreams.map((stream) => {
- // Get account name from our mapping if it exists
- const accountName =
- stream.m3u_account && m3uAccountsMap[stream.m3u_account]
- ? m3uAccountsMap[stream.m3u_account]
- : stream.m3u_account
- ? `M3U #${stream.m3u_account}`
- : 'Unknown M3U';
-
- return {
- value: stream.id.toString(),
- label: `${stream.name || `Stream #${stream.id}`} [${accountName}]`,
- };
- });
-
- if (location.pathname != '/stats') {
- return <>>;
- }
-
- // Safety check - if channel doesn't have required data, don't render
- if (!channel || !channel.channel_id) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {dayjs.duration(uptime, 'seconds').humanize()}
-
-
-
-
-
- stopChannel(channel.channel_id)}
- >
-
-
-
-
-
-
-
-
-
- {channelName}
-
-
-
-
-
- {streamProfileName}
-
-
-
-
- {/* Display M3U profile information */}
-
-
-
-
- {m3uProfileName}
-
-
-
-
- {/* Add stream selection dropdown */}
- {availableStreams.length > 0 && (
-
-
-
- )}
-
- {/* Add stream information badges */}
-
- {channel.resolution && (
-
-
- {channel.resolution}
-
-
- )}
- {channel.source_fps && (
-
-
- {channel.source_fps} FPS
-
-
- )}
- {channel.video_codec && (
-
-
- {channel.video_codec.toUpperCase()}
-
-
- )}
- {channel.audio_codec && (
-
-
- {channel.audio_codec.toUpperCase()}
-
-
- )}
- {channel.audio_channels && (
-
-
- {channel.audio_channels}
-
-
- )}
- {channel.stream_type && (
-
-
- {channel.stream_type.toUpperCase()}
-
-
- )}
- {channel.ffmpeg_speed && (
-
- =
- getBufferingSpeedThreshold()
- ? 'green'
- : 'red'
- }
- >
- {parseFloat(channel.ffmpeg_speed).toFixed(2)}x
-
-
- )}
-
-
-
-
-
-
-
- {formatSpeed(bitrates.at(-1) || 0)}
-
-
-
-
-
-
- Avg: {avgBitrate}
-
-
-
-
-
-
-
- {formatBytes(totalBytes)}
-
-
-
-
-
-
-
-
- {clientCount}
-
-
-
-
-
-
-
-
+
+ No active connections
+
+
+ ) : (
+
+ }>
+ {combinedConnections.map((connection) => {
+ if (connection.type === 'stream') {
+ return (
+
+ );
+ } else if (connection.type === 'vod') {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
);
};
-const ChannelsPage = () => {
+const StatsPage = () => {
const channels = useChannelsStore((s) => s.channels);
const channelsByUUID = useChannelsStore((s) => s.channelsByUUID);
const channelStats = useChannelsStore((s) => s.stats);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
- const logos = useLogosStore((s) => s.logos);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const [clients, setClients] = useState([]);
@@ -1284,19 +91,22 @@ const ChannelsPage = () => {
5
);
const refreshInterval = refreshIntervalSeconds * 1000; // Convert to milliseconds
+ const channelHistoryLength = Object.keys(channelHistory).length;
+ const vodConnectionsCount = vodConnections.reduce(
+ (total, vodContent) => total + (vodContent.connections?.length || 0),
+ 0
+ );
- const stopChannel = async (id) => {
- await API.stopChannel(id);
- };
-
- const stopClient = async (channelId, clientId) => {
- await API.stopClient(channelId, clientId);
+ const handleStopVODClient = async (clientId) => {
+ await stopVODClient(clientId);
+ // Refresh VOD stats after stopping to update the UI
+ fetchVODStats();
};
// Function to fetch channel stats from API
const fetchChannelStats = useCallback(async () => {
try {
- const response = await API.fetchActiveChannelStats();
+ const response = await fetchActiveChannelStats();
if (response) {
setChannelStats(response);
} else {
@@ -1314,7 +124,7 @@ const ChannelsPage = () => {
const fetchVODStats = useCallback(async () => {
try {
- const response = await API.getVODStats();
+ const response = await getVODStats();
if (response) {
setVodConnections(response.vod_connections || []);
} else {
@@ -1381,210 +191,112 @@ const ChannelsPage = () => {
// Use functional update to access previous state without dependency
setChannelHistory((prevChannelHistory) => {
// Create a completely new object based only on current channel stats
- const stats = {};
- const newChannelHistory = {}; // Start fresh instead of preserving old channels
-
- channelStats.channels.forEach((ch) => {
- // Make sure we have a valid channel_id
- if (!ch.channel_id) {
- console.warn('Found channel without channel_id:', ch);
- return;
- }
-
- let bitrates = [];
- if (prevChannelHistory[ch.channel_id]) {
- bitrates = [...(prevChannelHistory[ch.channel_id].bitrates || [])];
- const bitrate =
- ch.total_bytes - prevChannelHistory[ch.channel_id].total_bytes;
- if (bitrate > 0) {
- bitrates.push(bitrate);
- }
-
- if (bitrates.length > 15) {
- bitrates = bitrates.slice(1);
- }
- }
-
- // Find corresponding channel data
- const channelData =
- channelsByUUID && ch.channel_id
- ? channels[channelsByUUID[ch.channel_id]]
- : null;
-
- // Find stream profile
- const streamProfile = streamProfiles.find(
- (profile) => profile.id == parseInt(ch.stream_profile)
- );
-
- const channelWithMetadata = {
- ...ch,
- ...(channelData || {}), // Safely merge channel data if available
- bitrates,
- stream_profile: streamProfile || { name: 'Unknown' },
- // Make sure stream_id is set from the active stream info
- stream_id: ch.stream_id || null,
- };
-
- stats[ch.channel_id] = channelWithMetadata;
- newChannelHistory[ch.channel_id] = channelWithMetadata; // Only add currently active channels
- });
+ const stats = getStatsByChannelId(channelStats, prevChannelHistory, channelsByUUID, channels, streamProfiles);
console.log('Processed active channels:', stats);
// Update clients based on new stats
- const clientStats = Object.values(stats).reduce((acc, ch) => {
- if (ch.clients && Array.isArray(ch.clients)) {
- return acc.concat(
- ch.clients.map((client) => ({
- ...client,
- channel: ch,
- }))
- );
- }
- return acc;
- }, []);
- setClients(clientStats);
+ setClients(getClientStats(stats));
- return newChannelHistory; // Return only currently active channels
+ return stats; // Return only currently active channels
});
}, [channelStats, channels, channelsByUUID, streamProfiles]);
// Combine active streams and VOD connections into a single mixed list
const combinedConnections = useMemo(() => {
- const activeStreams = Object.values(channelHistory).map((channel) => ({
- type: 'stream',
- data: channel,
- id: channel.channel_id,
- sortKey: channel.uptime || 0, // Use uptime for sorting streams
- }));
-
- // Flatten VOD connections so each individual client gets its own card
- const vodItems = vodConnections.flatMap((vodContent) => {
- return (vodContent.connections || []).map((connection, index) => ({
- type: 'vod',
- data: {
- ...vodContent,
- // Override the connections array to contain only this specific connection
- connections: [connection],
- connection_count: 1, // Each card now represents a single connection
- // Add individual connection details at the top level for easier access
- individual_connection: connection,
- },
- id: `${vodContent.content_type}-${vodContent.content_uuid}-${connection.client_id}-${index}`,
- sortKey: connection.connected_at || Date.now() / 1000, // Use connection time for sorting
- }));
- });
-
- // Combine and sort by newest connections first (higher sortKey = more recent)
- return [...activeStreams, ...vodItems].sort(
- (a, b) => b.sortKey - a.sortKey
- );
+ return getCombinedConnections(channelHistory, vodConnections);
}, [channelHistory, vodConnections]);
return (
-
-
-
- Active Connections
-
-
- {Object.keys(channelHistory).length} stream
- {Object.keys(channelHistory).length !== 1 ? 's' : ''} •{' '}
- {vodConnections.reduce(
- (total, vodContent) =>
- total + (vodContent.connections?.length || 0),
- 0
- )}{' '}
- VOD connection
- {vodConnections.reduce(
- (total, vodContent) =>
- total + (vodContent.connections?.length || 0),
- 0
- ) !== 1
- ? 's'
- : ''}
-
-
- Refresh Interval (seconds):
- setRefreshIntervalSeconds(value || 0)}
- min={0}
- max={300}
- step={1}
- size="xs"
- style={{ width: 120 }}
- />
- {refreshIntervalSeconds === 0 && (
+ <>
+
+
+
+
+ Active Connections
+
- Refreshing disabled
+ {channelHistoryLength} {
+ channelHistoryLength !== 1 ? 'streams' : 'stream'
+ } • {vodConnectionsCount} {
+ vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection'
+ }
- )}
+
+ Refresh Interval (seconds):
+ setRefreshIntervalSeconds(value || 0)}
+ min={0}
+ max={300}
+ step={1}
+ size="xs"
+ w={120}
+ />
+ {refreshIntervalSeconds === 0 && (
+
+ Refreshing disabled
+
+ )}
+
+ {isPollingActive && refreshInterval > 0 && (
+
+ Refreshing every {refreshIntervalSeconds}s
+
+ )}
+ {
+ fetchChannelStats();
+ fetchVODStats();
+ }}
+ loading={false}
+ >
+ Refresh Now
+
+
- {isPollingActive && refreshInterval > 0 && (
-
- Refreshing every {refreshIntervalSeconds}s
-
- )}
- {
- fetchChannelStats();
- fetchVODStats();
- }}
- loading={false}
- >
- Refresh Now
-
-
-
-
-
- {combinedConnections.length === 0 ? (
+
-
- No active connections
-
+
- ) : (
- combinedConnections.map((connection) => {
- if (connection.type === 'stream') {
- return (
-
- );
- } else if (connection.type === 'vod') {
- return (
-
- );
- }
- return null;
- })
- )}
-
-
+
+
+
+ {/* System Events Section - Fixed at bottom */}
+
+
+
+
+
+ >
);
};
-export default ChannelsPage;
+export default StatsPage;
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx
index 570e49c1..e69f07f8 100644
--- a/frontend/src/pages/Users.jsx
+++ b/frontend/src/pages/Users.jsx
@@ -1,55 +1,25 @@
-import React, { useState } from 'react';
import UsersTable from '../components/tables/UsersTable';
import { Box } from '@mantine/core';
import useAuthStore from '../store/auth';
-import { USER_LEVELS } from '../constants';
+import ErrorBoundary from '../components/ErrorBoundary';
-const UsersPage = () => {
+const PageContent = () => {
const authUser = useAuthStore((s) => s.user);
-
- const [selectedUser, setSelectedUser] = useState(null);
- const [userModalOpen, setUserModalOpen] = useState(false);
- const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
- const [deleteTarget, setDeleteTarget] = useState(null);
- const [userToDelete, setUserToDelete] = useState(null);
-
- if (!authUser.id) {
- return <>>;
- }
-
- const closeUserModal = () => {
- setSelectedUser(null);
- setUserModalOpen(false);
- };
- const editUser = (user) => {
- setSelectedUser(user);
- setUserModalOpen(true);
- };
-
- const deleteUser = (id) => {
- // Get user details for the confirmation dialog
- const user = users.find((u) => u.id === id);
- setUserToDelete(user);
- setDeleteTarget(id);
-
- // Skip warning if it's been suppressed
- if (isWarningSuppressed('delete-user')) {
- return executeDeleteUser(id);
- }
-
- setConfirmDeleteOpen(true);
- };
-
- const executeDeleteUser = async (id) => {
- await API.deleteUser(id);
- setConfirmDeleteOpen(false);
- };
+ if (!authUser.id) throw new Error();
return (
-
+
);
+}
+
+const UsersPage = () => {
+ return (
+
+
+
+ );
};
export default UsersPage;
diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx
index 3c9e2b0f..460b7211 100644
--- a/frontend/src/pages/VODs.jsx
+++ b/frontend/src/pages/VODs.jsx
@@ -1,244 +1,31 @@
-import React, { useState, useEffect } from 'react';
+import React, { Suspense, useEffect, useState } from 'react';
import {
Box,
- Button,
- Card,
Flex,
- Group,
- Image,
- Text,
- Title,
- Select,
- TextInput,
- Pagination,
- Badge,
Grid,
+ GridCol,
+ Group,
Loader,
- Stack,
+ LoadingOverlay,
+ Pagination,
SegmentedControl,
- ActionIcon,
+ Select,
+ Stack,
+ TextInput,
+ Title,
} from '@mantine/core';
-import { Search, Play, Calendar, Clock, Star } from 'lucide-react';
+import { Search } from 'lucide-react';
import { useDisclosure } from '@mantine/hooks';
import useVODStore from '../store/useVODStore';
-import SeriesModal from '../components/SeriesModal';
-import VODModal from '../components/VODModal';
-
-const formatDuration = (seconds) => {
- if (!seconds) return '';
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
- return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
-};
-
-const VODCard = ({ vod, onClick }) => {
- const isEpisode = vod.type === 'episode';
-
- const getDisplayTitle = () => {
- if (isEpisode && vod.series) {
- const seasonEp =
- vod.season_number && vod.episode_number
- ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
- : '';
- return (
-
-
- {vod.series.name}
-
-
- {seasonEp} - {vod.name}
-
-
- );
- }
- return {vod.name};
- };
-
- const handleCardClick = async () => {
- // Just pass the basic vod info to the parent handler
- onClick(vod);
- };
-
- return (
-
-
-
- {vod.logo?.url ? (
-
- ) : (
-
-
-
- )}
-
- {
- e.stopPropagation();
- onClick(vod);
- }}
- >
-
-
-
-
- {isEpisode ? 'Episode' : 'Movie'}
-
-
-
-
-
- {getDisplayTitle()}
-
-
- {vod.year && (
-
-
-
- {vod.year}
-
-
- )}
-
- {vod.duration && (
-
-
-
- {formatDuration(vod.duration_secs)}
-
-
- )}
-
- {vod.rating && (
-
-
-
- {vod.rating}
-
-
- )}
-
-
- {vod.genre && (
-
- {vod.genre}
-
- )}
-
-
- );
-};
-
-const SeriesCard = ({ series, onClick }) => {
- return (
- onClick(series)}
- >
-
-
- {series.logo?.url ? (
-
- ) : (
-
-
-
- )}
- {/* Add Series badge in the same position as Movie badge */}
-
- Series
-
-
-
-
-
- {series.name}
-
-
- {series.year && (
-
-
-
- {series.year}
-
-
- )}
- {series.rating && (
-
-
-
- {series.rating}
-
-
- )}
-
-
- {series.genre && (
-
- {series.genre}
-
- )}
-
-
- );
-};
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import {
+ filterCategoriesToEnabled,
+ getCategoryOptions,
+} from '../utils/pages/VODsUtils.js';
+const SeriesModal = React.lazy(() => import('../components/SeriesModal'));
+const VODModal = React.lazy(() => import('../components/VODModal'));
+const VODCard = React.lazy(() => import('../components/cards/VODCard'));
+const SeriesCard = React.lazy(() => import('../components/cards/SeriesCard'));
const MIN_CARD_WIDTH = 260;
const MAX_CARD_WIDTH = 320;
@@ -312,19 +99,7 @@ const VODsPage = () => {
};
useEffect(() => {
- // setCategories(allCategories)
- setCategories(
- Object.keys(allCategories).reduce((acc, key) => {
- const enabled = allCategories[key].m3u_accounts.find(
- (account) => account.enabled === true
- );
- if (enabled) {
- acc[key] = allCategories[key];
- }
-
- return acc;
- }, {})
- );
+ setCategories(filterCategoriesToEnabled(allCategories));
}, [allCategories]);
useEffect(() => {
@@ -356,19 +131,7 @@ const VODsPage = () => {
setPage(1);
};
- const categoryOptions = [
- { value: '', label: 'All Categories' },
- ...Object.values(categories)
- .filter((cat) => {
- if (filters.type === 'movies') return cat.category_type === 'movie';
- if (filters.type === 'series') return cat.category_type === 'series';
- return true; // 'all' shows all
- })
- .map((cat) => ({
- value: `${cat.name}|${cat.category_type}`,
- label: `${cat.name} (${cat.category_type})`,
- })),
- ];
+ const categoryOptions = getCategoryOptions(categories, filters);
const totalPages = Math.ceil(totalCount / pageSize);
@@ -396,7 +159,7 @@ const VODsPage = () => {
icon={}
value={filters.search}
onChange={(e) => setFilters({ search: e.target.value })}
- style={{ minWidth: 200 }}
+ miw={200}
/>
{
value={filters.category}
onChange={onCategoryChange}
clearable
- style={{ minWidth: 150 }}
+ miw={150}
/>
{
value: v,
label: v,
}))}
- style={{ width: 110 }}
+ w={110}
/>
@@ -428,23 +191,25 @@ const VODsPage = () => {
) : (
<>
- {getDisplayData().map((item) => (
-
- {item.contentType === 'series' ? (
-
- ) : (
-
- )}
-
- ))}
+
+ }>
+ {getDisplayData().map((item) => (
+
+ {item.contentType === 'series' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
{/* Pagination */}
@@ -462,18 +227,26 @@ const VODsPage = () => {
{/* Series Episodes Modal */}
-
+
+ }>
+
+
+
{/* VOD Details Modal */}
-
+
+ }>
+
+
+
);
};
diff --git a/frontend/src/pages/__tests__/Channels.test.jsx b/frontend/src/pages/__tests__/Channels.test.jsx
new file mode 100644
index 00000000..e029952f
--- /dev/null
+++ b/frontend/src/pages/__tests__/Channels.test.jsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import useAuthStore from '../../store/auth';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import ChannelsPage from '../Channels';
+
+vi.mock('../../store/auth');
+vi.mock('../../hooks/useLocalStorage');
+vi.mock('../../components/tables/ChannelsTable', () => ({
+ default: () => ChannelsTable
+}));
+vi.mock('../../components/tables/StreamsTable', () => ({
+ default: () => StreamsTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+}));
+vi.mock('allotment', () => ({
+ Allotment: ({ children }) => {children}
,
+}));
+
+describe('ChannelsPage', () => {
+ beforeEach(() => {
+ useLocalStorage.mockReturnValue([[50, 50], vi.fn()]);
+ });
+
+ it('renders nothing when user is not authenticated', () => {
+ useAuthStore.mockReturnValue({ id: null, user_level: 0 });
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders only ChannelsTable for standard users', () => {
+ useAuthStore.mockReturnValue({ id: 1, user_level: 1 });
+ render();
+ expect(screen.getByTestId('channels-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('streams-table')).not.toBeInTheDocument();
+ });
+
+ it('renders split view for higher-level users', async () => {
+ useAuthStore.mockReturnValue({ id: 1, user_level: 2 });
+ render();
+ expect(screen.getByTestId('channels-table')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByTestId('streams-table')).toBeInTheDocument()
+ );
+ });
+});
diff --git a/frontend/src/pages/__tests__/ContentSources.test.jsx b/frontend/src/pages/__tests__/ContentSources.test.jsx
new file mode 100644
index 00000000..3f2ce1c5
--- /dev/null
+++ b/frontend/src/pages/__tests__/ContentSources.test.jsx
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import ContentSourcesPage from '../ContentSources';
+import useUserAgentsStore from '../../store/userAgents';
+
+vi.mock('../../store/userAgents');
+vi.mock('../../components/tables/M3UsTable', () => ({
+ default: () => M3UsTable
+}));
+vi.mock('../../components/tables/EPGsTable', () => ({
+ default: () => EPGsTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+ Stack: ({ children, ...props }) => {children}
,
+}));
+
+describe('ContentSourcesPage', () => {
+ it('renders error on userAgents error', () => {
+ const errorMessage = 'Failed to load userAgents.';
+ useUserAgentsStore.mockReturnValue(errorMessage);
+ render();
+ const element = screen.getByText(/Something went wrong/i);
+ expect(element).toBeInTheDocument();
+ });
+
+ it('no error renders tables', () => {
+ useUserAgentsStore.mockReturnValue(null);
+ render();
+ expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
+ expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/DVR.test.jsx b/frontend/src/pages/__tests__/DVR.test.jsx
new file mode 100644
index 00000000..be68bd7f
--- /dev/null
+++ b/frontend/src/pages/__tests__/DVR.test.jsx
@@ -0,0 +1,556 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import DVRPage from '../DVR';
+import dayjs from 'dayjs';
+import useChannelsStore from '../../store/channels';
+import useSettingsStore from '../../store/settings';
+import useVideoStore from '../../store/useVideoStore';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import {
+ isAfter,
+ isBefore,
+ useTimeHelpers,
+} from '../../utils/dateTimeUtils.js';
+import { categorizeRecordings } from '../../utils/pages/DVRUtils.js';
+import {
+ getPosterUrl,
+ getRecordingUrl,
+ getShowVideoUrl,
+} from '../../utils/cards/RecordingCardUtils.js';
+
+vi.mock('../../store/channels');
+vi.mock('../../store/settings');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../hooks/useLocalStorage');
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) => {children}
,
+ Container: ({ children }) => {children}
,
+ Title: ({ children, order }) => {children}
,
+ Text: ({ children }) => {children}
,
+ Button: ({ children, onClick, leftSection, loading, ...props }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Badge: ({ children }) => {children},
+ SimpleGrid: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Divider: () =>
,
+ useMantineTheme: () => ({
+ tailwind: {
+ green: { 5: '#22c55e' },
+ red: { 6: '#dc2626' },
+ yellow: { 6: '#ca8a04' },
+ gray: { 6: '#52525b' },
+ },
+ }),
+}));
+
+// Mock components
+vi.mock('../../components/cards/RecordingCard', () => ({
+ default: ({ recording, onOpenDetails, onOpenRecurring }) => (
+
+ {recording.custom_properties?.Title || 'Recording'}
+ onOpenDetails(recording)}>Open Details
+ {recording.custom_properties?.rule && (
+ onOpenRecurring(recording)}>
+ Open Recurring
+
+ )}
+
+ ),
+}));
+
+vi.mock('../../components/forms/RecordingDetailsModal', () => ({
+ default: ({
+ opened,
+ onClose,
+ recording,
+ onEdit,
+ onWatchLive,
+ onWatchRecording,
+ }) =>
+ opened ? (
+
+
+ {recording?.custom_properties?.Title}
+
+
Close Modal
+
Edit
+
Watch Live
+
Watch Recording
+
+ ) : null,
+}));
+
+vi.mock('../../components/forms/RecurringRuleModal', () => ({
+ default: ({ opened, onClose, ruleId }) =>
+ opened ? (
+
+
Rule ID: {ruleId}
+
Close Recurring
+
+ ) : null,
+}));
+
+vi.mock('../../components/forms/Recording', () => ({
+ default: ({ isOpen, onClose, recording }) =>
+ isOpen ? (
+
+
Recording ID: {recording?.id || 'new'}
+
Close Form
+
+ ) : null,
+}));
+
+vi.mock('../../components/ErrorBoundary', () => ({
+ default: ({ children }) => {children}
,
+}));
+
+vi.mock('../../utils/dateTimeUtils.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ isBefore: vi.fn(),
+ isAfter: vi.fn(),
+ useTimeHelpers: vi.fn(),
+ };
+});
+vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({
+ getPosterUrl: vi.fn(),
+ getRecordingUrl: vi.fn(),
+ getShowVideoUrl: vi.fn(),
+}));
+vi.mock('../../utils/pages/DVRUtils.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ categorizeRecordings: vi.fn(),
+ };
+});
+
+describe('DVRPage', () => {
+ const mockShowVideo = vi.fn();
+ const mockFetchRecordings = vi.fn();
+ const mockFetchChannels = vi.fn();
+ const mockFetchRecurringRules = vi.fn();
+ const mockRemoveRecording = vi.fn();
+
+ const defaultChannelsState = {
+ recordings: [],
+ channels: {},
+ recurringRules: [],
+ fetchRecordings: mockFetchRecordings,
+ fetchChannels: mockFetchChannels,
+ fetchRecurringRules: mockFetchRecurringRules,
+ removeRecording: mockRemoveRecording,
+ };
+
+ const defaultSettingsState = {
+ settings: {
+ system_settings: { value: { time_zone: 'America/New_York' } },
+ },
+ environment: {
+ env_mode: 'production',
+ },
+ };
+
+ const defaultVideoState = {
+ showVideo: mockShowVideo,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ const now = new Date('2024-01-15T12:00:00Z');
+ vi.setSystemTime(now);
+
+ isAfter.mockImplementation((a, b) => new Date(a) > new Date(b));
+ isBefore.mockImplementation((a, b) => new Date(a) < new Date(b));
+ useTimeHelpers.mockReturnValue({
+ toUserTime: (dt) => dayjs(dt).tz('America/New_York').toDate(),
+ userNow: () => dayjs().tz('America/New_York').toDate(),
+ });
+
+ categorizeRecordings.mockImplementation((recordings, toUserTime, now) => {
+ const inProgress = [];
+ const upcoming = [];
+ const completed = [];
+ recordings.forEach((rec) => {
+ const start = toUserTime(rec.start_time);
+ const end = toUserTime(rec.end_time);
+ if (now >= start && now <= end) inProgress.push(rec);
+ else if (now < start) upcoming.push(rec);
+ else completed.push(rec);
+ });
+ return { inProgress, upcoming, completed };
+ });
+
+ getPosterUrl.mockImplementation((recording) =>
+ recording?.id ? `http://poster.url/${recording.id}` : null
+ );
+ getRecordingUrl.mockImplementation(
+ (custom_properties) => custom_properties?.recording_url
+ );
+ getShowVideoUrl.mockImplementation((channel) => channel?.stream_url);
+
+ useChannelsStore.mockImplementation((selector) => {
+ return selector ? selector(defaultChannelsState) : defaultChannelsState;
+ });
+ useChannelsStore.getState = () => defaultChannelsState;
+
+ useSettingsStore.mockImplementation((selector) => {
+ return selector ? selector(defaultSettingsState) : defaultSettingsState;
+ });
+ useSettingsStore.getState = () => defaultSettingsState;
+
+ useVideoStore.mockImplementation((selector) => {
+ return selector ? selector(defaultVideoState) : defaultVideoState;
+ });
+ useVideoStore.getState = () => defaultVideoState;
+
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.clearAllTimers(); // Clear pending timers
+ vi.useRealTimers();
+ });
+
+ describe('Initial Render', () => {
+ it('renders new recording buttons', () => {
+ render();
+
+ expect(screen.getByText('New Recording')).toBeInTheDocument();
+ });
+
+ it('renders empty state when no recordings', () => {
+ render();
+
+ expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument();
+ });
+ });
+
+ describe('Recording Display', () => {
+ it('displays recordings grouped by date', () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recordings = [
+ {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Show 1' },
+ },
+ {
+ id: 2,
+ channel: 1,
+ start_time: now.add(1, 'day').toISOString(),
+ end_time: now.add(1, 'day').add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Show 2' },
+ },
+ ];
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = { ...defaultChannelsState, recordings };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(screen.getByTestId('recording-card-1')).toBeInTheDocument();
+ expect(screen.getByTestId('recording-card-2')).toBeInTheDocument();
+ });
+ });
+
+ describe('New Recording', () => {
+ it('opens recording form when new recording button is clicked', async () => {
+ render();
+
+ const newButton = screen.getByText('New Recording');
+ fireEvent.click(newButton);
+
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+ });
+
+ it('closes recording form when close is clicked', async () => {
+ render();
+
+ const newButton = screen.getByText('New Recording');
+ fireEvent.click(newButton);
+
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close Form');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Recording Details Modal', () => {
+ const setupRecording = () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Test Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: {
+ 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
+ },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ return recording;
+ };
+
+ it('opens details modal when recording card is clicked', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Show');
+ });
+
+ it('closes details modal when close is clicked', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const closeButton = screen.getByText('Close Modal');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument();
+ });
+
+ it('opens edit form from details modal', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const editButton = screen.getByText('Edit');
+ fireEvent.click(editButton);
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument();
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+ });
+ });
+
+ describe('Recurring Rule Modal', () => {
+ it('opens recurring rule modal when recording has rule', async () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recurring Show',
+ rule: { id: 100 },
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const recurringButton = screen.getByText('Open Recurring');
+ fireEvent.click(recurringButton);
+
+ expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
+ expect(screen.getByText('Rule ID: 100')).toBeInTheDocument();
+ });
+
+ it('closes recurring modal when close is clicked', async () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recurring Show',
+ rule: { id: 100 },
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const recurringButton = screen.getByText('Open Recurring');
+ fireEvent.click(recurringButton);
+
+ expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close Recurring');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Watch Functionality', () => {
+ it('calls showVideo for watch live on in-progress recording', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs();
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(30, 'minutes').toISOString(),
+ end_time: now.add(30, 'minutes').toISOString(),
+ custom_properties: { Title: 'Live Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: {
+ 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
+ },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const watchLiveButton = screen.getByText('Watch Live');
+ fireEvent.click(watchLiveButton);
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('stream.url'),
+ 'live'
+ );
+ });
+
+ it('calls showVideo for watch recording on completed recording', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recorded Show',
+ recording_url: 'http://recording.url/video.mp4',
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const watchButton = screen.getByText('Watch Recording');
+ fireEvent.click(watchButton);
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('http://recording.url/video.mp4'),
+ 'vod',
+ expect.objectContaining({
+ name: 'Recording',
+ })
+ );
+ });
+
+ it('does not call showVideo when recording URL is missing', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ custom_properties: { Title: 'No URL Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const detailsButton = await screen.findByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ const modal = await screen.findByTestId('details-modal');
+ expect(modal).toBeInTheDocument();
+
+ const watchButton = screen.getByText('Watch Recording');
+ fireEvent.click(watchButton);
+
+ expect(mockShowVideo).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx
new file mode 100644
index 00000000..feb5325c
--- /dev/null
+++ b/frontend/src/pages/__tests__/Guide.test.jsx
@@ -0,0 +1,619 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import {
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+} from '@testing-library/react';
+import dayjs from 'dayjs';
+import Guide from '../Guide';
+import useChannelsStore from '../../store/channels';
+import useLogosStore from '../../store/logos';
+import useEPGsStore from '../../store/epgs';
+import useSettingsStore from '../../store/settings';
+import useVideoStore from '../../store/useVideoStore';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import { showNotification } from '../../utils/notificationUtils.js';
+import * as guideUtils from '../guideUtils';
+import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
+import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
+import userEvent from '@testing-library/user-event';
+
+// Mock dependencies
+vi.mock('../../store/channels');
+vi.mock('../../store/logos');
+vi.mock('../../store/epgs');
+vi.mock('../../store/settings');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../hooks/useLocalStorage');
+
+vi.mock('@mantine/hooks', () => ({
+ useElementSize: () => ({
+ ref: vi.fn(),
+ width: 1200,
+ height: 800,
+ }),
+}));
+vi.mock('@mantine/core', async () => {
+ const actual = await vi.importActual('@mantine/core');
+ return {
+ ...actual,
+ Box: ({ children, style, onClick, className, ref }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children, direction, justify, align, gap, mb, style }) => (
+
+ {children}
+
+ ),
+ Group: ({ children, gap, justify }) => (
+
+ {children}
+
+ ),
+ Title: ({ children, order, size }) => (
+
+ {children}
+
+ ),
+ Text: ({ children, size, c, fw, lineClamp, style, onClick }) => (
+
+ {children}
+
+ ),
+ Paper: ({ children, style, onClick }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, leftSection, variant, size, color, disabled }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
+
+ {icon}
+
+ {rightSection}
+
+ ),
+ Select: ({ value, onChange, data, placeholder, clearable }) => (
+ onChange?.(e.target.value)}
+ aria-label={placeholder}
+ data-clearable={clearable}
+ >
+
+ {data?.map((option) => (
+
+ ))}
+
+ ),
+ ActionIcon: ({ children, onClick, variant, size, color }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) => {children}
,
+ LoadingOverlay: ({ visible }) => (visible ? Loading...
: null),
+ };
+});
+vi.mock('react-window', () => ({
+ VariableSizeList: ({ children, itemData, itemCount }) => (
+
+ {Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
+
+ {children({
+ index: i,
+ style: {},
+ data: itemData.filteredChannels[i]
+ })}
+
+ )}
+
+ ),
+}));
+
+vi.mock('../../components/GuideRow', () => ({
+ default: ({ data }) => GuideRow for {data?.name}
,
+}));
+vi.mock('../../components/HourTimeline', () => ({
+ default: ({ hourTimeline }) => (
+
+ {hourTimeline.map((hour, i) => (
+
{hour.label}
+ ))}
+
+ ),
+}));
+vi.mock('../../components/forms/ProgramRecordingModal', () => ({
+ __esModule: true,
+ default: ({ opened, onClose, program, onRecordOne }) =>
+ opened ? (
+
+
{program?.title}
+
Close
+
Record One
+
+ ) : null,
+}));
+vi.mock('../../components/forms/SeriesRecordingModal', () => ({
+ __esModule: true,
+ default: ({ opened, onClose, rules }) =>
+ opened ? (
+
+
Series Rules: {rules.length}
+
Close
+
+ ) : null,
+}));
+
+vi.mock('../guideUtils', async () => {
+ const actual = await vi.importActual('../guideUtils');
+ return {
+ ...actual,
+ fetchPrograms: vi.fn(),
+ createRecording: vi.fn(),
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRule: vi.fn(),
+ fetchRules: vi.fn(),
+ filterGuideChannels: vi.fn(),
+ getGroupOptions: vi.fn(),
+ getProfileOptions: vi.fn(),
+ };
+});
+vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/cards/RecordingCardUtils.js');
+ return {
+ ...actual,
+ getShowVideoUrl: vi.fn(),
+ };
+});
+vi.mock('../../utils/dateTimeUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/dateTimeUtils.js');
+ return {
+ ...actual,
+ getNow: vi.fn(),
+ add: vi.fn(),
+ format: vi.fn(),
+ initializeTime: vi.fn(),
+ startOfDay: vi.fn(),
+ convertToMs: vi.fn(),
+ useDateTimeFormat: vi.fn(),
+ };
+});
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+describe('Guide', () => {
+ let mockChannelsState;
+ let mockShowVideo;
+ let mockFetchRecordings;
+ const now = dayjs('2024-01-15T12:00:00Z');
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+
+ mockChannelsState = {
+ channels: {
+ 'channel-1': {
+ id: 'channel-1',
+ uuid: 'uuid-1',
+ name: 'Test Channel 1',
+ channel_number: 1,
+ logo_id: 'logo-1',
+ stream_url: 'http://stream1.test',
+ },
+ 'channel-2': {
+ id: 'channel-2',
+ uuid: 'uuid-2',
+ name: 'Test Channel 2',
+ channel_number: 2,
+ logo_id: 'logo-2',
+ stream_url: 'http://stream2.test',
+ },
+ },
+ recordings: [],
+ channelGroups: {
+ 'group-1': { id: 'group-1', name: 'News', channels: ['channel-1'] },
+ },
+ profiles: {
+ 'profile-1': { id: 'profile-1', name: 'HD Profile' },
+ },
+ };
+
+ mockShowVideo = vi.fn();
+ mockFetchRecordings = vi.fn().mockResolvedValue([]);
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...mockChannelsState,
+ fetchRecordings: mockFetchRecordings,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLogosStore.mockReturnValue({
+ 'logo-1': { url: 'http://logo1.png' },
+ 'logo-2': { url: 'http://logo2.png' },
+ });
+
+ useEPGsStore.mockImplementation((selector) =>
+ selector ? selector({ tvgsById: {}, epgs: {} }) : { tvgsById: {}, epgs: {} }
+ );
+
+ useSettingsStore.mockReturnValue('production');
+ useVideoStore.mockReturnValue(mockShowVideo);
+ useLocalStorage.mockReturnValue(['12h', vi.fn()]);
+
+ dateTimeUtils.getNow.mockReturnValue(now);
+ dateTimeUtils.format.mockImplementation((date, format) => {
+ if (format?.includes('dddd')) return 'Monday, 01/15/2024 • 12:00 PM';
+ return '12:00 PM';
+ });
+ dateTimeUtils.initializeTime.mockImplementation(date => date || now);
+ dateTimeUtils.startOfDay.mockReturnValue(now.startOf('day'));
+ dateTimeUtils.add.mockImplementation((date, amount, unit) =>
+ dayjs(date).add(amount, unit)
+ );
+ dateTimeUtils.convertToMs.mockImplementation(date => dayjs(date).valueOf());
+ dateTimeUtils.useDateTimeFormat.mockReturnValue(['12h', 'MM/DD/YYYY']);
+
+ guideUtils.fetchPrograms.mockResolvedValue([
+ {
+ id: 'prog-1',
+ tvg_id: 'tvg-1',
+ title: 'Test Program 1',
+ description: 'Description 1',
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ programStart: now,
+ programEnd: now.add(1, 'hour'),
+ startMs: now.valueOf(),
+ endMs: now.add(1, 'hour').valueOf(),
+ isLive: true,
+ isPast: false,
+ },
+ ]);
+
+ guideUtils.fetchRules.mockResolvedValue([]);
+ guideUtils.filterGuideChannels.mockImplementation(
+ (channels) => Object.values(channels)
+ );
+ guideUtils.createRecording.mockResolvedValue(undefined);
+ guideUtils.createSeriesRule.mockResolvedValue(undefined);
+ guideUtils.evaluateSeriesRule.mockResolvedValue(undefined);
+ guideUtils.getGroupOptions.mockReturnValue([
+ { value: 'all', label: 'All Groups' },
+ { value: 'group-1', label: 'News' },
+ ]);
+ guideUtils.getProfileOptions.mockReturnValue([
+ { value: 'all', label: 'All Profiles' },
+ { value: 'profile-1', label: 'HD Profile' },
+ ]);
+
+ recordingCardUtils.getShowVideoUrl.mockReturnValue('http://video.test');
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ describe('Rendering', () => {
+ it('renders the TV Guide title', async () => {
+ render();
+
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ it('displays current time in header', async () => {
+ render();
+
+ expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
+ });
+
+ it('renders channel rows when channels are available', async () => {
+ render();
+
+ expect(screen.getAllByTestId('guide-row')).toHaveLength(2);
+ });
+
+ it('shows no channels message when filters exclude all channels', async () => {
+ guideUtils.filterGuideChannels.mockReturnValue([]);
+
+ render();
+
+ // await waitFor(() => {
+ expect(screen.getByText('No channels match your filters')).toBeInTheDocument();
+ // });
+ });
+
+ it('displays channel count', async () => {
+ render();
+
+ // await waitFor(() => {
+ expect(screen.getByText(/2 channels/)).toBeInTheDocument();
+ // });
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('updates search query when user types', async () => {
+ vi.useRealTimers();
+
+ render();
+
+ const searchInput = screen.getByPlaceholderText('Search channels...');
+ fireEvent.change(searchInput, { target: { value: 'Test' } });
+
+ expect(searchInput).toHaveValue('Test');
+ });
+
+ it('clears search query when clear button is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const searchInput = screen.getByPlaceholderText('Search channels...');
+
+ await user.type(searchInput, 'Test');
+ expect(searchInput).toHaveValue('Test');
+
+ await user.click(screen.getByText('Clear Filters'));
+ expect(searchInput).toHaveValue('');
+ });
+
+ it('calls filterGuideChannels with search query', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const searchInput = await screen.findByPlaceholderText('Search channels...');
+ await user.type(searchInput, 'News');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ 'News',
+ 'all',
+ 'all',
+ expect.anything()
+ );
+ });
+ });
+ });
+
+ describe('Filter Functionality', () => {
+ it('filters by channel group', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const groupSelect = await screen.findByLabelText('Filter by group');
+ await user.selectOptions(groupSelect, 'group-1');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ '',
+ 'group-1',
+ 'all',
+ expect.anything()
+ );
+ });
+ });
+
+ it('filters by profile', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const profileSelect = await screen.findByLabelText('Filter by profile');
+ await user.selectOptions(profileSelect, 'profile-1');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ '',
+ 'all',
+ 'profile-1',
+ expect.anything()
+ );
+ });
+ });
+
+ it('clears all filters when Clear Filters is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ // Set some filters
+ const searchInput = await screen.findByPlaceholderText('Search channels...');
+ await user.type(searchInput, 'Test');
+
+ // Clear them
+ const clearButton = await screen.findByText('Clear Filters');
+ await user.click(clearButton);
+
+ expect(searchInput).toHaveValue('');
+ });
+ });
+
+ describe('Recording Functionality', () => {
+ it('opens Series Rules modal when button is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup();
+ render();
+
+ const rulesButton = await screen.findByText('Series Rules');
+ await user.click(rulesButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('series-recording-modal')).toBeInTheDocument();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+
+ it('fetches rules when opening Series Rules modal', async () => {
+ vi.useRealTimers();
+
+ const mockRules = [{ id: 1, title: 'Test Rule' }];
+ guideUtils.fetchRules.mockResolvedValue(mockRules);
+
+ const user = userEvent.setup();
+ render();
+
+ const rulesButton = await screen.findByText('Series Rules');
+ await user.click(rulesButton);
+
+ await waitFor(() => {
+ expect(guideUtils.fetchRules).toHaveBeenCalled();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+ });
+
+ describe('Navigation', () => {
+ it('scrolls to current time when Jump to current time is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const jumpButton = await screen.findByTitle('Jump to current time');
+ await user.click(jumpButton);
+
+ // Verify button was clicked (scroll behavior is tested in integration tests)
+ expect(jumpButton).toBeInTheDocument();
+ });
+ });
+
+ describe('Time Updates', () => {
+ it('updates current time every second', async () => {
+ render();
+
+ expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
+
+ // Advance time by 1 second
+ vi.advanceTimersByTime(1000);
+
+ expect(dateTimeUtils.getNow).toHaveBeenCalled();
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('shows notification when no channels are available', async () => {
+ useChannelsStore.mockImplementation((selector) => {
+ const state = { channels: {}, recordings: [], channelGroups: {}, profiles: {} };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'No channels available',
+ color: 'red.5',
+ });
+ });
+ });
+
+ describe('Watch Functionality', () => {
+ it('calls showVideo when watch button is clicked on live program', async () => {
+ vi.useRealTimers();
+
+ // Mock a live program
+ const liveProgram = {
+ id: 'prog-live',
+ tvg_id: 'tvg-1',
+ title: 'Live Show',
+ description: 'Live Description',
+ start_time: now.subtract(30, 'minutes').toISOString(),
+ end_time: now.add(30, 'minutes').toISOString(),
+ programStart: now.subtract(30, 'minutes'),
+ programEnd: now.add(30, 'minutes'),
+ startMs: now.subtract(30, 'minutes').valueOf(),
+ endMs: now.add(30, 'minutes').valueOf(),
+ isLive: true,
+ isPast: false,
+ };
+
+ guideUtils.fetchPrograms.mockResolvedValue([liveProgram]);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ // Implementation depends on how programs are rendered - this is a placeholder
+ // You would need to find and click the actual watch button in the rendered program
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+
+ it('does not show watch button for past programs', async () => {
+ vi.useRealTimers();
+
+ const pastProgram = {
+ id: 'prog-past',
+ tvg_id: 'tvg-1',
+ title: 'Past Show',
+ description: 'Past Description',
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ programStart: now.subtract(2, 'hours'),
+ programEnd: now.subtract(1, 'hour'),
+ startMs: now.subtract(2, 'hours').valueOf(),
+ endMs: now.subtract(1, 'hour').valueOf(),
+ isLive: false,
+ isPast: true,
+ };
+
+ guideUtils.fetchPrograms.mockResolvedValue([pastProgram]);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Login.test.jsx b/frontend/src/pages/__tests__/Login.test.jsx
new file mode 100644
index 00000000..3db66883
--- /dev/null
+++ b/frontend/src/pages/__tests__/Login.test.jsx
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import Login from '../Login';
+import useAuthStore from '../../store/auth';
+
+vi.mock('../../store/auth');
+vi.mock('../../components/forms/LoginForm', () => ({
+ default: () => LoginForm
+}));
+vi.mock('../../components/forms/SuperuserForm', () => ({
+ default: () => SuperuserForm
+}));
+vi.mock('@mantine/core', () => ({
+ Text: ({ children }) => {children}
,
+}));
+
+describe('Login', () => {
+ it('renders SuperuserForm when superuser does not exist', async () => {
+ useAuthStore.mockReturnValue(false);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('login-form')).not.toBeInTheDocument();
+ });
+
+ it('renders LoginForm when superuser exists', () => {
+ useAuthStore.mockReturnValue(true);
+
+ render();
+
+ expect(screen.getByTestId('login-form')).toBeInTheDocument();
+ expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Logos.test.jsx b/frontend/src/pages/__tests__/Logos.test.jsx
new file mode 100644
index 00000000..b710b2ef
--- /dev/null
+++ b/frontend/src/pages/__tests__/Logos.test.jsx
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import LogosPage from '../Logos';
+import useLogosStore from '../../store/logos';
+import useVODLogosStore from '../../store/vodLogos';
+import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+
+vi.mock('../../store/logos');
+vi.mock('../../store/vodLogos');
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+ updateNotification: vi.fn(),
+}));
+vi.mock('../../components/tables/LogosTable', () => ({
+ default: () => LogosTable
+}));
+vi.mock('../../components/tables/VODLogosTable', () => ({
+ default: () => VODLogosTable
+}));
+vi.mock('@mantine/core', () => {
+ const tabsComponent = ({ children, value, onChange }) =>
+ onChange('vod')}>{children}
;
+ tabsComponent.List = ({ children }) => {children}
;
+ tabsComponent.Tab = ({ children, value }) => {children};
+
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Flex: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children},
+ Tabs: tabsComponent,
+ TabsList: tabsComponent.List,
+ TabsTab: tabsComponent.Tab,
+ };
+});
+
+describe('LogosPage', () => {
+ const mockFetchAllLogos = vi.fn();
+ const mockNeedsAllLogos = vi.fn();
+
+ const defaultLogosState = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: { 1: {}, 2: {}, 3: {} },
+ };
+
+ const defaultVODLogosState = {
+ totalCount: 5,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ useLogosStore.mockImplementation((selector) => {
+ return selector ? selector(defaultLogosState) : defaultLogosState;
+ });
+ useLogosStore.getState = () => defaultLogosState;
+
+ useVODLogosStore.mockImplementation((selector) => {
+ return selector ? selector(defaultVODLogosState) : defaultVODLogosState;
+ });
+
+ mockNeedsAllLogos.mockReturnValue(true);
+ mockFetchAllLogos.mockResolvedValue();
+ });
+
+ it('renders with channel logos tab by default', () => {
+ render();
+
+ expect(screen.getByText('Logos')).toBeInTheDocument();
+ expect(screen.getByTestId('logos-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('vod-logos-table')).not.toBeInTheDocument();
+ });
+
+ it('displays correct channel logos count', () => {
+ render();
+
+ expect(screen.getByText(/\(3 logos\)/i)).toBeInTheDocument();
+ });
+
+ it('displays singular "logo" when count is 1', () => {
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: { 1: {} },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(screen.getByText(/\(1 logo\)/i)).toBeInTheDocument();
+ });
+
+ it('fetches all logos on mount when needed', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockNeedsAllLogos).toHaveBeenCalled();
+ expect(mockFetchAllLogos).toHaveBeenCalled();
+ });
+ });
+
+ it('does not fetch logos when not needed', async () => {
+ mockNeedsAllLogos.mockReturnValue(false);
+
+ render();
+
+ await waitFor(() => {
+ expect(mockNeedsAllLogos).toHaveBeenCalled();
+ expect(mockFetchAllLogos).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows error notification when fetching logos fails', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const error = new Error('Failed to fetch');
+ mockFetchAllLogos.mockRejectedValue(error);
+
+ render();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'Error',
+ message: 'Failed to load channel logos',
+ color: 'red',
+ });
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load channel logos:',
+ error
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('switches to VOD logos tab when clicked', () => {
+ const { rerender } = render();
+
+ expect(screen.getByTestId('logos-table')).toBeInTheDocument();
+
+ const tabs = screen.getByTestId('tabs');
+ fireEvent.click(tabs);
+
+ rerender();
+
+ expect(screen.getByTestId('vod-logos-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('logos-table')).not.toBeInTheDocument();
+ });
+
+ it('renders both tab options', () => {
+ render();
+
+ expect(screen.getByText('Channel Logos')).toBeInTheDocument();
+ expect(screen.getByText('VOD Logos')).toBeInTheDocument();
+ });
+
+ it('displays zero logos correctly', () => {
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: {},
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(screen.getByText(/\(0 logos\)/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Plugins.test.jsx b/frontend/src/pages/__tests__/Plugins.test.jsx
new file mode 100644
index 00000000..cbf052ed
--- /dev/null
+++ b/frontend/src/pages/__tests__/Plugins.test.jsx
@@ -0,0 +1,561 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import PluginsPage from '../Plugins';
+import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+import {
+ deletePluginByKey,
+ importPlugin,
+ setPluginEnabled,
+ updatePluginSettings,
+} from '../../utils/pages/PluginsUtils';
+import { usePluginStore } from '../../store/plugins';
+
+vi.mock('../../store/plugins');
+
+vi.mock('../../utils/pages/PluginsUtils', () => ({
+ deletePluginByKey: vi.fn(),
+ importPlugin: vi.fn(),
+ setPluginEnabled: vi.fn(),
+ updatePluginSettings: vi.fn(),
+ runPluginAction: vi.fn(),
+}));
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+ updateNotification: vi.fn(),
+}));
+
+vi.mock('@mantine/core', async () => {
+ return {
+ AppShellMain: ({ children }) => {children}
,
+ Box: ({ children, style }) => {children}
,
+ Stack: ({ children, gap }) => {children}
,
+ Group: ({ children, justify, mb }) => (
+
+ {children}
+
+ ),
+ Alert: ({ children, color, title }) => (
+
+ {title &&
{title}
}
+ {children}
+
+ ),
+ Text: ({ children, size, fw, c }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Loader: () => Loading...
,
+ Switch: ({ checked, onChange, label, description }) => (
+
+ ),
+ Divider: ({ my }) =>
,
+ ActionIcon: ({ children, onClick, color, variant, title }) => (
+
+ {children}
+
+ ),
+ SimpleGrid: ({ children, cols }) => (
+ {children}
+ ),
+ Modal: ({ opened, onClose, title, children, size, centered }) =>
+ opened ? (
+
+
{title}
+
Close Modal
+ {children}
+
+ ) : null,
+ FileInput: ({ value, onChange, label, placeholder, accept }) => (
+
+ {label && }
+ onChange?.(e.target.files[0])}
+ placeholder={placeholder}
+ accept={accept}
+ aria-label={label}
+ />
+
+ ),
+ };
+});
+vi.mock('@mantine/dropzone', () => ({
+ Dropzone: ({ children, onDrop, accept, maxSize }) => (
+ {
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ onDrop([file]);
+ }}
+ >
+
Drop files
+ {children}
+
+ ),
+}));
+
+vi.mock('../../components/cards/PluginCard.jsx', () => ({
+ default: ({ plugin }) => (
+
+
{plugin.name}
+
{plugin.description}
+
+ ),
+}));
+
+describe('PluginsPage', () => {
+ const mockPlugins = [
+ {
+ key: 'plugin1',
+ name: 'Test Plugin 1',
+ description: 'Description 1',
+ enabled: true,
+ ever_enabled: true,
+ },
+ {
+ key: 'plugin2',
+ name: 'Test Plugin 2',
+ description: 'Description 2',
+ enabled: false,
+ ever_enabled: false,
+ },
+ ];
+
+ const mockPluginStoreState = {
+ plugins: mockPlugins,
+ loading: false,
+ fetchPlugins: vi.fn(),
+ updatePlugin: vi.fn(),
+ removePlugin: vi.fn(),
+ invalidatePlugins: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(mockPluginStoreState) : mockPluginStoreState;
+ });
+ usePluginStore.getState = vi.fn(() => mockPluginStoreState);
+ });
+
+ describe('Rendering', () => {
+ it('renders the page with plugins list', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Plugins')).toBeInTheDocument();
+ expect(screen.getByText('Test Plugin 1')).toBeInTheDocument();
+ expect(screen.getByText('Test Plugin 2')).toBeInTheDocument();
+ });
+ });
+
+ it('renders import button', () => {
+ render();
+
+ expect(screen.getByText('Import Plugin')).toBeInTheDocument();
+ });
+
+ it('renders reload button', () => {
+ render();
+
+ const reloadButton = screen.getByTitle('Reload');
+ expect(reloadButton).toBeInTheDocument();
+ });
+
+ it('shows loader when loading and no plugins', () => {
+ const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() };
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(loadingState) : loadingState;
+ });
+ usePluginStore.getState = vi.fn(() => loadingState);
+
+ render();
+
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no plugins', () => {
+ const emptyState = { plugins: [], loading: false, fetchPlugins: vi.fn() };
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(emptyState) : emptyState;
+ });
+ usePluginStore.getState = vi.fn(() => emptyState);
+
+ render();
+
+ expect(screen.getByText(/No plugins found/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Import Plugin', () => {
+ it('opens import modal when import button is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin');
+ });
+
+ it('shows dropzone and file input in import modal', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ expect(screen.getByTestId('dropzone')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument();
+ });
+
+ it('closes import modal when close button is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Close Modal'));
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('handles file upload via dropzone', async () => {
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' },
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+ const dropzone = screen.getByTestId('dropzone');
+ fireEvent.click(dropzone);
+
+ await waitFor(() => {
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ expect(uploadButton).not.toBeDisabled();
+ });
+ });
+
+ it('uploads plugin successfully', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(importPlugin).toHaveBeenCalledWith(file);
+ expect(showNotification).toHaveBeenCalled();
+ expect(updateNotification).toHaveBeenCalled();
+ });
+ });
+
+ it('handles upload failure', async () => {
+ importPlugin.mockResolvedValue({
+ success: false,
+ error: 'Upload failed',
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(updateNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ color: 'red',
+ title: 'Import failed',
+ })
+ );
+ });
+ });
+
+ it('shows enable switch after successful import', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('New Plugin')).toBeInTheDocument();
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+ });
+
+ it('enables plugin after import when switch is toggled', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: true,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(setPluginEnabled).toHaveBeenCalledWith('new-plugin', true);
+ });
+ });
+ });
+
+ describe('Trust Warning', () => {
+ it('shows trust warning for untrusted plugins', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true, ever_enabled: true });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument();
+ });
+ });
+
+ it('enables plugin when trust is confirmed', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true, ever_enabled: true });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('I understand, enable')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('I understand, enable'));
+
+ await waitFor(() => {
+ expect(setPluginEnabled).toHaveBeenCalledWith('new-plugin', true);
+ });
+ });
+
+ it('cancels enable when trust is denied', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ const cancelButtons = screen.getAllByText('Cancel');
+ expect(cancelButtons.length).toBeGreaterThan(0);
+ });
+
+ const cancelButtons = screen.getAllByText('Cancel');
+ fireEvent.click(cancelButtons[cancelButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(setPluginEnabled).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Reload', () => {
+ it('reloads plugins when reload button is clicked', async () => {
+ const invalidatePlugins = vi.fn();
+ usePluginStore.getState = vi.fn(() => ({
+ ...mockPluginStoreState,
+ invalidatePlugins,
+ }));
+
+ render();
+
+ const reloadButton = screen.getByTitle('Reload');
+ fireEvent.click(reloadButton);
+
+ await waitFor(() => {
+ expect(invalidatePlugins).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx
new file mode 100644
index 00000000..6a254326
--- /dev/null
+++ b/frontend/src/pages/__tests__/Settings.test.jsx
@@ -0,0 +1,208 @@
+import {
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import SettingsPage from '../Settings';
+import useAuthStore from '../../store/auth';
+import { USER_LEVELS } from '../../constants';
+import userEvent from '@testing-library/user-event';
+
+// Mock all dependencies
+vi.mock('../../store/auth');
+vi.mock('../../components/tables/UserAgentsTable', () => ({
+ default: ({ active }) => UserAgentsTable {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/tables/StreamProfilesTable', () => ({
+ default: ({ active }) => StreamProfilesTable {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/backups/BackupManager', () => ({
+ default: ({ active }) => BackupManager {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
+ default: ({ active }) => UiSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
+ default: ({ active }) => NetworkAccessForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
+ default: ({ active }) => ProxySettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
+ default: ({ active }) => StreamSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
+ default: ({ active }) => DvrSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
+ default: ({ active }) => SystemSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/ErrorBoundary', () => ({
+ default: ({ children }) => {children}
,
+}));
+
+vi.mock('@mantine/core', async () => {
+ const accordionComponent = ({ children, onChange, defaultValue }) => {children}
;
+ accordionComponent.Item = ({ children, value }) => (
+ {children}
+ );
+ accordionComponent.Control = ({ children }) => (
+ {children}
+ );
+ accordionComponent.Panel = ({ children }) => (
+ {children}
+ );
+
+ return {
+ Accordion: accordionComponent,
+ AccordionItem: accordionComponent.Item,
+ AccordionControl: accordionComponent.Control,
+ AccordionPanel: accordionComponent.Panel,
+ Box: ({ children }) => {children}
,
+ Center: ({ children }) => {children}
,
+ Loader: () => Loading...
,
+ Text: ({ children }) => {children},
+ };
+});
+
+
+describe('SettingsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering for Regular User', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.USER,
+ username: 'testuser',
+ });
+ });
+
+ it('renders the settings page', () => {
+ render();
+
+ expect(screen.getByTestId('accordion')).toBeInTheDocument();
+ });
+
+ it('renders UI Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-ui-settings')).toBeInTheDocument();
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+ });
+
+ it('opens UI Settings panel by default', () => {
+ render();
+
+ expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
+ });
+
+ it('does not render admin-only sections for regular users', () => {
+ render();
+
+ expect(screen.queryByText('DVR')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('System Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('User-Agents')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stream Profiles')).not.toBeInTheDocument();
+ expect(screen.queryByText('Network Access')).not.toBeInTheDocument();
+ expect(screen.queryByText('Proxy Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('Backup & Restore')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Rendering for Admin User', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.ADMIN,
+ username: 'admin',
+ });
+ });
+
+ it('renders all accordion items for admin', async () => {
+ render();
+
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText('DVR')).toBeInTheDocument();
+ expect(screen.getByText('Stream Settings')).toBeInTheDocument();
+ expect(screen.getByText('System Settings')).toBeInTheDocument();
+ expect(screen.getByText('User-Agents')).toBeInTheDocument();
+ expect(screen.getByText('Stream Profiles')).toBeInTheDocument();
+ expect(screen.getByText('Network Access')).toBeInTheDocument();
+ expect(screen.getByText('Proxy Settings')).toBeInTheDocument();
+ expect(screen.getByText('Backup & Restore')).toBeInTheDocument();
+ });
+ });
+
+ it('renders DVR settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-dvr-settings')).toBeInTheDocument();
+ });
+
+ it('renders Stream Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-stream-settings')).toBeInTheDocument();
+ });
+
+ it('renders System Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-system-settings')).toBeInTheDocument();
+ });
+
+ it('renders User-Agents accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-user-agents')).toBeInTheDocument();
+ });
+
+ it('renders Stream Profiles accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-stream-profiles')).toBeInTheDocument();
+ });
+
+ it('renders Network Access accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-network-access')).toBeInTheDocument();
+ });
+
+ it('renders Proxy Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-proxy-settings')).toBeInTheDocument();
+ });
+
+ it('renders Backup & Restore accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
+ });
+ });
+
+ describe('Accordion Interactions', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.ADMIN,
+ username: 'admin',
+ });
+ });
+
+ it('opens DVR settings when clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const streamSettingsButton = screen.getByText('DVR');
+ await user.click(streamSettingsButton);
+
+ await screen.findByTestId('dvr-settings-form');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx
new file mode 100644
index 00000000..bf5cdb42
--- /dev/null
+++ b/frontend/src/pages/__tests__/Stats.test.jsx
@@ -0,0 +1,494 @@
+// src/pages/__tests__/Stats.test.jsx
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+ act,
+} from '@testing-library/react';
+import StatsPage from '../Stats';
+import useStreamProfilesStore from '../../store/streamProfiles';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import useChannelsStore from '../../store/channels';
+import useLogosStore from '../../store/logos';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ getStatsByChannelId,
+ getVODStats,
+ stopChannel,
+ stopClient,
+ stopVODClient,
+} from '../../utils/pages/StatsUtils.js';
+
+// Mock dependencies
+vi.mock('../../store/channels');
+vi.mock('../../store/logos');
+vi.mock('../../store/streamProfiles');
+vi.mock('../../hooks/useLocalStorage');
+
+vi.mock('../../components/SystemEvents', () => ({
+ default: () => SystemEvents
+}));
+
+vi.mock('../../components/ErrorBoundary.jsx', () => ({
+ default: ({ children }) => {children}
+}));
+
+vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({
+ default: ({ vodContent, stopVODClient }) => (
+
+ VODConnectionCard - {vodContent.content_uuid}
+ {vodContent.connections?.map((conn) => (
+ stopVODClient(conn.client_id)}
+ >
+ Stop VOD Client
+
+ ))}
+
+ ),
+}));
+
+vi.mock('../../components/cards/StreamConnectionCard.jsx', () => ({
+ default: ({ channel }) => (
+
+ StreamConnectionCard - {channel.uuid}
+
+ ),
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+ Button: ({ children, onClick, loading, ...props }) => (
+
+ {children}
+
+ ),
+ Group: ({ children }) => {children}
,
+ LoadingOverlay: () => Loading...
,
+ Text: ({ children }) => {children},
+ Title: ({ children }) => {children}
,
+ NumberInput: ({ value, onChange, min, max, ...props }) => (
+ onChange(Number(e.target.value))}
+ min={min}
+ max={max}
+ {...props}
+ />
+ ),
+}));
+
+//mock stats utils
+vi.mock('../../utils/pages/StatsUtils', () => {
+ return {
+ fetchActiveChannelStats: vi.fn(),
+ getVODStats: vi.fn(),
+ getClientStats: vi.fn(),
+ getCombinedConnections: vi.fn(),
+ getStatsByChannelId: vi.fn(),
+ stopChannel: vi.fn(),
+ stopClient: vi.fn(),
+ stopVODClient: vi.fn(),
+ };
+});
+
+describe('StatsPage', () => {
+ const mockChannels = [
+ { id: 1, uuid: 'channel-1', name: 'Channel 1' },
+ { id: 2, uuid: 'channel-2', name: 'Channel 2' },
+ ];
+
+ const mockChannelsByUUID = {
+ 'channel-1': mockChannels[0],
+ 'channel-2': mockChannels[1],
+ };
+
+ const mockStreamProfiles = [
+ { id: 1, name: 'Profile 1' },
+ ];
+
+ const mockLogos = {
+ 'logo-1': 'logo-url-1',
+ };
+
+ const mockChannelStats = {
+ channels: [
+ { channel_id: 1, uuid: 'channel-1', connections: 2 },
+ { channel_id: 2, uuid: 'channel-2', connections: 1 },
+ ],
+ };
+
+ const mockVODStats = {
+ vod_connections: [
+ {
+ content_uuid: 'vod-1',
+ connections: [
+ { client_id: 'client-1', ip: '192.168.1.1' },
+ ],
+ },
+ ],
+ };
+
+ const mockProcessedChannelHistory = {
+ 1: { id: 1, uuid: 'channel-1', connections: 2 },
+ 2: { id: 2, uuid: 'channel-2', connections: 1 },
+ };
+
+ const mockClients = [
+ { id: 'client-1', channel_id: 1 },
+ { id: 'client-2', channel_id: 1 },
+ { id: 'client-3', channel_id: 2 },
+ ];
+
+ const mockCombinedConnections = [
+ { id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
+ { id: 2, type: 'stream', data: { id: 2, uuid: 'channel-2' } },
+ { id: 3, type: 'vod', data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] } },
+ ];
+
+ let mockSetChannelStats;
+ let mockSetRefreshInterval;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockSetChannelStats = vi.fn();
+ mockSetRefreshInterval = vi.fn();
+
+ // Setup store mocks
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ channels: mockChannels,
+ channelsByUUID: mockChannelsByUUID,
+ stats: { channels: mockChannelStats.channels },
+ setChannelStats: mockSetChannelStats,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useStreamProfilesStore.mockImplementation((selector) => {
+ const state = {
+ profiles: mockStreamProfiles,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ logos: mockLogos,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLocalStorage.mockReturnValue([5, mockSetRefreshInterval]);
+
+ // Setup API mocks
+ fetchActiveChannelStats.mockResolvedValue(mockChannelStats);
+ getVODStats.mockResolvedValue(mockVODStats);
+ getStatsByChannelId.mockReturnValue(mockProcessedChannelHistory);
+ getClientStats.mockReturnValue(mockClients);
+ getCombinedConnections.mockReturnValue(mockCombinedConnections);
+ stopVODClient.mockResolvedValue({});
+
+ delete window.location;
+ window.location = { pathname: '/stats' };
+ });
+
+ describe('Initial Rendering', () => {
+ it('renders the page title', async () => {
+ render();
+ await screen.findByText('Active Connections')
+ });
+
+ it('fetches initial stats on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('displays connection counts', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 streams/)).toBeInTheDocument();
+ expect(screen.getByText(/1 VOD connection/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders SystemEvents component', async () => {
+ render();
+ await screen.findByTestId('system-events')
+ });
+ });
+
+ describe('Refresh Interval Controls', () => {
+ it('displays default refresh interval', () => {
+ render();
+
+ waitFor(() => {
+ const input = screen.getByTestId('refresh-interval-input');
+ expect(input).toHaveValue(5);
+ });
+ });
+
+ it('updates refresh interval when input changes', async () => {
+ render();
+
+ const input = screen.getByTestId('refresh-interval-input');
+ fireEvent.change(input, { target: { value: '10' } });
+
+ await waitFor(() => {
+ expect(mockSetRefreshInterval).toHaveBeenCalledWith(10);
+ });
+ });
+
+ it('displays polling active message when interval > 0', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Refreshing every 5s/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays disabled message when interval is 0', async () => {
+ useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
+ render();
+
+ await screen.findByText('Refreshing disabled')
+ });
+ });
+
+ describe('Auto-refresh Polling', () => {
+ it('sets up polling interval for stats', async () => {
+ vi.useFakeTimers();
+
+ render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+
+ // Advance timers by 5 seconds
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+
+ vi.useRealTimers();
+ });
+
+ it('does not poll when interval is 0', async () => {
+ vi.useFakeTimers();
+
+ useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
+ render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(10000);
+ });
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+
+ it('clears interval on unmount', async () => {
+ vi.useFakeTimers();
+
+ const { unmount } = render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ unmount();
+
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ // Should not fetch again after unmount
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('Manual Refresh', () => {
+ it('refreshes stats when Refresh Now button is clicked', async () => {
+ render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ const refreshButton = screen.getByText('Refresh Now');
+ fireEvent.click(refreshButton);
+
+ await waitFor(() => {
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+
+ describe('Connection Display', () => {
+ it('renders stream connection cards', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('stream-connection-card-channel-1')).toBeInTheDocument();
+ expect(screen.getByTestId('stream-connection-card-channel-2')).toBeInTheDocument();
+ });
+ });
+
+ it('renders VOD connection cards', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument();
+ });
+ });
+
+ it('displays empty state when no connections', async () => {
+ getCombinedConnections.mockReturnValue([]);
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('No active connections')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('VOD Client Management', () => {
+ it('stops VOD client when stop button is clicked', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('stop-vod-client-client-1')).toBeInTheDocument();
+ });
+
+ const stopButton = screen.getByTestId('stop-vod-client-client-1');
+ fireEvent.click(stopButton);
+
+ await waitFor(() => {
+ expect(stopVODClient).toHaveBeenCalledWith('client-1');
+ });
+ });
+
+ it('refreshes VOD stats after stopping client', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+ });
+
+ const stopButton = await screen.findByTestId('stop-vod-client-client-1');
+ fireEvent.click(stopButton);
+
+ await waitFor(() => {
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+
+ describe('Stats Processing', () => {
+ it('processes channel stats correctly', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getStatsByChannelId).toHaveBeenCalledWith(
+ mockChannelStats,
+ expect.any(Object),
+ mockChannelsByUUID,
+ mockChannels,
+ mockStreamProfiles
+ );
+ });
+ });
+
+ it('updates clients based on processed stats', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getClientStats).toHaveBeenCalledWith(mockProcessedChannelHistory);
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('handles fetchActiveChannelStats error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ fetchActiveChannelStats.mockRejectedValue(new Error('API Error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Error fetching channel stats:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+
+ it('handles getVODStats error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ getVODStats.mockRejectedValue(new Error('VOD API Error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Error fetching VOD stats:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Connection Count Display', () => {
+ it('displays singular form for 1 stream', async () => {
+ getCombinedConnections.mockReturnValue([
+ { id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
+ ]);
+ getStatsByChannelId.mockReturnValue({ 1: { id: 1, uuid: 'channel-1' } });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/1 stream/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays plural form for multiple VOD connections', async () => {
+ const multiVODStats = {
+ vod_connections: [
+ { content_uuid: 'vod-1', connections: [{ client_id: 'c1' }] },
+ { content_uuid: 'vod-2', connections: [{ client_id: 'c2' }] },
+ ],
+ };
+ getVODStats.mockResolvedValue(multiVODStats);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 VOD connections/)).toBeInTheDocument();
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Users.test.jsx b/frontend/src/pages/__tests__/Users.test.jsx
new file mode 100644
index 00000000..3ee63627
--- /dev/null
+++ b/frontend/src/pages/__tests__/Users.test.jsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import UsersPage from '../Users';
+import useAuthStore from '../../store/auth';
+
+vi.mock('../../store/auth');
+vi.mock('../../components/tables/UsersTable', () => ({
+ default: () => UsersTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+}));
+
+describe('UsersPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing when user is not authenticated', () => {
+ useAuthStore.mockReturnValue({ id: null });
+
+ const { container } = render();
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.queryByTestId('users-table')).not.toBeInTheDocument();
+ });
+
+ it('renders UsersTable when user is authenticated', () => {
+ useAuthStore.mockReturnValue({ id: 1, email: 'test@example.com' });
+
+ render();
+
+ expect(screen.getByTestId('users-table')).toBeInTheDocument();
+ });
+
+ it('handles user with id 0 as authenticated', () => {
+ useAuthStore.mockReturnValue({ id: 0 });
+
+ const { container } = render();
+
+ // id: 0 is falsy, so should render empty
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('switches from unauthenticated to authenticated state', () => {
+ useAuthStore.mockReturnValue({ id: null });
+
+ render();
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+
+ useAuthStore.mockReturnValue({ id: 1 });
+
+ render();
+
+ expect(screen.getByTestId('users-table')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/VODs.test.jsx b/frontend/src/pages/__tests__/VODs.test.jsx
new file mode 100644
index 00000000..6e7c00ec
--- /dev/null
+++ b/frontend/src/pages/__tests__/VODs.test.jsx
@@ -0,0 +1,468 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import VODsPage from '../VODs';
+import useVODStore from '../../store/useVODStore';
+import {
+ filterCategoriesToEnabled,
+ getCategoryOptions,
+} from '../../utils/pages/VODsUtils.js';
+
+vi.mock('../../store/useVODStore');
+
+vi.mock('../../components/SeriesModal', () => ({
+ default: ({ opened, series, onClose }) =>
+ opened ? (
+
+
{series?.name}
+
Close
+
+ ) : null
+}));
+vi.mock('../../components/VODModal', () => ({
+ default: ({ opened, vod, onClose }) =>
+ opened ? (
+
+ ) : null
+}));
+vi.mock('../../components/cards/VODCard', () => ({
+ default: ({ vod, onClick }) => (
+ onClick(vod)}>
+
{vod.name}
+
+ )
+}));
+vi.mock('../../components/cards/SeriesCard', () => ({
+ default: ({ series, onClick }) => (
+ onClick(series)}>
+
{series.name}
+
+ )
+}));
+
+vi.mock('@mantine/core', () => {
+ const gridComponent = ({ children, ...props }) => {children}
;
+ gridComponent.Col = ({ children, ...props }) => {children}
;
+
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Stack: ({ children, ...props }) => {children}
,
+ Group: ({ children, ...props }) => {children}
,
+ Flex: ({ children, ...props }) => {children}
,
+ Title: ({ children, ...props }) => {children}
,
+ TextInput: ({ value, onChange, placeholder, icon }) => (
+
+ {icon}
+
+
+ ),
+ Select: ({ value, onChange, data, label, placeholder }) => (
+
+ {label && }
+ onChange?.(e.target.value)}
+ aria-label={placeholder || label}
+ >
+ {data?.map((option) => (
+
+ ))}
+
+
+ ),
+ SegmentedControl: ({ value, onChange, data }) => (
+
+ {data.map((item) => (
+ onChange(item.value)}
+ data-active={value === item.value}
+ >
+ {item.label}
+
+ ))}
+
+ ),
+ Pagination: ({ page, onChange, total }) => (
+
+ onChange(page - 1)} disabled={page === 1}>
+ Prev
+
+ {page} of {total}
+ onChange(page + 1)} disabled={page === total}>
+ Next
+
+
+ ),
+ Grid: gridComponent,
+ GridCol: gridComponent.Col,
+ Loader: () => Loading...
,
+ LoadingOverlay: ({ visible }) =>
+ visible ? Loading...
: null,
+ };
+});
+
+vi.mock('../../utils/pages/VODsUtils.js', () => {
+ return {
+ filterCategoriesToEnabled: vi.fn(),
+ getCategoryOptions: vi.fn(),
+ };
+});
+
+describe('VODsPage', () => {
+ const mockFetchContent = vi.fn();
+ const mockFetchCategories = vi.fn();
+ const mockSetFilters = vi.fn();
+ const mockSetPage = vi.fn();
+ const mockSetPageSize = vi.fn();
+
+ const defaultStoreState = {
+ currentPageContent: [],
+ categories: {},
+ filters: { type: 'all', search: '', category: '' },
+ currentPage: 1,
+ totalCount: 0,
+ pageSize: 12,
+ setFilters: mockSetFilters,
+ setPage: mockSetPage,
+ setPageSize: mockSetPageSize,
+ fetchContent: mockFetchContent,
+ fetchCategories: mockFetchCategories,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetchContent.mockResolvedValue();
+ mockFetchCategories.mockResolvedValue();
+ filterCategoriesToEnabled.mockReturnValue({});
+ getCategoryOptions.mockReturnValue([]);
+ useVODStore.mockImplementation((selector) => selector(defaultStoreState));
+ localStorage.clear();
+ });
+
+ it('renders the page title', async () => {
+ render();
+ await screen.findByText('Video on Demand');
+ });
+
+ it('fetches categories on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockFetchCategories).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('fetches content on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('displays loader during initial load', async () => {
+ render();
+ await screen.findByTestId('loader');
+ });
+
+ it('displays content after loading', async () => {
+ const stateWithContent = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Movie 1', contentType: 'movie' },
+ { id: 2, name: 'Series 1', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithContent));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Movie 1')).toBeInTheDocument();
+ expect(screen.getByText('Series 1')).toBeInTheDocument();
+ });
+ });
+
+ it('renders VOD cards for movies', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('vod-card')).toBeInTheDocument();
+ });
+ });
+
+ it('renders series cards for series', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Series 1', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('series-card')).toBeInTheDocument();
+ });
+ });
+
+ it('opens VOD modal when VOD card is clicked', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Movie', contentType: 'movie' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('vod-card'));
+ });
+
+ expect(screen.getByTestId('vod-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('vod-name')).toHaveTextContent('Test Movie');
+ });
+
+ it('opens series modal when series card is clicked', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Series', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('series-card'));
+ });
+
+ expect(screen.getByTestId('series-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('series-name')).toHaveTextContent('Test Series');
+ });
+
+ it('closes VOD modal when close button is clicked', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Movie', contentType: 'movie' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('vod-card'));
+ });
+
+ fireEvent.click(screen.getByText('Close'));
+
+ expect(screen.queryByTestId('vod-modal')).not.toBeInTheDocument();
+ });
+
+ it('closes series modal when close button is clicked', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Series', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('series-card'));
+ });
+
+ fireEvent.click(screen.getByText('Close'));
+
+ expect(screen.queryByTestId('series-modal')).not.toBeInTheDocument();
+ });
+
+ it('updates filters when search input changes', async () => {
+ render();
+
+ const searchInput = screen.getByPlaceholderText('Search VODs...');
+ fireEvent.change(searchInput, { target: { value: 'test search' } });
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({ search: 'test search' });
+ });
+ });
+
+ it('updates filters and resets page when type changes', async () => {
+ render();
+
+ const moviesButton = screen.getByText('Movies');
+ fireEvent.click(moviesButton);
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({
+ type: 'movies',
+ category: '',
+ });
+ expect(mockSetPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('updates filters and resets page when category changes', async () => {
+ getCategoryOptions.mockReturnValue([
+ { value: 'action', label: 'Action' },
+ ]);
+
+ render();
+
+ const categorySelect = screen.getByLabelText('Category');
+ fireEvent.change(categorySelect, { target: { value: 'action' } });
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({ category: 'action' });
+ expect(mockSetPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('updates page size and saves to localStorage', async () => {
+ render();
+
+ const pageSizeSelect = screen.getByLabelText('Page Size');
+ fireEvent.change(pageSizeSelect, { target: { value: '24' } });
+
+ await waitFor(() => {
+ expect(mockSetPageSize).toHaveBeenCalledWith(24);
+ expect(localStorage.getItem('vodsPageSize')).toBe('24');
+ });
+ });
+
+ it('loads page size from localStorage on mount', async () => {
+ localStorage.setItem('vodsPageSize', '48');
+
+ render();
+
+ await waitFor(() => {
+ expect(mockSetPageSize).toHaveBeenCalledWith(48);
+ });
+ });
+
+ it('displays pagination when total pages > 1', async () => {
+ const stateWithPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 25,
+ pageSize: 12,
+ };
+ useVODStore.mockImplementation((selector) =>
+ selector(stateWithPagination)
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ });
+ });
+
+ it('does not display pagination when total pages <= 1', async () => {
+ const stateNoPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 5,
+ pageSize: 12,
+ };
+ useVODStore.mockImplementation((selector) => selector(stateNoPagination));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
+ });
+ });
+
+ it('changes page when pagination is clicked', async () => {
+ const stateWithPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 25,
+ pageSize: 12,
+ currentPage: 1,
+ };
+ useVODStore.mockImplementation((selector) =>
+ selector(stateWithPagination)
+ );
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Next'));
+ });
+
+ expect(mockSetPage).toHaveBeenCalledWith(2);
+ });
+
+ it('refetches content when filters change', async () => {
+ const { rerender } = render();
+
+ const updatedState = {
+ ...defaultStoreState,
+ filters: { type: 'movies', search: '', category: '' },
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('refetches content when page changes', async () => {
+ const { rerender } = render();
+
+ const updatedState = {
+ ...defaultStoreState,
+ currentPage: 2,
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('refetches content when page size changes', async () => {
+ const { rerender } = render();
+
+ const updatedState = {
+ ...defaultStoreState,
+ pageSize: 24,
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js
index 58a6d292..01bbe846 100644
--- a/frontend/src/pages/__tests__/guideUtils.test.js
+++ b/frontend/src/pages/__tests__/guideUtils.test.js
@@ -1,100 +1,1108 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import dayjs from 'dayjs';
-import {
- PROGRAM_HEIGHT,
- EXPANDED_PROGRAM_HEIGHT,
- buildChannelIdMap,
- mapProgramsByChannel,
- computeRowHeights,
-} from '../guideUtils.js';
+import utc from 'dayjs/plugin/utc';
+import * as guideUtils from '../guideUtils';
+import * as dateTimeUtils from '../../utils/dateTimeUtils';
+import API from '../../api';
+
+dayjs.extend(utc);
+
+vi.mock('../../utils/dateTimeUtils', () => ({
+ convertToMs: vi.fn((time) => {
+ if (typeof time === 'number') return time;
+ return dayjs(time).valueOf();
+ }),
+ initializeTime: vi.fn((time) => {
+ if (typeof time === 'number') return dayjs(time);
+ return dayjs(time);
+ }),
+ startOfDay: vi.fn((time) => dayjs(time).startOf('day')),
+ isBefore: vi.fn((a, b) => dayjs(a).isBefore(dayjs(b))),
+ isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))),
+ isSame: vi.fn((a, b, unit) => dayjs(a).isSame(dayjs(b), unit)),
+ add: vi.fn((time, amount, unit) => dayjs(time).add(amount, unit)),
+ diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)),
+ format: vi.fn((time, formatStr) => dayjs(time).format(formatStr)),
+ getNow: vi.fn(() => dayjs()),
+ getNowMs: vi.fn(() => dayjs().valueOf()),
+ roundToNearest: vi.fn((time, minutes) => {
+ const m = dayjs(time).minute();
+ const rounded = Math.round(m / minutes) * minutes;
+ return dayjs(time).minute(rounded).second(0).millisecond(0);
+ }),
+}));
+
+vi.mock('../../api', () => ({
+ default: {
+ getGrid: vi.fn(),
+ createRecording: vi.fn(),
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRules: vi.fn(),
+ deleteSeriesRule: vi.fn(),
+ listSeriesRules: vi.fn(),
+ },
+}));
describe('guideUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
describe('buildChannelIdMap', () => {
- it('maps tvg ids from epg records and falls back to channel uuid', () => {
+ it('should create map with channel UUIDs when no EPG data', () => {
const channels = [
- { id: 1, epg_data_id: 'epg-1', uuid: 'uuid-1' },
- { id: 2, epg_data_id: null, uuid: 'uuid-2' },
+ { id: 1, uuid: 'uuid-1', epg_data_id: null },
+ { id: 2, uuid: 'uuid-2', epg_data_id: null },
+ ];
+ const tvgsById = {};
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ expect(result.get('uuid-2')).toEqual([2]);
+ });
+
+ it('should use tvg_id from EPG data for regular sources', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const tvgsById = {
- 'epg-1': { tvg_id: 'alpha' },
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
};
- const map = buildChannelIdMap(channels, tvgsById);
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
- expect(map.get('alpha')).toBe(1);
- expect(map.get('uuid-2')).toBe(2);
+ expect(result.get('tvg-123')).toEqual([1]);
+ });
+
+ it('should use channel UUID for dummy EPG sources', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'dummy' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ });
+
+ it('should group multiple channels with same tvg_id', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ { id: 2, uuid: 'uuid-2', epg_data_id: 'epg-2' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'shared-tvg', epg_source: 'source-1' },
+ 'epg-2': { tvg_id: 'shared-tvg', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('shared-tvg')).toEqual([1, 2]);
+ });
+
+ it('should fall back to UUID when tvg_id is null', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: null, epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('uuid-1')).toEqual([1]);
});
});
describe('mapProgramsByChannel', () => {
- it('groups programs by channel and sorts them by start time', () => {
+ it('should return empty map when no programs', () => {
+ const channelIdByTvgId = new Map();
+
+ const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId);
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should return empty map when no channel mapping', () => {
+ const programs = [{ tvg_id: 'tvg-1' }];
+
+ const result = guideUtils.mapProgramsByChannel(programs, new Map());
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should map programs to channels', () => {
+ const nowMs = 1000000;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
const programs = [
{
- id: 10,
- tvg_id: 'alpha',
- start_time: dayjs('2025-01-01T02:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T03:00:00Z').toISOString(),
- title: 'Late Show',
- },
- {
- id: 11,
- tvg_id: 'alpha',
- start_time: dayjs('2025-01-01T01:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T02:00:00Z').toISOString(),
- title: 'Evening News',
- },
- {
- id: 20,
- tvg_id: 'beta',
- start_time: dayjs('2025-01-01T00:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T01:00:00Z').toISOString(),
- title: 'Morning Show',
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
},
];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const channelIdByTvgId = new Map([
- ['alpha', 1],
- ['beta', 2],
- ]);
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
- const map = mapProgramsByChannel(programs, channelIdByTvgId);
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(1)[0]).toMatchObject({
+ id: 1,
+ tvg_id: 'tvg-1',
+ });
+ });
- expect(map.get(1)).toHaveLength(2);
- expect(map.get(1)?.map((item) => item.id)).toEqual([11, 10]);
- expect(map.get(2)).toHaveLength(1);
- expect(map.get(2)?.[0].startMs).toBeTypeOf('number');
- expect(map.get(2)?.[0].endMs).toBeTypeOf('number');
+ it('should precompute startMs and endMs', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.convertToMs.mockImplementation((time) =>
+ typeof time === 'number' ? time : dayjs(time).valueOf()
+ );
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0]).toHaveProperty('startMs');
+ expect(result.get(1)[0]).toHaveProperty('endMs');
+ });
+
+ it('should mark program as live when now is between start and end', () => {
+ const startMs = 1000;
+ const endMs = 2000;
+ const nowMs = 1500;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs,
+ endMs,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].isLive).toBe(true);
+ expect(result.get(1)[0].isPast).toBe(false);
+ });
+
+ it('should mark program as past when now is after end', () => {
+ const startMs = 1000;
+ const endMs = 2000;
+ const nowMs = 3000;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs,
+ endMs,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].isLive).toBe(false);
+ expect(result.get(1)[0].isPast).toBe(true);
+ });
+
+ it('should add program to multiple channels with same tvg_id', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(2)).toHaveLength(1);
+ expect(result.get(3)).toHaveLength(1);
+ });
+
+ it('should sort programs by start time', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+
+ const programs = [
+ {
+ id: 2,
+ tvg_id: 'tvg-1',
+ startMs: 2000,
+ endMs: 3000,
+ start_time: '2024-01-15T11:00:00Z',
+ end_time: '2024-01-15T12:00:00Z',
+ },
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs: 1000,
+ endMs: 2000,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].id).toBe(1);
+ expect(result.get(1)[1].id).toBe(2);
});
});
describe('computeRowHeights', () => {
- it('returns program heights with expanded rows when needed', () => {
- const filteredChannels = [
- { id: 1 },
- { id: 2 },
+ it('should return empty array when no channels', () => {
+ const result = guideUtils.computeRowHeights([], new Map(), null);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return default height for all channels when none expanded', () => {
+ const channels = [{ id: 1 }, { id: 2 }];
+ const programsByChannelId = new Map();
+
+ const result = guideUtils.computeRowHeights(channels, programsByChannelId, null);
+
+ expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ });
+
+ it('should return expanded height for channel with expanded program', () => {
+ const channels = [{ id: 1 }, { id: 2 }];
+ const programsByChannelId = new Map([
+ [1, [{ id: 'program-1' }]],
+ [2, [{ id: 'program-2' }]],
+ ]);
+
+ const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1');
+
+ expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ });
+
+ it('should use custom heights when provided', () => {
+ const channels = [{ id: 1 }];
+ const programsByChannelId = new Map([[1, [{ id: 'program-1' }]]]);
+ const customDefault = 100;
+ const customExpanded = 200;
+
+ const result = guideUtils.computeRowHeights(
+ channels,
+ programsByChannelId,
+ 'program-1',
+ customDefault,
+ customExpanded
+ );
+
+ expect(result).toEqual([customExpanded]);
+ });
+ });
+
+ describe('fetchPrograms', () => {
+ it('should fetch and transform programs', async () => {
+ const mockPrograms = [
+ {
+ id: 1,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ API.getGrid.mockResolvedValue(mockPrograms);
+ dateTimeUtils.convertToMs.mockReturnValue(1000);
+
+ const result = await guideUtils.fetchPrograms();
+
+ expect(API.getGrid).toHaveBeenCalledTimes(1);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveProperty('startMs');
+ expect(result[0]).toHaveProperty('endMs');
+ });
+ });
+
+ describe('sortChannels', () => {
+ it('should sort channels by channel number', () => {
+ const channels = {
+ 1: { id: 1, channel_number: 3 },
+ 2: { id: 2, channel_number: 1 },
+ 3: { id: 3, channel_number: 2 },
+ };
+
+ const result = guideUtils.sortChannels(channels);
+
+ expect(result[0].channel_number).toBe(1);
+ expect(result[1].channel_number).toBe(2);
+ expect(result[2].channel_number).toBe(3);
+ });
+
+ it('should put channels without number at end', () => {
+ const channels = {
+ 1: { id: 1, channel_number: 2 },
+ 2: { id: 2, channel_number: null },
+ 3: { id: 3, channel_number: 1 },
+ };
+
+ const result = guideUtils.sortChannels(channels);
+
+ expect(result[0].channel_number).toBe(1);
+ expect(result[1].channel_number).toBe(2);
+ expect(result[2].channel_number).toBeNull();
+ });
+ });
+
+ describe('filterGuideChannels', () => {
+ it('should return all channels when no filters', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
];
- const programsByChannel = new Map([
- [1, [{ id: 10 }, { id: 11 }]],
- [2, [{ id: 20 }]],
- ]);
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
- const collapsed = computeRowHeights(
- filteredChannels,
- programsByChannel,
- null
- );
- expect(collapsed).toEqual([PROGRAM_HEIGHT, PROGRAM_HEIGHT]);
+ expect(result).toHaveLength(2);
+ });
- const expanded = computeRowHeights(
- filteredChannels,
- programsByChannel,
- 10
- );
- expect(expanded).toEqual([
- EXPANDED_PROGRAM_HEIGHT,
- PROGRAM_HEIGHT,
- ]);
+ it('should filter by search query', () => {
+ const channels = [
+ { id: 1, name: 'ESPN' },
+ { id: 2, name: 'CNN' },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {});
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('ESPN');
+ });
+
+ it('should filter by channel group', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1', channel_group_id: 1 },
+ { id: 2, name: 'Channel 2', channel_group_id: 2 },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {});
+
+ expect(result).toHaveLength(1);
+ expect(result[0].channel_group_id).toBe(1);
+ });
+
+ it('should filter by profile with array of channels', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const profiles = {
+ profile1: {
+ channels: [
+ { id: 1, enabled: true },
+ { id: 2, enabled: false },
+ ],
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('should filter by profile with Set of channels', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const profiles = {
+ profile1: {
+ channels: new Set([1]),
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('should apply multiple filters together', () => {
+ const channels = [
+ { id: 1, name: 'ESPN', channel_group_id: 1 },
+ { id: 2, name: 'ESPN2', channel_group_id: 2 },
+ { id: 3, name: 'CNN', channel_group_id: 1 },
+ ];
+ const profiles = {
+ profile1: {
+ channels: [
+ { id: 1, enabled: true },
+ { id: 3, enabled: true },
+ ],
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+ });
+
+ describe('calculateEarliestProgramStart', () => {
+ it('should return default when no programs', () => {
+ const defaultStart = dayjs('2024-01-15T00:00:00Z');
+
+ const result = guideUtils.calculateEarliestProgramStart([], defaultStart);
+
+ expect(result).toBe(defaultStart);
+ });
+
+ it('should return earliest program start', () => {
+ dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+
+ const programs = [
+ { start_time: '2024-01-15T12:00:00Z' },
+ { start_time: '2024-01-15T10:00:00Z' },
+ { start_time: '2024-01-15T14:00:00Z' },
+ ];
+ const defaultStart = dayjs.utc('2024-01-16T00:00:00Z');
+
+ const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart);
+
+ expect(result.hour()).toBe(10);
+ });
+ });
+
+ describe('calculateLatestProgramEnd', () => {
+ it('should return default when no programs', () => {
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+
+ const result = guideUtils.calculateLatestProgramEnd([], defaultEnd);
+
+ expect(result).toBe(defaultEnd);
+ });
+
+ it('should return latest program end', () => {
+ dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
+ dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b)));
+
+ const programs = [
+ { end_time: '2024-01-15T12:00:00Z' },
+ { end_time: '2024-01-15T18:00:00Z' },
+ { end_time: '2024-01-15T14:00:00Z' },
+ ];
+ const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z');
+
+ const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd);
+
+ expect(result.hour()).toBe(18);
+ });
+ });
+
+ describe('calculateStart', () => {
+ it('should return earliest when before default', () => {
+ const earliest = dayjs('2024-01-15T08:00:00Z');
+ const defaultStart = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(true);
+
+ const result = guideUtils.calculateStart(earliest, defaultStart);
+
+ expect(result).toBe(earliest);
+ });
+
+ it('should return default when earliest is after', () => {
+ const earliest = dayjs('2024-01-15T12:00:00Z');
+ const defaultStart = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+
+ const result = guideUtils.calculateStart(earliest, defaultStart);
+
+ expect(result).toBe(defaultStart);
+ });
+ });
+
+ describe('calculateEnd', () => {
+ it('should return latest when after default', () => {
+ const latest = dayjs('2024-01-16T02:00:00Z');
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+ dateTimeUtils.isAfter.mockReturnValue(true);
+
+ const result = guideUtils.calculateEnd(latest, defaultEnd);
+
+ expect(result).toBe(latest);
+ });
+
+ it('should return default when latest is before', () => {
+ const latest = dayjs('2024-01-15T22:00:00Z');
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+ dateTimeUtils.isAfter.mockReturnValue(false);
+
+ const result = guideUtils.calculateEnd(latest, defaultEnd);
+
+ expect(result).toBe(defaultEnd);
+ });
+ });
+
+ describe('mapChannelsById', () => {
+ it('should create map of channels by id', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+
+ const result = guideUtils.mapChannelsById(channels);
+
+ expect(result.get(1).name).toBe('Channel 1');
+ expect(result.get(2).name).toBe('Channel 2');
+ });
+ });
+
+ describe('mapRecordingsByProgramId', () => {
+ it('should return empty map for null recordings', () => {
+ const result = guideUtils.mapRecordingsByProgramId(null);
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should map recordings by program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ custom_properties: {
+ program: { id: 'program-1' },
+ },
+ },
+ {
+ id: 2,
+ custom_properties: {
+ program: { id: 'program-2' },
+ },
+ },
+ ];
+
+ const result = guideUtils.mapRecordingsByProgramId(recordings);
+
+ expect(result.get('program-1').id).toBe(1);
+ expect(result.get('program-2').id).toBe(2);
+ });
+
+ it('should skip recordings without program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ custom_properties: {},
+ },
+ ];
+
+ const result = guideUtils.mapRecordingsByProgramId(recordings);
+
+ expect(result.size).toBe(0);
+ });
+ });
+
+ describe('formatTime', () => {
+ it('should return "Today" for today', () => {
+ const today = dayjs();
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValueOnce(true);
+
+ const result = guideUtils.formatTime(today, 'MM/DD');
+
+ expect(result).toBe('Today');
+ });
+
+ it('should return "Tomorrow" for tomorrow', () => {
+ const today = dayjs();
+ const tomorrow = today.add(1, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true);
+
+ const result = guideUtils.formatTime(tomorrow, 'MM/DD');
+
+ expect(result).toBe('Tomorrow');
+ });
+
+ it('should return day name within a week', () => {
+ const today = dayjs();
+ const future = today.add(3, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValue(false);
+ dateTimeUtils.isBefore.mockReturnValue(true);
+ dateTimeUtils.format.mockReturnValue('Wednesday');
+
+ const result = guideUtils.formatTime(future, 'MM/DD');
+
+ expect(result).toBe('Wednesday');
+ });
+
+ it('should return formatted date beyond a week', () => {
+ const today = dayjs();
+ const future = today.add(10, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValue(false);
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.format.mockReturnValue('01/25');
+
+ const result = guideUtils.formatTime(future, 'MM/DD');
+
+ expect(result).toBe('01/25');
+ });
+ });
+
+ describe('calculateHourTimeline', () => {
+ it('should generate hours between start and end', () => {
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T13:00:00Z');
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isSame.mockReturnValue(true);
+
+ const formatDayLabel = vi.fn((time) => 'Today');
+ const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+
+ expect(result).toHaveLength(3);
+ expect(formatDayLabel).toHaveBeenCalledTimes(3);
+ });
+
+ it('should mark new day transitions', () => {
+ const start = dayjs('2024-01-15T23:00:00Z');
+ const end = dayjs('2024-01-16T02:00:00Z');
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isSame.mockImplementation((a, b, unit) => dayjs(a).isSame(dayjs(b), unit));
+
+ const formatDayLabel = vi.fn((time) => 'Day');
+ const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+
+ expect(result[0].isNewDay).toBe(true);
+ });
+ });
+
+ describe('calculateNowPosition', () => {
+ it('should return -1 when now is before start', () => {
+ const now = dayjs('2024-01-15T09:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(true);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBe(-1);
+ });
+
+ it('should return -1 when now is after end', () => {
+ const now = dayjs('2024-01-15T19:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.isAfter.mockReturnValue(true);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBe(-1);
+ });
+
+ it('should calculate position when now is between start and end', () => {
+ const now = dayjs('2024-01-15T11:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.isAfter.mockReturnValue(false);
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBeGreaterThan(0);
+ });
+ });
+
+ describe('calculateScrollPosition', () => {
+ it('should calculate scroll position for current time', () => {
+ const now = dayjs('2024-01-15T11:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const rounded = dayjs('2024-01-15T11:00:00Z');
+ dateTimeUtils.roundToNearest.mockReturnValue(rounded);
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateScrollPosition(now, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should return 0 when calculated position is negative', () => {
+ const now = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const rounded = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.roundToNearest.mockReturnValue(rounded);
+ dateTimeUtils.diff.mockReturnValue(0);
+
+ const result = guideUtils.calculateScrollPosition(now, start);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('matchChannelByTvgId', () => {
+ it('should return null when no matching channel ids', () => {
+ const channelIdByTvgId = new Map();
+ const channelById = new Map();
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return first matching channel', () => {
+ const channel = { id: 1, name: 'Channel 1' };
+ const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
+ const channelById = new Map([[1, channel]]);
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBe(channel);
+ });
+
+ it('should return null when channel not in channelById map', () => {
+ const channelIdByTvgId = new Map([['tvg-1', [999]]]);
+ const channelById = new Map();
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('fetchRules', () => {
+ it('should fetch series rules from API', async () => {
+ const mockRules = [{ id: 1, tvg_id: 'tvg-1' }];
+ API.listSeriesRules.mockResolvedValue(mockRules);
+
+ const result = await guideUtils.fetchRules();
+
+ expect(API.listSeriesRules).toHaveBeenCalledTimes(1);
+ expect(result).toBe(mockRules);
+ });
+ });
+
+ describe('getRuleByProgram', () => {
+ it('should return null when no rules', () => {
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(null, program);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should find rule by tvg_id without title', () => {
+ const rules = [{ tvg_id: 'tvg-1', title: null }];
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[0]);
+ });
+
+ it('should find rule by tvg_id and title', () => {
+ const rules = [
+ { tvg_id: 'tvg-1', title: 'Show A' },
+ { tvg_id: 'tvg-1', title: 'Show B' },
+ ];
+ const program = { tvg_id: 'tvg-1', title: 'Show B' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[1]);
+ });
+
+ it('should handle string comparison for tvg_id', () => {
+ const rules = [{ tvg_id: 123, title: null }];
+ const program = { tvg_id: '123', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[0]);
+ });
+ });
+
+ describe('createRecording', () => {
+ it('should create recording via API', async () => {
+ const channel = { id: 1 };
+ const program = {
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ };
+
+ await guideUtils.createRecording(channel, program);
+
+ expect(API.createRecording).toHaveBeenCalledWith({
+ channel: '1',
+ start_time: program.start_time,
+ end_time: program.end_time,
+ custom_properties: { program },
+ });
+ });
+ });
+
+ describe('createSeriesRule', () => {
+ it('should create series rule via API', async () => {
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+ const mode = 'all';
+
+ await guideUtils.createSeriesRule(program, mode);
+
+ expect(API.createSeriesRule).toHaveBeenCalledWith({
+ tvg_id: program.tvg_id,
+ mode,
+ title: program.title,
+ });
+ });
+ });
+
+ describe('evaluateSeriesRule', () => {
+ it('should evaluate series rule via API', async () => {
+ const program = { tvg_id: 'tvg-1' };
+
+ await guideUtils.evaluateSeriesRule(program);
+
+ expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id);
+ });
+ });
+
+ describe('calculateLeftScrollPosition', () => {
+ it('should calculate left position using startMs', () => {
+ const program = {
+ startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(),
+ };
+ const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf();
+ dateTimeUtils.convertToMs.mockImplementation((time) => {
+ if (typeof time === 'number') return time;
+ return dayjs.utc(time).valueOf();
+ });
+
+ const result = guideUtils.calculateLeftScrollPosition(program, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should calculate left position from start_time when no startMs', () => {
+ const program = {
+ start_time: '2024-01-15T10:30:00Z',
+ };
+ const start = '2024-01-15T10:00:00Z';
+ dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf());
+
+ const result = guideUtils.calculateLeftScrollPosition(program, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('calculateDesiredScrollPosition', () => {
+ it('should subtract 20 from left position', () => {
+ const result = guideUtils.calculateDesiredScrollPosition(100);
+
+ expect(result).toBe(80);
+ });
+
+ it('should return 0 when result would be negative', () => {
+ const result = guideUtils.calculateDesiredScrollPosition(10);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('calculateScrollPositionByTimeClick', () => {
+ it('should calculate scroll position from time click', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 100, width: 450 }),
+ },
+ clientX: 325,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should snap to 15-minute increments', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 0, width: 450 }),
+ },
+ clientX: 112.5,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(75);
+
+ guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(dateTimeUtils.diff).toHaveBeenCalled();
+ });
+
+ it('should handle click at end of hour', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 0, width: 450 }),
+ },
+ clientX: 450,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(120);
+
+ const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour');
+ });
+ });
+
+ describe('getGroupOptions', () => {
+ it('should return only "All" when no channel groups', () => {
+ const result = guideUtils.getGroupOptions(null, []);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('all');
+ });
+
+ it('should include groups used by channels', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Sports' },
+ 2: { id: 2, name: 'News' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ { id: 2, channel_group_id: 1 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].label).toBe('Sports');
+ });
+
+ it('should exclude groups not used by any channel', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Sports' },
+ 2: { id: 2, name: 'News' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].label).toBe('Sports');
+ });
+
+ it('should sort groups alphabetically', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Z Group' },
+ 2: { id: 2, name: 'A Group' },
+ 3: { id: 3, name: 'M Group' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ { id: 2, channel_group_id: 2 },
+ { id: 3, channel_group_id: 3 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result[1].label).toBe('A Group');
+ expect(result[2].label).toBe('M Group');
+ expect(result[3].label).toBe('Z Group');
+ });
+ });
+
+ describe('getProfileOptions', () => {
+ it('should return only "All" when no profiles', () => {
+ const result = guideUtils.getProfileOptions(null);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('all');
+ });
+
+ it('should include all profiles except id 0', () => {
+ const profiles = {
+ 0: { id: '0', name: 'All' },
+ 1: { id: '1', name: 'Profile 1' },
+ 2: { id: '2', name: 'Profile 2' },
+ };
+
+ const result = guideUtils.getProfileOptions(profiles);
+
+ expect(result).toHaveLength(3);
+ expect(result[1].label).toBe('Profile 1');
+ expect(result[2].label).toBe('Profile 2');
+ });
+ });
+
+ describe('deleteSeriesRuleByTvgId', () => {
+ it('should delete series rule via API', async () => {
+ await guideUtils.deleteSeriesRuleByTvgId('tvg-1');
+
+ expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1');
+ });
+ });
+
+ describe('evaluateSeriesRulesByTvgId', () => {
+ it('should evaluate series rules via API', async () => {
+ await guideUtils.evaluateSeriesRulesByTvgId('tvg-1');
+
+ expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1');
});
});
});
diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js
index 1f4ff671..68bb74b2 100644
--- a/frontend/src/pages/guideUtils.js
+++ b/frontend/src/pages/guideUtils.js
@@ -1,7 +1,26 @@
-import dayjs from 'dayjs';
+import {
+ convertToMs,
+ initializeTime,
+ startOfDay,
+ isBefore,
+ isAfter,
+ isSame,
+ add,
+ diff,
+ format,
+ getNow,
+ getNowMs,
+ roundToNearest
+} from '../utils/dateTimeUtils.js';
+import API from '../api.js';
export const PROGRAM_HEIGHT = 90;
export const EXPANDED_PROGRAM_HEIGHT = 180;
+/** Layout constants */
+export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
+export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
+export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
+export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const map = new Map();
@@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
return map;
}
-export function mapProgramsByChannel(programs, channelIdByTvgId) {
+export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
if (!programs?.length || !channelIdByTvgId?.size) {
return new Map();
}
const map = new Map();
+ const nowMs = getNowMs();
+
programs.forEach((program) => {
const channelIds = channelIdByTvgId.get(String(program.tvg_id));
if (!channelIds || channelIds.length === 0) {
return;
}
- const startMs = program.startMs ?? dayjs(program.start_time).valueOf();
- const endMs = program.endMs ?? dayjs(program.end_time).valueOf();
+ const startMs = program.startMs ?? convertToMs(program.start_time);
+ const endMs = program.endMs ?? convertToMs(program.end_time);
const programData = {
...program,
startMs,
endMs,
+ programStart: initializeTime(program.startMs),
+ programEnd: initializeTime(program.endMs),
+ // Precompute live/past status
+ isLive: nowMs >= program.startMs && nowMs < program.endMs,
+ isPast: nowMs >= program.endMs,
};
// Add this program to all channels that share the same TVG ID
@@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) {
});
return map;
-}
+};
export function computeRowHeights(
filteredChannels,
@@ -94,3 +120,282 @@ export function computeRowHeights(
return expanded ? expandedHeight : defaultHeight;
});
}
+
+export const fetchPrograms = async () => {
+ console.log('Fetching program grid...');
+ const fetched = await API.getGrid(); // GETs your EPG grid
+ console.log(`Received ${fetched.length} programs`);
+
+ return fetched.map((program) => {
+ return {
+ ...program,
+ startMs: convertToMs(program.start_time),
+ endMs: convertToMs(program.end_time),
+ };
+ });
+};
+
+export const sortChannels = (channels) => {
+ // Include ALL channels, sorted by channel number - don't filter by EPG data
+ const sortedChannels = Object.values(channels).sort(
+ (a, b) =>
+ (a.channel_number || Infinity) - (b.channel_number || Infinity)
+ );
+
+ console.log(`Using all ${sortedChannels.length} available channels`);
+ return sortedChannels;
+}
+
+export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
+ return guideChannels.filter((channel) => {
+ // Search filter
+ if (searchQuery) {
+ if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
+ }
+
+ // Channel group filter
+ if (selectedGroupId !== 'all') {
+ if (channel.channel_group_id !== parseInt(selectedGroupId)) return false;
+ }
+
+ // Profile filter
+ if (selectedProfileId !== 'all') {
+ const profileChannels = profiles[selectedProfileId]?.channels || [];
+ const enabledChannelIds = Array.isArray(profileChannels)
+ ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
+ : profiles[selectedProfileId]?.channels instanceof Set
+ ? Array.from(profiles[selectedProfileId].channels)
+ : [];
+
+ if (!enabledChannelIds.includes(channel.id)) return false;
+ }
+
+ return true;
+ });
+}
+
+export const calculateEarliestProgramStart = (programs, defaultStart) => {
+ if (!programs.length) return defaultStart;
+ return programs.reduce((acc, p) => {
+ const s = initializeTime(p.start_time);
+ return isBefore(s, acc) ? s : acc;
+ }, defaultStart);
+}
+
+export const calculateLatestProgramEnd = (programs, defaultEnd) => {
+ if (!programs.length) return defaultEnd;
+ return programs.reduce((acc, p) => {
+ const e = initializeTime(p.end_time);
+ return isAfter(e, acc) ? e : acc;
+ }, defaultEnd);
+}
+
+export const calculateStart = (earliestProgramStart, defaultStart) => {
+ return isBefore(earliestProgramStart, defaultStart)
+ ? earliestProgramStart
+ : defaultStart;
+}
+
+export const calculateEnd = (latestProgramEnd, defaultEnd) => {
+ return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
+}
+
+export const mapChannelsById = (guideChannels) => {
+ const map = new Map();
+ guideChannels.forEach((channel) => {
+ map.set(channel.id, channel);
+ });
+ return map;
+}
+
+export const mapRecordingsByProgramId = (recordings) => {
+ const map = new Map();
+ (recordings || []).forEach((recording) => {
+ const programId = recording?.custom_properties?.program?.id;
+ if (programId != null) {
+ map.set(programId, recording);
+ }
+ });
+ return map;
+}
+
+export const formatTime = (time, dateFormat) => {
+ const today = startOfDay(getNow());
+ const tomorrow = add(today, 1, 'day');
+ const weekLater = add(today, 7, 'day');
+ const day = startOfDay(time);
+
+ if (isSame(day, today, 'day')) {
+ return 'Today';
+ } else if (isSame(day, tomorrow, 'day')) {
+ return 'Tomorrow';
+ } else if (isBefore(day, weekLater)) {
+ // Within a week, show day name
+ return format(time, 'dddd');
+ } else {
+ // Beyond a week, show month and day
+ return format(time, dateFormat);
+ }
+}
+
+export const calculateHourTimeline = (start, end, formatDayLabel) => {
+ const hours = [];
+ let current = start;
+ let currentDay = null;
+
+ while (isBefore(current, end)) {
+ // Check if we're entering a new day
+ const day = startOfDay(current);
+ const isNewDay = !currentDay || !isSame(day, currentDay, 'day');
+
+ if (isNewDay) {
+ currentDay = day;
+ }
+
+ // Add day information to our hour object
+ hours.push({
+ time: current,
+ isNewDay,
+ dayLabel: formatDayLabel(current),
+ });
+
+ current = add(current, 1, 'hour');
+ }
+ return hours;
+}
+
+export const calculateNowPosition = (now, start, end) => {
+ if (isBefore(now, start) || isAfter(now, end)) return -1;
+ const minutesSinceStart = diff(now, start, 'minute');
+ return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const calculateScrollPosition = (now, start) => {
+ const roundedNow = roundToNearest(now, 30);
+ const nowOffset = diff(roundedNow, start, 'minute');
+ const scrollPosition =
+ (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
+
+ return Math.max(scrollPosition, 0);
+};
+
+export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
+ const channelIds = channelIdByTvgId.get(String(tvgId));
+ if (!channelIds || channelIds.length === 0) {
+ return null;
+ }
+ // Return the first channel that matches this TVG ID
+ return channelById.get(channelIds[0]) || null;
+}
+
+export const fetchRules = async () => {
+ return await API.listSeriesRules();
+}
+
+export const getRuleByProgram = (rules, program) => {
+ return (rules || []).find(
+ (r) =>
+ String(r.tvg_id) === String(program.tvg_id) &&
+ (!r.title || r.title === program.title)
+ );
+}
+
+export const createRecording = async (channel, program) => {
+ await API.createRecording({
+ channel: `${channel.id}`,
+ start_time: program.start_time,
+ end_time: program.end_time,
+ custom_properties: { program },
+ });
+}
+
+export const createSeriesRule = async (program, mode) => {
+ await API.createSeriesRule({
+ tvg_id: program.tvg_id,
+ mode,
+ title: program.title,
+ });
+}
+
+export const evaluateSeriesRule = async (program) => {
+ await API.evaluateSeriesRules(program.tvg_id);
+}
+
+export const calculateLeftScrollPosition = (program, start) => {
+ const programStartMs =
+ program.startMs ?? convertToMs(program.start_time);
+ const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
+
+ return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const calculateDesiredScrollPosition = (leftPx) => {
+ return Math.max(0, leftPx - 20);
+}
+
+export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const clickPositionX = event.clientX - rect.left;
+ const percentageAcross = clickPositionX / rect.width;
+ const minuteWithinHour = percentageAcross * 60;
+
+ const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
+
+ const adjustedTime = (snappedMinute === 60)
+ ? add(clickedTime, 1, 'hour').minute(0)
+ : clickedTime.minute(snappedMinute);
+
+ const snappedOffset = diff(adjustedTime, start, 'minute');
+ return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+};
+
+export const getGroupOptions = (channelGroups, guideChannels) => {
+ const options = [{ value: 'all', label: 'All Channel Groups' }];
+
+ if (channelGroups && guideChannels.length > 0) {
+ // Get unique channel group IDs from the channels that have program data
+ const usedGroupIds = new Set();
+ guideChannels.forEach((channel) => {
+ if (channel.channel_group_id) {
+ usedGroupIds.add(channel.channel_group_id);
+ }
+ });
+ // Only add groups that are actually used by channels in the guide
+ Object.values(channelGroups)
+ .filter((group) => usedGroupIds.has(group.id))
+ .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
+ .forEach((group) => {
+ options.push({
+ value: group.id.toString(),
+ label: group.name,
+ });
+ });
+ }
+ return options;
+}
+
+export const getProfileOptions = (profiles) => {
+ const options = [{ value: 'all', label: 'All Profiles' }];
+
+ if (profiles) {
+ Object.values(profiles).forEach((profile) => {
+ if (profile.id !== '0') {
+ // Skip the 'All' default profile
+ options.push({
+ value: profile.id.toString(),
+ label: profile.name,
+ });
+ }
+ });
+ }
+
+ return options;
+}
+
+export const deleteSeriesRuleByTvgId = async (tvg_id) => {
+ await API.deleteSeriesRule(tvg_id);
+}
+
+export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
+ await API.evaluateSeriesRules(tvg_id);
+}
\ No newline at end of file
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index fd2c52b8..8fe943b7 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -7,7 +7,6 @@ import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
import useUserAgentsStore from './userAgents';
import useUsersStore from './users';
-import useLogosStore from './logos';
import API from '../api';
import { USER_LEVELS } from '../constants';
@@ -43,6 +42,8 @@ const useAuthStore = create((set, get) => ({
throw new Error('Unauthorized');
}
+ set({ user, isAuthenticated: true });
+
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
@@ -63,7 +64,8 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
- set({ user, isAuthenticated: true });
+ // Note: Logos are loaded after the Channels page tables finish loading
+ // This is handled by the tables themselves signaling completion
} catch (error) {
console.error('Error initializing data:', error);
}
@@ -134,13 +136,21 @@ const useAuthStore = create((set, get) => ({
return false; // Add explicit return for when data.access is not available
} catch (error) {
console.error('Token refresh failed:', error);
- get().logout();
+ await get().logout();
return false; // Add explicit return after error
}
},
// Action to logout
- logout: () => {
+ logout: async () => {
+ // Call backend logout endpoint to log the event
+ try {
+ await API.logout();
+ } catch (error) {
+ // Continue with logout even if API call fails
+ console.error('Logout API call failed:', error);
+ }
+
set({
accessToken: null,
refreshToken: null,
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 3635d784..9fb958b2 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -402,6 +402,7 @@ const useChannelsStore = create((set, get) => ({
try {
set({
recordings: await api.getRecordings(),
+ isLoading: false,
});
} catch (error) {
console.error('Failed to fetch recordings:', error);
diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx
index e0576364..1760bc45 100644
--- a/frontend/src/store/epgs.jsx
+++ b/frontend/src/store/epgs.jsx
@@ -5,6 +5,7 @@ const useEPGsStore = create((set) => ({
epgs: {},
tvgs: [],
tvgsById: {},
+ tvgsLoaded: false,
isLoading: false,
error: null,
refreshProgress: {},
@@ -36,11 +37,16 @@ const useEPGsStore = create((set) => ({
acc[tvg.id] = tvg;
return acc;
}, {}),
+ tvgsLoaded: true,
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch tvgs:', error);
- set({ error: 'Failed to load tvgs.', isLoading: false });
+ set({
+ error: 'Failed to load tvgs.',
+ tvgsLoaded: true,
+ isLoading: false,
+ });
}
},
@@ -50,9 +56,17 @@ const useEPGsStore = create((set) => ({
})),
updateEPG: (epg) =>
- set((state) => ({
- epgs: { ...state.epgs, [epg.id]: epg },
- })),
+ set((state) => {
+ // Validate that epg is an object with an id
+ if (!epg || typeof epg !== 'object' || !epg.id) {
+ console.error('updateEPG called with invalid epg:', epg);
+ return state;
+ }
+
+ return {
+ epgs: { ...state.epgs, [epg.id]: epg },
+ };
+ }),
removeEPGs: (epgIds) =>
set((state) => {
@@ -66,6 +80,12 @@ const useEPGsStore = create((set) => ({
updateEPGProgress: (data) =>
set((state) => {
+ // Validate that data is an object with a source
+ if (!data || typeof data !== 'object' || !data.source) {
+ console.error('updateEPGProgress called with invalid data:', data);
+ return state;
+ }
+
// Early exit if source doesn't exist in our EPGs store
if (!state.epgs[data.source] && !data.status) {
return state;
diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx
index 4634f672..5843b113 100644
--- a/frontend/src/store/logos.jsx
+++ b/frontend/src/store/logos.jsx
@@ -9,16 +9,10 @@ const useLogosStore = create((set, get) => ({
hasLoadedAll: false, // Track if we've loaded all logos
hasLoadedChannelLogos: false, // Track if we've loaded channel logos
error: null,
+ allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready
- // Basic CRUD operations
- setLogos: (logos) => {
- set({
- logos: logos.reduce((acc, logo) => {
- acc[logo.id] = { ...logo };
- return acc;
- }, {}),
- });
- },
+ // Enable logo rendering (call this after tables have loaded and painted)
+ enableLogoRendering: () => set({ allowLogoRendering: true }),
addLogo: (newLogo) =>
set((state) => {
@@ -73,6 +67,9 @@ const useLogosStore = create((set, get) => ({
// Smart loading methods
fetchLogos: async (pageSize = 100) => {
+ // Don't fetch if logo fetching is not allowed yet
+ if (!get().allowLogoFetching) return [];
+
set({ isLoading: true, error: null });
try {
const response = await api.getLogos({ page_size: pageSize });
@@ -163,59 +160,28 @@ const useLogosStore = create((set, get) => ({
},
fetchChannelAssignableLogos: async () => {
- const { backgroundLoading, hasLoadedChannelLogos, channelLogos } = get();
+ const { hasLoadedChannelLogos, channelLogos } = get();
- // Prevent concurrent calls
- if (
- backgroundLoading ||
- (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0)
- ) {
+ // Return cached if already loaded
+ if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) {
return Object.values(channelLogos);
}
- set({ backgroundLoading: true, error: null });
- try {
- // Load all channel logos (no special filtering needed - all Logo entries are for channels)
- const response = await api.getLogos({
- no_pagination: 'true', // Get all channel logos
- });
+ // Fetch all logos and cache them as channel logos
+ const logos = await get().fetchAllLogos();
- // Handle both paginated and non-paginated responses
- const logos = Array.isArray(response) ? response : response.results || [];
+ set({
+ channelLogos: logos.reduce((acc, logo) => {
+ acc[logo.id] = { ...logo };
+ return acc;
+ }, {}),
+ hasLoadedChannelLogos: true,
+ });
- console.log(`Fetched ${logos.length} channel logos`);
-
- // Store in both places, but this is intentional and only when specifically requested
- set({
- logos: {
- ...get().logos, // Keep existing logos
- ...logos.reduce((acc, logo) => {
- acc[logo.id] = { ...logo };
- return acc;
- }, {}),
- },
- channelLogos: logos.reduce((acc, logo) => {
- acc[logo.id] = { ...logo };
- return acc;
- }, {}),
- hasLoadedChannelLogos: true,
- backgroundLoading: false,
- });
-
- return logos;
- } catch (error) {
- console.error('Failed to fetch channel logos:', error);
- set({
- error: 'Failed to load channel logos.',
- backgroundLoading: false,
- });
- throw error;
- }
+ return logos;
},
fetchLogosByIds: async (logoIds) => {
- if (!logoIds || logoIds.length === 0) return [];
-
try {
// Filter out logos we already have
const missingIds = logoIds.filter((id) => !get().logos[id]);
diff --git a/frontend/src/store/plugins.jsx b/frontend/src/store/plugins.jsx
new file mode 100644
index 00000000..e8d0b065
--- /dev/null
+++ b/frontend/src/store/plugins.jsx
@@ -0,0 +1,41 @@
+import { create } from 'zustand';
+import API from '../api';
+
+export const usePluginStore = create((set, get) => ({
+ plugins: [],
+ loading: false,
+ error: null,
+
+ fetchPlugins: async () => {
+ set({ loading: true, error: null });
+ try {
+ const response = await API.getPlugins();
+ set({ plugins: response || [], loading: false });
+ } catch (error) {
+ set({ error, loading: false });
+ }
+ },
+
+ updatePlugin: (key, updates) => {
+ set((state) => ({
+ plugins: state.plugins.map((p) =>
+ p.key === key ? { ...p, ...updates } : p
+ ),
+ }));
+ },
+
+ addPlugin: (plugin) => {
+ set((state) => ({ plugins: [...state.plugins, plugin] }));
+ },
+
+ removePlugin: (key) => {
+ set((state) => ({
+ plugins: state.plugins.filter((p) => p.key !== key),
+ }));
+ },
+
+ invalidatePlugins: () => {
+ set({ plugins: [] });
+ get().fetchPlugins();
+ },
+}));
\ No newline at end of file
diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
index b0aecd61..43edb1c9 100644
--- a/frontend/src/store/useVODStore.jsx
+++ b/frontend/src/store/useVODStore.jsx
@@ -364,7 +364,7 @@ const useVODStore = create((set, get) => ({
name: seriesInfo.name,
},
type: 'episode',
- uuid: episode.id, // Use the stream ID as UUID for playback
+ uuid: episode.uuid,
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
new file mode 100644
index 00000000..54644dcd
--- /dev/null
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -0,0 +1,473 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import * as dateTimeUtils from '../dateTimeUtils';
+import useSettingsStore from '../../store/settings';
+import useLocalStorage from '../../hooks/useLocalStorage';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+vi.mock('../../store/settings');
+vi.mock('../../hooks/useLocalStorage');
+
+describe('dateTimeUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('convertToMs', () => {
+ it('should convert date to milliseconds', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.convertToMs(date);
+ expect(result).toBe(dayjs(date).valueOf());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.convertToMs(date);
+ expect(result).toBe(dayjs(date).valueOf());
+ });
+ });
+
+ describe('convertToSec', () => {
+ it('should convert date to unix timestamp', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.convertToSec(date);
+ expect(result).toBe(dayjs(date).unix());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.convertToSec(date);
+ expect(result).toBe(dayjs(date).unix());
+ });
+ });
+
+ describe('initializeTime', () => {
+ it('should create dayjs object from date string', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.initializeTime(date);
+ expect(result.format()).toBe(dayjs(date).format());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.initializeTime(date);
+ expect(result.format()).toBe(dayjs(date).format());
+ });
+ });
+
+ describe('startOfDay', () => {
+ it('should return start of day', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.startOfDay(date);
+ expect(result.hour()).toBe(0);
+ expect(result.minute()).toBe(0);
+ expect(result.second()).toBe(0);
+ });
+ });
+
+ describe('isBefore', () => {
+ it('should return true when first date is before second', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isBefore(date1, date2)).toBe(true);
+ });
+
+ it('should return false when first date is after second', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.isBefore(date1, date2)).toBe(false);
+ });
+ });
+
+ describe('isAfter', () => {
+ it('should return true when first date is after second', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.isAfter(date1, date2)).toBe(true);
+ });
+
+ it('should return false when first date is before second', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isAfter(date1, date2)).toBe(false);
+ });
+ });
+
+ describe('isSame', () => {
+ it('should return true when dates are same day', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isSame(date1, date2)).toBe(true);
+ });
+
+ it('should return false when dates are different days', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-16T10:00:00Z';
+ expect(dateTimeUtils.isSame(date1, date2)).toBe(false);
+ });
+
+ it('should accept unit parameter', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T10:30:00Z';
+ expect(dateTimeUtils.isSame(date1, date2, 'hour')).toBe(true);
+ expect(dateTimeUtils.isSame(date1, date2, 'minute')).toBe(false);
+ });
+ });
+
+ describe('add', () => {
+ it('should add time to date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.add(date, 1, 'hour');
+ expect(result.hour()).toBe(11);
+ });
+
+ it('should handle different units', () => {
+ const date = '2024-01-15T10:00:00Z';
+ const dayResult = dateTimeUtils.add(date, 1, 'day');
+ expect(dayResult.date()).toBe(16);
+
+ const monthResult = dateTimeUtils.add(date, 1, 'month');
+ expect(monthResult.month()).toBe(1);
+ });
+ });
+
+ describe('subtract', () => {
+ it('should subtract time from date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.subtract(date, 1, 'hour');
+ expect(result.hour()).toBe(9);
+ });
+
+ it('should handle different units', () => {
+ const date = '2024-01-15T10:00:00Z';
+ const dayResult = dateTimeUtils.subtract(date, 1, 'day');
+ expect(dayResult.date()).toBe(14);
+ });
+ });
+
+ describe('diff', () => {
+ it('should calculate difference in milliseconds by default', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ const result = dateTimeUtils.diff(date1, date2);
+ expect(result).toBe(3600000);
+ });
+
+ it('should calculate difference in specified unit', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.diff(date1, date2, 'hour')).toBe(1);
+ expect(dateTimeUtils.diff(date1, date2, 'minute')).toBe(60);
+ });
+ });
+
+ describe('format', () => {
+ it('should format date with given format string', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.format(date, 'YYYY-MM-DD');
+ expect(result).toMatch(/2024-01-15/);
+ });
+
+ it('should handle time formatting', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.format(date, 'HH:mm');
+ expect(result).toMatch(/\d{2}:\d{2}/);
+ });
+ });
+
+ describe('getNow', () => {
+ it('should return current time as dayjs object', () => {
+ const result = dateTimeUtils.getNow();
+ expect(result.isValid()).toBe(true);
+ });
+ });
+
+ describe('toFriendlyDuration', () => {
+ it('should convert duration to human readable format', () => {
+ const result = dateTimeUtils.toFriendlyDuration(60, 'minutes');
+ expect(result).toBe('an hour');
+ });
+
+ it('should handle different units', () => {
+ const result = dateTimeUtils.toFriendlyDuration(2, 'hours');
+ expect(result).toBe('2 hours');
+ });
+ });
+
+ describe('fromNow', () => {
+ it('should return relative time from now', () => {
+ const pastDate = dayjs().subtract(1, 'hour').toISOString();
+ const result = dateTimeUtils.fromNow(pastDate);
+ expect(result).toMatch(/ago/);
+ });
+ });
+
+ describe('getNowMs', () => {
+ it('should return current time in milliseconds', () => {
+ const result = dateTimeUtils.getNowMs();
+ expect(typeof result).toBe('number');
+ expect(result).toBeGreaterThan(0);
+ });
+ });
+
+ describe('roundToNearest', () => {
+ it('should round to nearest 15 minutes', () => {
+ const date = dayjs('2024-01-15T10:17:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.minute()).toBe(15);
+ });
+
+ it('should round up when past halfway point', () => {
+ const date = dayjs('2024-01-15T10:23:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.minute()).toBe(30);
+ });
+
+ it('should handle rounding to next hour', () => {
+ const date = dayjs.utc('2024-01-15T10:53:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.hour()).toBe(11);
+ expect(result.minute()).toBe(0);
+ });
+
+ it('should handle different minute intervals', () => {
+ const date = dayjs('2024-01-15T10:20:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 30);
+ expect(result.minute()).toBe(30);
+ });
+ });
+
+ describe('useUserTimeZone', () => {
+ it('should return time zone from local storage', () => {
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ useSettingsStore.mockReturnValue({});
+
+ const { result } = renderHook(() => dateTimeUtils.useUserTimeZone());
+
+ expect(result.current).toBe('America/New_York');
+ });
+
+ it('should update time zone from settings', () => {
+ const setTimeZone = vi.fn();
+ useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
+ useSettingsStore.mockReturnValue({
+ 'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
+ });
+
+ renderHook(() => dateTimeUtils.useUserTimeZone());
+
+ expect(setTimeZone).toHaveBeenCalledWith('America/Los_Angeles');
+ });
+ });
+
+ describe('useTimeHelpers', () => {
+ beforeEach(() => {
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ useSettingsStore.mockReturnValue({});
+ });
+
+ it('should return time zone, toUserTime, and userNow', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ expect(result.current).toHaveProperty('timeZone');
+ expect(result.current).toHaveProperty('toUserTime');
+ expect(result.current).toHaveProperty('userNow');
+ });
+
+ it('should convert value to user time zone', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+ const date = '2024-01-15T10:00:00Z';
+
+ const converted = result.current.toUserTime(date);
+
+ expect(converted.isValid()).toBe(true);
+ });
+
+ it('should return null for null value', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ const converted = result.current.toUserTime(null);
+
+ expect(converted).toBeDefined();
+ expect(converted.isValid()).toBe(false);
+ });
+
+ it('should handle timezone conversion errors', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+ const date = '2024-01-15T10:00:00Z';
+
+ const converted = result.current.toUserTime(date);
+
+ expect(converted.isValid()).toBe(true);
+ });
+
+ it('should return current time in user timezone', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ const now = result.current.userNow();
+
+ expect(now.isValid()).toBe(true);
+ });
+ });
+
+ describe('RECURRING_DAY_OPTIONS', () => {
+ it('should have 7 day options', () => {
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS).toHaveLength(7);
+ });
+
+ it('should start with Sunday', () => {
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' });
+ });
+
+ it('should include all weekdays', () => {
+ const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(opt => opt.label);
+ expect(labels).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']);
+ });
+ });
+
+ describe('useDateTimeFormat', () => {
+ it('should return 12h format and mdy date format by default', () => {
+ useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current).toEqual(['h:mma', 'MMM D']);
+ });
+
+ it('should return 24h format when set', () => {
+ useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current[0]).toBe('HH:mm');
+ });
+
+ it('should return dmy date format when set', () => {
+ useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current[1]).toBe('D MMM');
+ });
+ });
+
+ describe('toTimeString', () => {
+ it('should return 00:00 for null value', () => {
+ expect(dateTimeUtils.toTimeString(null)).toBe('00:00');
+ });
+
+ it('should parse HH:mm format', () => {
+ expect(dateTimeUtils.toTimeString('14:30')).toBe('14:30');
+ });
+
+ it('should parse HH:mm:ss format', () => {
+ const result = dateTimeUtils.toTimeString('14:30:45');
+ expect(result).toMatch(/14:30/);
+ });
+
+ it('should return original string for unparseable format', () => {
+ expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM');
+ });
+
+ it('should return original string for invalid format', () => {
+ expect(dateTimeUtils.toTimeString('invalid')).toBe('invalid');
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T14:30:00Z');
+ const result = dateTimeUtils.toTimeString(date);
+ expect(result).toMatch(/\d{2}:\d{2}/);
+ });
+
+ it('should return 00:00 for invalid Date', () => {
+ expect(dateTimeUtils.toTimeString(new Date('invalid'))).toBe('00:00');
+ });
+ });
+
+ describe('parseDate', () => {
+ it('should return null for null value', () => {
+ expect(dateTimeUtils.parseDate(null)).toBeNull();
+ });
+
+ it('should parse YYYY-MM-DD format', () => {
+ const result = dateTimeUtils.parseDate('2024-01-15');
+ expect(result).toBeInstanceOf(Date);
+ expect(result?.getFullYear()).toBe(2024);
+ });
+
+ it('should parse ISO 8601 format', () => {
+ const result = dateTimeUtils.parseDate('2024-01-15T10:30:00Z');
+ expect(result).toBeInstanceOf(Date);
+ });
+
+ it('should return null for invalid date', () => {
+ expect(dateTimeUtils.parseDate('invalid')).toBeNull();
+ });
+ });
+
+ describe('buildTimeZoneOptions', () => {
+ it('should return array of timezone options', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ expect(Array.isArray(result)).toBe(true);
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should format timezone with offset', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ expect(result[0]).toHaveProperty('value');
+ expect(result[0]).toHaveProperty('label');
+ expect(result[0].label).toMatch(/UTC[+-]\d{2}:\d{2}/);
+ });
+
+ it('should sort by offset then name', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ for (let i = 1; i < result.length; i++) {
+ expect(result[i].numericOffset).toBeGreaterThanOrEqual(result[i - 1].numericOffset);
+ }
+ });
+
+ it('should include DST information when applicable', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ const dstZone = result.find(opt => opt.label.includes('DST range'));
+ expect(dstZone).toBeDefined();
+ });
+
+ it('should add preferred zone if not in list', () => {
+ const preferredZone = 'Custom/Zone';
+ const result = dateTimeUtils.buildTimeZoneOptions(preferredZone);
+ const found = result.find(opt => opt.value === preferredZone);
+ expect(found).toBeDefined();
+ });
+
+ it('should not duplicate existing zones', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions('UTC');
+ const utcOptions = result.filter(opt => opt.value === 'UTC');
+ expect(utcOptions).toHaveLength(1);
+ });
+ });
+
+ describe('getDefaultTimeZone', () => {
+ it('should return system timezone', () => {
+ const result = dateTimeUtils.getDefaultTimeZone();
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should return UTC on error', () => {
+ const originalDateTimeFormat = Intl.DateTimeFormat;
+ Intl.DateTimeFormat = vi.fn(() => {
+ throw new Error('Test error');
+ });
+
+ const result = dateTimeUtils.getDefaultTimeZone();
+ expect(result).toBe('UTC');
+
+ Intl.DateTimeFormat = originalDateTimeFormat;
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/networkUtils.test.js b/frontend/src/utils/__tests__/networkUtils.test.js
new file mode 100644
index 00000000..bb820589
--- /dev/null
+++ b/frontend/src/utils/__tests__/networkUtils.test.js
@@ -0,0 +1,144 @@
+import { describe, it, expect } from 'vitest';
+import * as networkUtils from '../networkUtils';
+
+describe('networkUtils', () => {
+ describe('IPV4_CIDR_REGEX', () => {
+ it('should match valid IPv4 CIDR notation', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0/24')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('10.0.0.0/8')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('172.16.0.0/12')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('0.0.0.0/0')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(true);
+ });
+
+ it('should not match invalid IPv4 CIDR notation', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0/33')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('256.168.1.0/24')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168/24')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('invalid')).toBe(false);
+ });
+
+ it('should not match IPv6 addresses', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('2001:db8::/32')).toBe(false);
+ });
+ });
+
+ describe('IPV6_CIDR_REGEX', () => {
+ it('should match valid IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/32')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('fe80::/10')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::/0')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334/64')).toBe(true);
+ });
+
+ it('should match compressed IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::1/128')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::1/128')).toBe(true);
+ });
+
+ it('should match IPv6 with embedded IPv4', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true);
+ });
+
+ it('should not match invalid IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::')).toBe(false);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/129')).toBe(false);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('invalid/64')).toBe(false);
+ });
+
+ it('should not match IPv4 addresses', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('192.168.1.0/24')).toBe(false);
+ });
+ });
+
+ describe('formatBytes', () => {
+ it('should return "0 Bytes" for zero bytes', () => {
+ expect(networkUtils.formatBytes(0)).toBe('0 Bytes');
+ });
+
+ it('should format bytes correctly', () => {
+ expect(networkUtils.formatBytes(100)).toBe('100.00 Bytes');
+ expect(networkUtils.formatBytes(500)).toBe('500.00 Bytes');
+ });
+
+ it('should format kilobytes correctly', () => {
+ expect(networkUtils.formatBytes(1024)).toBe('1.00 KB');
+ expect(networkUtils.formatBytes(2048)).toBe('2.00 KB');
+ expect(networkUtils.formatBytes(1536)).toBe('1.50 KB');
+ });
+
+ it('should format megabytes correctly', () => {
+ expect(networkUtils.formatBytes(1048576)).toBe('1.00 MB');
+ expect(networkUtils.formatBytes(2097152)).toBe('2.00 MB');
+ expect(networkUtils.formatBytes(5242880)).toBe('5.00 MB');
+ });
+
+ it('should format gigabytes correctly', () => {
+ expect(networkUtils.formatBytes(1073741824)).toBe('1.00 GB');
+ expect(networkUtils.formatBytes(2147483648)).toBe('2.00 GB');
+ });
+
+ it('should format terabytes correctly', () => {
+ expect(networkUtils.formatBytes(1099511627776)).toBe('1.00 TB');
+ });
+
+ it('should format large numbers', () => {
+ expect(networkUtils.formatBytes(1125899906842624)).toBe('1.00 PB');
+ });
+
+ it('should handle decimal values', () => {
+ const result = networkUtils.formatBytes(1536);
+ expect(result).toMatch(/1\.50 KB/);
+ });
+
+ it('should always show two decimal places', () => {
+ const result = networkUtils.formatBytes(1024);
+ expect(result).toBe('1.00 KB');
+ });
+ });
+
+ describe('formatSpeed', () => {
+ it('should return "0 Bytes" for zero speed', () => {
+ expect(networkUtils.formatSpeed(0)).toBe('0 Bytes');
+ });
+
+ it('should format bits per second correctly', () => {
+ expect(networkUtils.formatSpeed(100)).toBe('100.00 bps');
+ expect(networkUtils.formatSpeed(500)).toBe('500.00 bps');
+ });
+
+ it('should format kilobits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1024)).toBe('1.00 Kbps');
+ expect(networkUtils.formatSpeed(2048)).toBe('2.00 Kbps');
+ expect(networkUtils.formatSpeed(1536)).toBe('1.50 Kbps');
+ });
+
+ it('should format megabits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1048576)).toBe('1.00 Mbps');
+ expect(networkUtils.formatSpeed(2097152)).toBe('2.00 Mbps');
+ expect(networkUtils.formatSpeed(10485760)).toBe('10.00 Mbps');
+ });
+
+ it('should format gigabits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1073741824)).toBe('1.00 Gbps');
+ expect(networkUtils.formatSpeed(2147483648)).toBe('2.00 Gbps');
+ });
+
+ it('should handle decimal values', () => {
+ const result = networkUtils.formatSpeed(1536);
+ expect(result).toMatch(/1\.50 Kbps/);
+ });
+
+ it('should always show two decimal places', () => {
+ const result = networkUtils.formatSpeed(1024);
+ expect(result).toBe('1.00 Kbps');
+ });
+
+ it('should use speed units not byte units', () => {
+ const result = networkUtils.formatSpeed(1024);
+ expect(result).not.toContain('KB');
+ expect(result).toContain('Kbps');
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/notificationUtils.test.js b/frontend/src/utils/__tests__/notificationUtils.test.js
new file mode 100644
index 00000000..bfea55d8
--- /dev/null
+++ b/frontend/src/utils/__tests__/notificationUtils.test.js
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { notifications } from '@mantine/notifications';
+import * as notificationUtils from '../notificationUtils';
+
+vi.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: vi.fn(),
+ update: vi.fn(),
+ },
+}));
+
+describe('notificationUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('showNotification', () => {
+ it('should call notifications.show with notification object', () => {
+ const notificationObject = {
+ title: 'Test Title',
+ message: 'Test message',
+ color: 'blue',
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ expect(notifications.show).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the result from notifications.show', () => {
+ const mockReturnValue = 'notification-id-123';
+ notifications.show.mockReturnValue(mockReturnValue);
+
+ const result = notificationUtils.showNotification({ message: 'test' });
+
+ expect(result).toBe(mockReturnValue);
+ });
+
+ it('should handle notification with all properties', () => {
+ const notificationObject = {
+ id: 'custom-id',
+ title: 'Success',
+ message: 'Operation completed',
+ color: 'green',
+ autoClose: 5000,
+ withCloseButton: true,
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ });
+
+ it('should handle minimal notification object', () => {
+ const notificationObject = {
+ message: 'Simple message',
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ });
+ });
+
+ describe('updateNotification', () => {
+ it('should call notifications.update with id and notification object', () => {
+ const notificationId = 'notification-123';
+ const notificationObject = {
+ title: 'Updated Title',
+ message: 'Updated message',
+ color: 'green',
+ };
+
+ notificationUtils.updateNotification(notificationId, notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject);
+ expect(notifications.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the result from notifications.update', () => {
+ const mockReturnValue = { success: true };
+ notifications.update.mockReturnValue(mockReturnValue);
+
+ const result = notificationUtils.updateNotification('id', { message: 'test' });
+
+ expect(result).toBe(mockReturnValue);
+ });
+
+ it('should handle loading to success transition', () => {
+ const notificationId = 'loading-notification';
+ const updateObject = {
+ title: 'Success',
+ message: 'Operation completed successfully',
+ color: 'green',
+ loading: false,
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle loading to error transition', () => {
+ const notificationId = 'loading-notification';
+ const updateObject = {
+ title: 'Error',
+ message: 'Operation failed',
+ color: 'red',
+ loading: false,
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle partial updates', () => {
+ const notificationId = 'notification-123';
+ const updateObject = {
+ color: 'yellow',
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle empty notification id', () => {
+ const notificationObject = { message: 'test' };
+
+ notificationUtils.updateNotification('', notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith('', notificationObject);
+ });
+
+ it('should handle null notification id', () => {
+ const notificationObject = { message: 'test' };
+
+ notificationUtils.updateNotification(null, notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(null, notificationObject);
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/PluginCardUtils.js b/frontend/src/utils/cards/PluginCardUtils.js
new file mode 100644
index 00000000..8752e019
--- /dev/null
+++ b/frontend/src/utils/cards/PluginCardUtils.js
@@ -0,0 +1,24 @@
+export const getConfirmationDetails = (action, plugin, settings) => {
+ const actionConfirm = action.confirm;
+ const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
+ let requireConfirm = false;
+ let confirmTitle = `Run ${action.label}?`;
+ let confirmMessage = `You're about to run "${action.label}" from "${plugin.name}".`;
+
+ if (actionConfirm) {
+ if (typeof actionConfirm === 'boolean') {
+ requireConfirm = actionConfirm;
+ } else if (typeof actionConfirm === 'object') {
+ requireConfirm = actionConfirm.required !== false;
+ if (actionConfirm.title) confirmTitle = actionConfirm.title;
+ if (actionConfirm.message) confirmMessage = actionConfirm.message;
+ }
+ } else if (confirmField) {
+ const settingVal = settings?.confirm;
+ const effectiveConfirm =
+ (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
+ requireConfirm = !!effectiveConfirm;
+ }
+
+ return { requireConfirm, confirmTitle, confirmMessage };
+};
diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js
new file mode 100644
index 00000000..65b3da3a
--- /dev/null
+++ b/frontend/src/utils/cards/RecordingCardUtils.js
@@ -0,0 +1,92 @@
+import API from '../../api.js';
+import useChannelsStore from '../../store/channels.jsx';
+
+export const removeRecording = (id) => {
+ // Optimistically remove immediately from UI
+ try {
+ useChannelsStore.getState().removeRecording(id);
+ } catch (error) {
+ console.error('Failed to optimistically remove recording', error);
+ }
+ // Fire-and-forget server delete; websocket will keep others in sync
+ API.deleteRecording(id).catch(() => {
+ // On failure, fallback to refetch to restore state
+ try {
+ useChannelsStore.getState().fetchRecordings();
+ } catch (error) {
+ console.error('Failed to refresh recordings after delete', error);
+ }
+ });
+};
+
+export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => {
+ let purl = posterLogoId
+ ? `/api/channels/logos/${posterLogoId}/cache/`
+ : customProperties?.poster_url || posterUrl || '/logo.png';
+ if (
+ typeof import.meta !== 'undefined' &&
+ import.meta.env &&
+ import.meta.env.DEV &&
+ purl &&
+ purl.startsWith('/')
+ ) {
+ purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
+ }
+ return purl;
+};
+
+export const getShowVideoUrl = (channel, env_mode) => {
+ let url = `/proxy/ts/stream/${channel.uuid}`;
+ if (env_mode === 'dev') {
+ url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
+ }
+ return url;
+};
+
+export const runComSkip = async (recording) => {
+ await API.runComskip(recording.id);
+};
+
+export const deleteRecordingById = async (recordingId) => {
+ await API.deleteRecording(recordingId);
+};
+
+export const deleteSeriesAndRule = async (seriesInfo) => {
+ const { tvg_id, title } = seriesInfo;
+ if (tvg_id) {
+ try {
+ await API.bulkRemoveSeriesRecordings({
+ tvg_id,
+ title,
+ scope: 'title',
+ });
+ } catch (error) {
+ console.error('Failed to remove series recordings', error);
+ }
+ try {
+ await API.deleteSeriesRule(tvg_id);
+ } catch (error) {
+ console.error('Failed to delete series rule', error);
+ }
+ }
+};
+
+export const getRecordingUrl = (customProps, env_mode) => {
+ let fileUrl = customProps?.file_url || customProps?.output_file_url;
+ if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) {
+ fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
+ }
+ return fileUrl;
+};
+
+export const getSeasonLabel = (season, episode, onscreen) => {
+ return season && episode
+ ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
+ : onscreen || null;
+};
+
+export const getSeriesInfo = (customProps) => {
+ const cp = customProps || {};
+ const pr = cp.program || {};
+ return { tvg_id: pr.tvg_id, title: pr.title };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js
new file mode 100644
index 00000000..5c9d9ccc
--- /dev/null
+++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js
@@ -0,0 +1,131 @@
+import API from '../../api.js';
+import {
+ format,
+ getNow,
+ initializeTime,
+ subtract,
+ toFriendlyDuration,
+} from '../dateTimeUtils.js';
+
+// Get buffering_speed from proxy settings
+export const getBufferingSpeedThreshold = (proxySetting) => {
+ try {
+ if (proxySetting?.value) {
+ return parseFloat(proxySetting.value.buffering_speed) || 1.0;
+ }
+ } catch (error) {
+ console.error('Error getting buffering speed:', error);
+ }
+ return 1.0; // Default fallback
+};
+
+export const getStartDate = (uptime) => {
+ // Get the current date and time
+ const currentDate = new Date();
+ // Calculate the start date by subtracting uptime (in milliseconds)
+ const startDate = new Date(currentDate.getTime() - uptime * 1000);
+ // Format the date as a string (you can adjust the format as needed)
+ return startDate.toLocaleString({
+ weekday: 'short', // optional, adds day of the week
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: true, // 12-hour format with AM/PM
+ });
+};
+
+export const getM3uAccountsMap = (m3uAccounts) => {
+ const map = {};
+ if (m3uAccounts && Array.isArray(m3uAccounts)) {
+ m3uAccounts.forEach((account) => {
+ if (account.id) {
+ map[account.id] = account.name;
+ }
+ });
+ }
+ return map;
+};
+
+export const getChannelStreams = async (channelId) => {
+ return await API.getChannelStreams(channelId);
+};
+
+export const getMatchingStreamByUrl = (streamData, channelUrl) => {
+ return streamData.find(
+ (stream) =>
+ channelUrl.includes(stream.url) || stream.url.includes(channelUrl)
+ );
+};
+
+export const getSelectedStream = (availableStreams, streamId) => {
+ return availableStreams.find((s) => s.id.toString() === streamId);
+};
+
+export const switchStream = (channel, streamId) => {
+ return API.switchStream(channel.channel_id, streamId);
+};
+
+export const connectedAccessor = (dateFormat) => {
+ return (row) => {
+ // Check for connected_since (which is seconds since connection)
+ if (row.connected_since) {
+ // Calculate the actual connection time by subtracting the seconds from current time
+ const connectedTime = subtract(getNow(), row.connected_since, 'second');
+ return format(connectedTime, `${dateFormat} HH:mm:ss`);
+ }
+
+ // Fallback to connected_at if it exists
+ if (row.connected_at) {
+ const connectedTime = initializeTime(row.connected_at * 1000);
+ return format(connectedTime, `${dateFormat} HH:mm:ss`);
+ }
+
+ return 'Unknown';
+ };
+};
+
+export const durationAccessor = () => {
+ return (row) => {
+ if (row.connected_since) {
+ return toFriendlyDuration(row.connected_since, 'seconds');
+ }
+
+ if (row.connection_duration) {
+ return toFriendlyDuration(row.connection_duration, 'seconds');
+ }
+
+ return '-';
+ };
+};
+
+export const getLogoUrl = (logoId, logos, previewedStream) => {
+ return (
+ (logoId && logos && logos[logoId] ? logos[logoId].cache_url : null) ||
+ previewedStream?.logo_url ||
+ null
+ );
+};
+
+export const getStreamsByIds = (streamId) => {
+ return API.getStreamsByIds([streamId]);
+};
+
+export const getStreamOptions = (availableStreams, m3uAccountsMap) => {
+ return availableStreams.map((stream) => {
+ // Get account name from our mapping if it exists
+ const accountName =
+ stream.m3u_account && m3uAccountsMap[stream.m3u_account]
+ ? m3uAccountsMap[stream.m3u_account]
+ : stream.m3u_account
+ ? `M3U #${stream.m3u_account}`
+ : 'Unknown M3U';
+
+ return {
+ value: stream.id.toString(),
+ label: `${stream.name || `Stream #${stream.id}`} [${accountName}]`,
+ };
+ });
+};
diff --git a/frontend/src/utils/cards/VODCardUtils.js b/frontend/src/utils/cards/VODCardUtils.js
new file mode 100644
index 00000000..3ec456e7
--- /dev/null
+++ b/frontend/src/utils/cards/VODCardUtils.js
@@ -0,0 +1,13 @@
+export const formatDuration = (seconds) => {
+ if (!seconds) return '';
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
+};
+
+export const getSeasonLabel = (vod) => {
+ return vod.season_number && vod.episode_number
+ ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
+ : '';
+};
diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js
new file mode 100644
index 00000000..3bf635b6
--- /dev/null
+++ b/frontend/src/utils/cards/VodConnectionCardUtils.js
@@ -0,0 +1,139 @@
+import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js';
+
+export const formatDuration = (seconds) => {
+ if (!seconds) return 'Unknown';
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+};
+
+// Format time for display (e.g., "1:23:45" or "23:45")
+export const formatTime = (seconds) => {
+ if (!seconds || seconds === 0) return '0:00';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ } else {
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+ }
+};
+
+export const getMovieDisplayTitle = (vodContent) => {
+ return vodContent.content_name;
+}
+
+export const getEpisodeDisplayTitle = (metadata) => {
+ const season = metadata.season_number
+ ? `S${metadata.season_number.toString().padStart(2, '0')}`
+ : 'S??';
+ const episode = metadata.episode_number
+ ? `E${metadata.episode_number.toString().padStart(2, '0')}`
+ : 'E??';
+ return `${metadata.series_name} - ${season}${episode}`;
+}
+
+export const getMovieSubtitle = (metadata) => {
+ const parts = [];
+ if (metadata.genre) parts.push(metadata.genre);
+ // We'll handle rating separately as a badge now
+ return parts;
+}
+
+export const getEpisodeSubtitle = (metadata) => {
+ return [metadata.episode_name || 'Episode'];
+}
+
+export const calculateProgress = (connection, duration_secs) => {
+ if (!connection || !duration_secs) {
+ return {
+ percentage: 0,
+ currentTime: 0,
+ totalTime: duration_secs || 0,
+ };
+ }
+
+ const totalSeconds = duration_secs;
+ let percentage = 0;
+ let currentTime = 0;
+ const now = getNowMs() / 1000; // Current time in seconds
+
+ // Priority 1: Use last_seek_percentage if available (most accurate from range requests)
+ if (
+ connection.last_seek_percentage &&
+ connection.last_seek_percentage > 0 &&
+ connection.last_seek_timestamp
+ ) {
+ // Calculate the position at the time of seek
+ const seekPosition = Math.round(
+ (connection.last_seek_percentage / 100) * totalSeconds
+ );
+
+ // Add elapsed time since the seek
+ const elapsedSinceSeek = now - connection.last_seek_timestamp;
+ currentTime = seekPosition + Math.floor(elapsedSinceSeek);
+
+ // Don't exceed the total duration
+ currentTime = Math.min(currentTime, totalSeconds);
+
+ percentage = (currentTime / totalSeconds) * 100;
+ }
+ // Priority 2: Use position_seconds if available
+ else if (connection.position_seconds && connection.position_seconds > 0) {
+ currentTime = connection.position_seconds;
+ percentage = (currentTime / totalSeconds) * 100;
+ }
+
+ return {
+ percentage: Math.min(percentage, 100), // Cap at 100%
+ currentTime: Math.max(0, currentTime), // Don't go negative
+ totalTime: totalSeconds,
+ };
+}
+
+export const calculateConnectionDuration = (connection) => {
+ // If duration is provided by API, use it
+ if (connection.duration && connection.duration > 0) {
+ return toFriendlyDuration(connection.duration, 'seconds');
+ }
+
+ // Fallback: try to extract from client_id timestamp
+ if (connection.client_id && connection.client_id.startsWith('vod_')) {
+ try {
+ const parts = connection.client_id.split('_');
+ if (parts.length >= 2) {
+ const clientStartTime = parseInt(parts[1]) / 1000; // Convert ms to seconds
+ const currentTime = getNowMs() / 1000;
+ return toFriendlyDuration(currentTime - clientStartTime, 'seconds');
+ }
+ } catch {
+ // Ignore parsing errors
+ }
+ }
+
+ return 'Unknown duration';
+}
+
+export const calculateConnectionStartTime = (connection, dateFormat) => {
+ if (connection.connected_at) {
+ return format(connection.connected_at * 1000, `${dateFormat} HH:mm:ss`);
+ }
+
+ // Fallback: calculate from client_id timestamp
+ if (connection.client_id && connection.client_id.startsWith('vod_')) {
+ try {
+ const parts = connection.client_id.split('_');
+ if (parts.length >= 2) {
+ const clientStartTime = parseInt(parts[1]);
+ return format(clientStartTime, `${dateFormat} HH:mm:ss`);
+ }
+ } catch {
+ // Ignore parsing errors
+ }
+ }
+
+ return 'Unknown';
+}
\ No newline at end of file
diff --git a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
new file mode 100644
index 00000000..a6074a4a
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
@@ -0,0 +1,158 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getConfirmationDetails,
+} from '../PluginCardUtils';
+
+describe('PluginCardUtils', () => {
+ describe('getConfirmationDetails', () => {
+ it('requires confirmation when action.confirm is true', () => {
+ const action = { label: 'Test Action', confirm: true };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result).toEqual({
+ requireConfirm: true,
+ confirmTitle: 'Run Test Action?',
+ confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".',
+ });
+ });
+
+ it('does not require confirmation when action.confirm is false', () => {
+ const action = { label: 'Test Action', confirm: false };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('uses custom title and message from action.confirm object', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ required: true,
+ title: 'Custom Title',
+ message: 'Custom message',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result).toEqual({
+ requireConfirm: true,
+ confirmTitle: 'Custom Title',
+ confirmMessage: 'Custom message',
+ });
+ });
+
+ it('requires confirmation when action.confirm.required is not explicitly false', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ title: 'Custom Title',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('does not require confirmation when action.confirm.required is false', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ required: false,
+ title: 'Custom Title',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('uses confirm field from plugin when action.confirm is undefined', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('uses settings value over field default', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: false }],
+ };
+ const settings = { confirm: true };
+ const result = getConfirmationDetails(action, plugin, settings);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('uses field default when settings value is undefined', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const settings = {};
+ const result = getConfirmationDetails(action, plugin, settings);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('does not require confirmation when no confirm configuration exists', () => {
+ const action = { label: 'Test Action' };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('handles plugin without fields array', () => {
+ const action = { label: 'Test Action' };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('handles null or undefined settings', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const result = getConfirmationDetails(action, plugin, null);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('converts truthy confirm field values to boolean', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: 1 }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('handles confirm field with null default', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: null }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
new file mode 100644
index 00000000..3410c596
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
@@ -0,0 +1,390 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ removeRecording,
+ getPosterUrl,
+ getShowVideoUrl,
+ runComSkip,
+ deleteRecordingById,
+ deleteSeriesAndRule,
+ getRecordingUrl,
+ getSeasonLabel,
+ getSeriesInfo,
+} from '../RecordingCardUtils';
+import API from '../../../api';
+import useChannelsStore from '../../../store/channels';
+
+vi.mock('../../../api');
+vi.mock('../../../store/channels');
+
+describe('RecordingCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('removeRecording', () => {
+ let mockRemoveRecording;
+ let mockFetchRecordings;
+
+ beforeEach(() => {
+ mockRemoveRecording = vi.fn();
+ mockFetchRecordings = vi.fn();
+ useChannelsStore.getState = vi.fn(() => ({
+ removeRecording: mockRemoveRecording,
+ fetchRecordings: mockFetchRecordings,
+ }));
+ });
+
+ it('optimistically removes recording from store', () => {
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(mockRemoveRecording).toHaveBeenCalledWith('recording-1');
+ });
+
+ it('calls API to delete recording', () => {
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(API.deleteRecording).toHaveBeenCalledWith('recording-1');
+ });
+
+ it('handles optimistic removal error', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ mockRemoveRecording.mockImplementation(() => {
+ throw new Error('Store error');
+ });
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to optimistically remove recording',
+ expect.any(Error)
+ );
+ consoleError.mockRestore();
+ });
+
+ it('refetches recordings when API delete fails', async () => {
+ API.deleteRecording.mockRejectedValue(new Error('Delete failed'));
+
+ removeRecording('recording-1');
+
+ await vi.waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('handles fetch error after failed delete', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.deleteRecording.mockRejectedValue(new Error('Delete failed'));
+ mockFetchRecordings.mockImplementation(() => {
+ throw new Error('Fetch error');
+ });
+
+ removeRecording('recording-1');
+
+ await vi.waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to refresh recordings after delete',
+ expect.any(Error)
+ );
+ });
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('getPosterUrl', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('returns logo URL when posterLogoId is provided', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl('logo-123', {}, '');
+
+ expect(result).toBe('/api/channels/logos/logo-123/cache/');
+ });
+
+ it('returns custom poster_url when no posterLogoId', () => {
+ vi.stubEnv('DEV', false);
+ const customProps = { poster_url: '/custom/poster.jpg' };
+ const result = getPosterUrl(null, customProps, '');
+
+ expect(result).toBe('/custom/poster.jpg');
+ });
+
+ it('returns posterUrl when no posterLogoId or custom poster_url', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl(null, {}, '/fallback/poster.jpg');
+
+ expect(result).toBe('/fallback/poster.jpg');
+ });
+
+ it('returns default logo when no parameters provided', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl(null, {}, '');
+
+ expect(result).toBe('/logo.png');
+ });
+
+ it('prepends dev server URL in dev mode for relative paths', () => {
+ vi.stubEnv('DEV', true);
+ const result = getPosterUrl(null, {}, '/poster.jpg');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/poster\.jpg$/);
+ });
+
+ it('does not prepend dev URL for absolute URLs', () => {
+ vi.stubEnv('DEV', true);
+ const result = getPosterUrl(null, {}, 'https://example.com/poster.jpg');
+
+ expect(result).toBe('https://example.com/poster.jpg');
+ });
+ });
+
+ describe('getShowVideoUrl', () => {
+ it('returns proxy URL for channel', () => {
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'production');
+
+ expect(result).toBe('/proxy/ts/stream/channel-123');
+ });
+
+ it('prepends dev server URL in dev mode', () => {
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'dev');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/);
+ });
+ });
+
+ describe('runComSkip', () => {
+ it('calls API runComskip with recording id', async () => {
+ API.runComskip.mockResolvedValue();
+ const recording = { id: 'recording-1' };
+
+ await runComSkip(recording);
+
+ expect(API.runComskip).toHaveBeenCalledWith('recording-1');
+ });
+ });
+
+ describe('deleteRecordingById', () => {
+ it('calls API deleteRecording with id', async () => {
+ API.deleteRecording.mockResolvedValue();
+
+ await deleteRecordingById('recording-1');
+
+ expect(API.deleteRecording).toHaveBeenCalledWith('recording-1');
+ });
+ });
+
+ describe('deleteSeriesAndRule', () => {
+ it('removes series recordings and deletes series rule', async () => {
+ API.bulkRemoveSeriesRecordings.mockResolvedValue();
+ API.deleteSeriesRule.mockResolvedValue();
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({
+ tvg_id: 'series-123',
+ title: 'Test Series',
+ scope: 'title',
+ });
+ expect(API.deleteSeriesRule).toHaveBeenCalledWith('series-123');
+ });
+
+ it('does nothing when tvg_id is not provided', async () => {
+ const seriesInfo = { title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(API.bulkRemoveSeriesRecordings).not.toHaveBeenCalled();
+ expect(API.deleteSeriesRule).not.toHaveBeenCalled();
+ });
+
+ it('handles bulk remove error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed'));
+ API.deleteSeriesRule.mockResolvedValue();
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to remove series recordings',
+ expect.any(Error)
+ );
+ expect(API.deleteSeriesRule).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ it('handles delete rule error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.bulkRemoveSeriesRecordings.mockResolvedValue();
+ API.deleteSeriesRule.mockRejectedValue(new Error('Delete rule failed'));
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to delete series rule',
+ expect.any(Error)
+ );
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('getRecordingUrl', () => {
+ it('returns file_url when available', () => {
+ const customProps = { file_url: '/recordings/file.mp4' };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/recordings/file.mp4');
+ });
+
+ it('returns output_file_url when file_url is not available', () => {
+ const customProps = { output_file_url: '/output/file.mp4' };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/output/file.mp4');
+ });
+
+ it('prefers file_url over output_file_url', () => {
+ const customProps = {
+ file_url: '/recordings/file.mp4',
+ output_file_url: '/output/file.mp4',
+ };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/recordings/file.mp4');
+ });
+
+ it('prepends dev server URL in dev mode for relative paths', () => {
+ const customProps = { file_url: '/recordings/file.mp4' };
+ const result = getRecordingUrl(customProps, 'dev');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/);
+ });
+
+ it('does not prepend dev URL for absolute URLs', () => {
+ const customProps = { file_url: 'https://example.com/file.mp4' };
+ const result = getRecordingUrl(customProps, 'dev');
+
+ expect(result).toBe('https://example.com/file.mp4');
+ });
+
+ it('returns undefined when no file URL is available', () => {
+ const result = getRecordingUrl({}, 'production');
+
+ expect(result).toBeUndefined();
+ });
+
+ it('handles null customProps', () => {
+ const result = getRecordingUrl(null, 'production');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getSeasonLabel', () => {
+ it('returns formatted season and episode label', () => {
+ const result = getSeasonLabel(1, 5, null);
+
+ expect(result).toBe('S01E05');
+ });
+
+ it('pads single digit season and episode numbers', () => {
+ const result = getSeasonLabel(2, 3, null);
+
+ expect(result).toBe('S02E03');
+ });
+
+ it('handles multi-digit season and episode numbers', () => {
+ const result = getSeasonLabel(12, 34, null);
+
+ expect(result).toBe('S12E34');
+ });
+
+ it('returns onscreen value when season or episode is missing', () => {
+ const result = getSeasonLabel(null, 5, 'Episode 5');
+
+ expect(result).toBe('Episode 5');
+ });
+
+ it('returns onscreen value when only episode is missing', () => {
+ const result = getSeasonLabel(1, null, 'Special');
+
+ expect(result).toBe('Special');
+ });
+
+ it('returns null when no season, episode, or onscreen provided', () => {
+ const result = getSeasonLabel(null, null, null);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns formatted label even when onscreen is provided', () => {
+ const result = getSeasonLabel(1, 5, 'Episode 5');
+
+ expect(result).toBe('S01E05');
+ });
+ });
+
+ describe('getSeriesInfo', () => {
+ it('extracts tvg_id and title from program', () => {
+ const customProps = {
+ program: { tvg_id: 'series-123', title: 'Test Series' },
+ };
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: 'series-123',
+ title: 'Test Series',
+ });
+ });
+
+ it('handles missing program object', () => {
+ const customProps = {};
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles null customProps', () => {
+ const result = getSeriesInfo(null);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles undefined customProps', () => {
+ const result = getSeriesInfo(undefined);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles partial program data', () => {
+ const customProps = {
+ program: { tvg_id: 'series-123' },
+ };
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: 'series-123',
+ title: undefined,
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
new file mode 100644
index 00000000..92c028c9
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
@@ -0,0 +1,300 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamConnectionCardUtils from '../StreamConnectionCardUtils';
+import API from '../../../api.js';
+import * as dateTimeUtils from '../../dateTimeUtils.js';
+
+vi.mock('../../../api.js');
+vi.mock('../../dateTimeUtils.js');
+
+describe('StreamConnectionCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getBufferingSpeedThreshold', () => {
+ it('should return parsed buffering_speed from proxy settings', () => {
+ const proxySetting = {
+ value: { buffering_speed: 2.5 }
+ };
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5);
+ });
+
+ it('should return 1.0 for invalid JSON', () => {
+ const proxySetting = { value: { buffering_speed: 'invalid' } };
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ consoleSpy.mockRestore();
+ });
+
+ it('should return 1.0 when buffering_speed is not a number', () => {
+ const proxySetting = {
+ value: JSON.stringify({ buffering_speed: 'not a number' })
+ };
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ });
+
+ it('should return 1.0 when proxySetting is null', () => {
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0);
+ });
+
+ it('should return 1.0 when value is missing', () => {
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0);
+ });
+ });
+
+ describe('getStartDate', () => {
+ it('should calculate start date from uptime in seconds', () => {
+ const uptime = 3600; // 1 hour
+ const result = StreamConnectionCardUtils.getStartDate(uptime);
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should handle zero uptime', () => {
+ const result = StreamConnectionCardUtils.getStartDate(0);
+ expect(typeof result).toBe('string');
+ });
+ });
+
+ describe('getM3uAccountsMap', () => {
+ it('should create map from m3u accounts array', () => {
+ const m3uAccounts = [
+ { id: 1, name: 'Account 1' },
+ { id: 2, name: 'Account 2' }
+ ];
+ const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
+ expect(result).toEqual({ 1: 'Account 1', 2: 'Account 2' });
+ });
+
+ it('should handle accounts without id', () => {
+ const m3uAccounts = [
+ { name: 'Account 1' },
+ { id: 2, name: 'Account 2' }
+ ];
+ const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
+ expect(result).toEqual({ 2: 'Account 2' });
+ });
+
+ it('should return empty object for null input', () => {
+ expect(StreamConnectionCardUtils.getM3uAccountsMap(null)).toEqual({});
+ });
+
+ it('should return empty object for non-array input', () => {
+ expect(StreamConnectionCardUtils.getM3uAccountsMap({})).toEqual({});
+ });
+ });
+
+ describe('getChannelStreams', () => {
+ it('should call API.getChannelStreams with channelId', async () => {
+ const mockStreams = [{ id: 1, name: 'Stream 1' }];
+ API.getChannelStreams.mockResolvedValue(mockStreams);
+
+ const result = await StreamConnectionCardUtils.getChannelStreams(123);
+
+ expect(API.getChannelStreams).toHaveBeenCalledWith(123);
+ expect(result).toEqual(mockStreams);
+ });
+ });
+
+ describe('getMatchingStreamByUrl', () => {
+ it('should find stream when channelUrl includes stream url', () => {
+ const streamData = [
+ { id: 1, url: 'http://example.com/stream1' },
+ { id: 2, url: 'http://example.com/stream2' }
+ ];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://example.com/stream1/playlist.m3u8'
+ );
+ expect(result).toEqual(streamData[0]);
+ });
+
+ it('should find stream when stream url includes channelUrl', () => {
+ const streamData = [
+ { id: 1, url: 'http://example.com/stream1/playlist.m3u8' }
+ ];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://example.com/stream1'
+ );
+ expect(result).toEqual(streamData[0]);
+ });
+
+ it('should return undefined when no match found', () => {
+ const streamData = [{ id: 1, url: 'http://example.com/stream1' }];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://different.com/stream'
+ );
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getSelectedStream', () => {
+ it('should find stream by id as string', () => {
+ const streams = [
+ { id: 1, name: 'Stream 1' },
+ { id: 2, name: 'Stream 2' }
+ ];
+ const result = StreamConnectionCardUtils.getSelectedStream(streams, '2');
+ expect(result).toEqual(streams[1]);
+ });
+
+ it('should return undefined when stream not found', () => {
+ const streams = [{ id: 1, name: 'Stream 1' }];
+ const result = StreamConnectionCardUtils.getSelectedStream(streams, '99');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('switchStream', () => {
+ it('should call API.switchStream with channel_id and streamId', () => {
+ const channel = { channel_id: 123 };
+ API.switchStream.mockResolvedValue({ success: true });
+
+ StreamConnectionCardUtils.switchStream(channel, 456);
+
+ expect(API.switchStream).toHaveBeenCalledWith(123, 456);
+ });
+ });
+
+ describe('connectedAccessor', () => {
+ it('should format connected_since correctly', () => {
+ const mockNow = new Date('2024-01-01T12:00:00');
+ const mockConnectedTime = new Date('2024-01-01T10:00:00');
+
+ dateTimeUtils.getNow.mockReturnValue(mockNow);
+ dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
+ dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
+
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({ connected_since: 7200 });
+
+ expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second');
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/01/2024 10:00:00');
+ });
+
+ it('should fallback to connected_at when connected_since is missing', () => {
+ const mockTime = new Date('2024-01-01T10:00:00');
+
+ dateTimeUtils.initializeTime.mockReturnValue(mockTime);
+ dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
+
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({ connected_at: 1704103200 });
+
+ expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
+ expect(result).toBe('01/01/2024 10:00:00');
+ });
+
+ it('should return Unknown when no time data available', () => {
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({});
+ expect(result).toBe('Unknown');
+ });
+ });
+
+ describe('durationAccessor', () => {
+ it('should format connected_since duration', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('2h 30m');
+
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({ connected_since: 9000 });
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds');
+ expect(result).toBe('2h 30m');
+ });
+
+ it('should fallback to connection_duration', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 15m');
+
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({ connection_duration: 4500 });
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds');
+ expect(result).toBe('1h 15m');
+ });
+
+ it('should return - when no duration data available', () => {
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({});
+ expect(result).toBe('-');
+ });
+ });
+
+ describe('getLogoUrl', () => {
+ it('should return cache_url from logos map when logoId exists', () => {
+ const logos = {
+ 'logo-123': { cache_url: '/api/logos/logo-123/cache/' }
+ };
+ const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null);
+ expect(result).toBe('/api/logos/logo-123/cache/');
+ });
+
+ it('should fallback to previewedStream logo_url when logoId not in map', () => {
+ const previewedStream = { logo_url: 'http://example.com/logo.png' };
+ const result = StreamConnectionCardUtils.getLogoUrl('logo-456', {}, previewedStream);
+ expect(result).toBe('http://example.com/logo.png');
+ });
+
+ it('should return null when no logo available', () => {
+ const result = StreamConnectionCardUtils.getLogoUrl(null, {}, null);
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getStreamsByIds', () => {
+ it('should call API.getStreamsByIds with array containing streamId', async () => {
+ const mockStreams = [{ id: 123, name: 'Stream' }];
+ API.getStreamsByIds.mockResolvedValue(mockStreams);
+
+ const result = await StreamConnectionCardUtils.getStreamsByIds(123);
+
+ expect(API.getStreamsByIds).toHaveBeenCalledWith([123]);
+ expect(result).toEqual(mockStreams);
+ });
+ });
+
+ describe('getStreamOptions', () => {
+ it('should format stream options with account names from map', () => {
+ const streams = [
+ { id: 1, name: 'Stream 1', m3u_account: 100 },
+ { id: 2, name: 'Stream 2', m3u_account: 200 }
+ ];
+ const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' };
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+
+ expect(result).toEqual([
+ { value: '1', label: 'Stream 1 [Premium Account]' },
+ { value: '2', label: 'Stream 2 [Basic Account]' }
+ ]);
+ });
+
+ it('should use default M3U label when account not in map', () => {
+ const streams = [{ id: 1, name: 'Stream 1', m3u_account: 999 }];
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, {});
+
+ expect(result[0].label).toBe('Stream 1 [M3U #999]');
+ });
+
+ it('should handle streams without name', () => {
+ const streams = [{ id: 5, m3u_account: 100 }];
+ const accountsMap = { 100: 'Account' };
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+
+ expect(result[0].label).toBe('Stream #5 [Account]');
+ });
+
+ it('should handle streams without m3u_account', () => {
+ const streams = [{ id: 1, name: 'Stream 1' }];
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, {});
+
+ expect(result[0].label).toBe('Stream 1 [Unknown M3U]');
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/VODCardUtils.test.js b/frontend/src/utils/cards/__tests__/VODCardUtils.test.js
new file mode 100644
index 00000000..b9ada55c
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/VODCardUtils.test.js
@@ -0,0 +1,90 @@
+import { describe, it, expect } from 'vitest';
+import * as VODCardUtils from '../VODCardUtils';
+
+describe('VODCardUtils', () => {
+ describe('formatDuration', () => {
+ it('should format duration with hours and minutes', () => {
+ const result = VODCardUtils.formatDuration(3661); // 1h 1m 1s
+ expect(result).toBe('1h 1m');
+ });
+
+ it('should format duration with minutes and seconds when less than an hour', () => {
+ const result = VODCardUtils.formatDuration(125); // 2m 5s
+ expect(result).toBe('2m 5s');
+ });
+
+ it('should format duration with only minutes when seconds are zero', () => {
+ const result = VODCardUtils.formatDuration(120); // 2m 0s
+ expect(result).toBe('2m 0s');
+ });
+
+ it('should format duration with only seconds when less than a minute', () => {
+ const result = VODCardUtils.formatDuration(45);
+ expect(result).toBe('0m 45s');
+ });
+
+ it('should handle multiple hours correctly', () => {
+ const result = VODCardUtils.formatDuration(7325); // 2h 2m 5s
+ expect(result).toBe('2h 2m');
+ });
+
+ it('should return empty string for zero seconds', () => {
+ const result = VODCardUtils.formatDuration(0);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string for null', () => {
+ const result = VODCardUtils.formatDuration(null);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string for undefined', () => {
+ const result = VODCardUtils.formatDuration(undefined);
+ expect(result).toBe('');
+ });
+ });
+
+ describe('getSeasonLabel', () => {
+ it('should format season and episode numbers with padding', () => {
+ const vod = { season_number: 1, episode_number: 5 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('S01E05');
+ });
+
+ it('should format double-digit season and episode numbers', () => {
+ const vod = { season_number: 12, episode_number: 23 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('S12E23');
+ });
+
+ it('should return empty string when season_number is missing', () => {
+ const vod = { episode_number: 5 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when episode_number is missing', () => {
+ const vod = { season_number: 1 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when both are missing', () => {
+ const vod = {};
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should handle season_number of zero', () => {
+ const vod = { season_number: 0, episode_number: 1 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should handle episode_number of zero', () => {
+ const vod = { season_number: 1, episode_number: 0 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
new file mode 100644
index 00000000..9765daf3
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
@@ -0,0 +1,323 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as VodConnectionCardUtils from '../VodConnectionCardUtils';
+import * as dateTimeUtils from '../../dateTimeUtils.js';
+
+vi.mock('../../dateTimeUtils.js');
+
+describe('VodConnectionCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('formatDuration', () => {
+ it('should format duration with hours and minutes when hours > 0', () => {
+ const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s
+ expect(result).toBe('1h 1m');
+ });
+
+ it('should format duration with only minutes when less than an hour', () => {
+ const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s
+ expect(result).toBe('2m');
+ });
+
+ it('should format duration with 0 minutes when less than 60 seconds', () => {
+ const result = VodConnectionCardUtils.formatDuration(45);
+ expect(result).toBe('0m');
+ });
+
+ it('should handle multiple hours correctly', () => {
+ const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s
+ expect(result).toBe('2h 2m');
+ });
+
+ it('should return Unknown for zero seconds', () => {
+ const result = VodConnectionCardUtils.formatDuration(0);
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown for null', () => {
+ const result = VodConnectionCardUtils.formatDuration(null);
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown for undefined', () => {
+ const result = VodConnectionCardUtils.formatDuration(undefined);
+ expect(result).toBe('Unknown');
+ });
+ });
+
+ describe('formatTime', () => {
+ it('should format time with hours when hours > 0', () => {
+ const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05
+ expect(result).toBe('1:01:05');
+ });
+
+ it('should format time without hours when less than an hour', () => {
+ const result = VodConnectionCardUtils.formatTime(125); // 2:05
+ expect(result).toBe('2:05');
+ });
+
+ it('should pad minutes and seconds with zeros', () => {
+ const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05
+ expect(result).toBe('1:00:05');
+ });
+
+ it('should handle only seconds', () => {
+ const result = VodConnectionCardUtils.formatTime(45); // 0:45
+ expect(result).toBe('0:45');
+ });
+
+ it('should return 0:00 for zero seconds', () => {
+ const result = VodConnectionCardUtils.formatTime(0);
+ expect(result).toBe('0:00');
+ });
+
+ it('should return 0:00 for null', () => {
+ const result = VodConnectionCardUtils.formatTime(null);
+ expect(result).toBe('0:00');
+ });
+
+ it('should return 0:00 for undefined', () => {
+ const result = VodConnectionCardUtils.formatTime(undefined);
+ expect(result).toBe('0:00');
+ });
+ });
+
+ describe('getMovieDisplayTitle', () => {
+ it('should return content_name from vodContent', () => {
+ const vodContent = { content_name: 'The Matrix' };
+ const result = VodConnectionCardUtils.getMovieDisplayTitle(vodContent);
+ expect(result).toBe('The Matrix');
+ });
+ });
+
+ describe('getEpisodeDisplayTitle', () => {
+ it('should format title with season and episode numbers', () => {
+ const metadata = {
+ series_name: 'Breaking Bad',
+ season_number: 1,
+ episode_number: 5
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Breaking Bad - S01E05');
+ });
+
+ it('should pad single-digit season and episode numbers', () => {
+ const metadata = {
+ series_name: 'The Office',
+ season_number: 3,
+ episode_number: 9
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('The Office - S03E09');
+ });
+
+ it('should use S?? when season_number is missing', () => {
+ const metadata = {
+ series_name: 'Lost',
+ episode_number: 5
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Lost - S??E05');
+ });
+
+ it('should use E?? when episode_number is missing', () => {
+ const metadata = {
+ series_name: 'Friends',
+ season_number: 2
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Friends - S02E??');
+ });
+ });
+
+ describe('getMovieSubtitle', () => {
+ it('should return array with genre when present', () => {
+ const metadata = { genre: 'Action' };
+ const result = VodConnectionCardUtils.getMovieSubtitle(metadata);
+ expect(result).toEqual(['Action']);
+ });
+
+ it('should return empty array when genre is missing', () => {
+ const metadata = {};
+ const result = VodConnectionCardUtils.getMovieSubtitle(metadata);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getEpisodeSubtitle', () => {
+ it('should return array with episode_name when present', () => {
+ const metadata = { episode_name: 'Pilot' };
+ const result = VodConnectionCardUtils.getEpisodeSubtitle(metadata);
+ expect(result).toEqual(['Pilot']);
+ });
+
+ it('should return array with Episode when episode_name is missing', () => {
+ const metadata = {};
+ const result = VodConnectionCardUtils.getEpisodeSubtitle(metadata);
+ expect(result).toEqual(['Episode']);
+ });
+ });
+
+ describe('calculateProgress', () => {
+ beforeEach(() => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000); // 1000 seconds
+ });
+
+ it('should calculate progress from last_seek_percentage', () => {
+ const connection = {
+ last_seek_percentage: 50,
+ last_seek_timestamp: 990 // 10 seconds ago
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(110); // 50% of 200 = 100, plus 10 elapsed
+ expect(result.percentage).toBeCloseTo(55);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should cap currentTime at duration when seeking', () => {
+ const connection = {
+ last_seek_percentage: 95,
+ last_seek_timestamp: 900 // 100 seconds ago
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(200); // Capped at duration
+ expect(result.percentage).toBe(100);
+ });
+
+ it('should fallback to position_seconds when seek data unavailable', () => {
+ const connection = {
+ position_seconds: 75
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(75);
+ expect(result.percentage).toBe(37.5);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should return zero progress when no connection data', () => {
+ const result = VodConnectionCardUtils.calculateProgress(null, 200);
+
+ expect(result.currentTime).toBe(0);
+ expect(result.percentage).toBe(0);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should return zero progress when duration is missing', () => {
+ const connection = { position_seconds: 50 };
+ const result = VodConnectionCardUtils.calculateProgress(connection, null);
+
+ expect(result.currentTime).toBe(0);
+ expect(result.percentage).toBe(0);
+ expect(result.totalTime).toBe(0);
+ });
+
+ it('should ensure currentTime is not negative', () => {
+ const connection = {
+ last_seek_percentage: 10,
+ last_seek_timestamp: 2000 // In the future somehow
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('calculateConnectionDuration', () => {
+ it('should use duration from connection when available', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m');
+ const connection = { duration: 5400 };
+
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds');
+ expect(result).toBe('1h 30m');
+ });
+
+ it('should calculate duration from client_id timestamp when duration missing', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
+
+ const connection = { client_id: 'vod_900000_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds');
+ expect(result).toBe('45m');
+ });
+
+ it('should return Unknown duration when no data available', () => {
+ const connection = {};
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(result).toBe('Unknown duration');
+ });
+
+ it('should return Unknown duration when client_id is invalid format', () => {
+ const connection = { client_id: 'invalid_format' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(result).toBe('Unknown duration');
+ });
+
+ it('should handle parsing errors gracefully', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
+
+ const connection = { client_id: 'vod_invalid_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ // If parseInt fails, the code should still handle it
+ expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation
+ });
+ });
+
+ describe('calculateConnectionStartTime', () => {
+ it('should format connected_at timestamp when available', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
+
+ const connection = { connected_at: 1705329000 };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/15/2024 14:30:00');
+ });
+
+ it('should calculate start time from client_id when connected_at missing', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
+
+ const connection = { client_id: 'vod_1705323600000_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/15/2024 13:00:00');
+ });
+
+ it('should return Unknown when no timestamp data available', () => {
+ const connection = {};
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown when client_id is invalid format', () => {
+ const connection = { client_id: 'invalid_format' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(result).toBe('Unknown');
+ });
+
+ it('should handle parsing errors gracefully', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
+
+ const connection = { client_id: 'vod_notanumber_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ // If parseInt succeeds on any number, format will be called
+ expect(result).toBe('01/15/2024 13:00:00'); // or 'Unknown' depending on implementation
+ });
+
+ });
+});
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
new file mode 100644
index 00000000..53f9912c
--- /dev/null
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -0,0 +1,267 @@
+import { useCallback, useEffect } from 'react';
+import dayjs from 'dayjs';
+import duration from 'dayjs/plugin/duration';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import useSettingsStore from '../store/settings';
+import useLocalStorage from '../hooks/useLocalStorage';
+
+dayjs.extend(duration);
+dayjs.extend(relativeTime);
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
+
+export const convertToSec = (dateTime) => dayjs(dateTime).unix();
+
+export const initializeTime = (dateTime) => dayjs(dateTime);
+
+export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
+
+export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2);
+
+export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2);
+
+export const isSame = (date1, date2, unit = 'day') =>
+ dayjs(date1).isSame(date2, unit);
+
+export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit);
+
+export const subtract = (dateTime, value, unit) =>
+ dayjs(dateTime).subtract(value, unit);
+
+export const diff = (date1, date2, unit = 'millisecond') =>
+ dayjs(date1).diff(date2, unit);
+
+export const format = (dateTime, formatStr) =>
+ dayjs(dateTime).format(formatStr);
+
+export const getNow = () => dayjs();
+
+export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize();
+
+export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
+
+export const getNowMs = () => Date.now();
+
+export const roundToNearest = (dateTime, minutes) => {
+ const current = initializeTime(dateTime);
+ const minute = current.minute();
+ const snappedMinute = Math.round(minute / minutes) * minutes;
+
+ return snappedMinute === 60
+ ? current.add(1, 'hour').minute(0)
+ : current.minute(snappedMinute);
+};
+
+export const useUserTimeZone = () => {
+ const settings = useSettingsStore((s) => s.settings);
+ const [timeZone, setTimeZone] = useLocalStorage(
+ 'time-zone',
+ dayjs.tz?.guess
+ ? dayjs.tz.guess()
+ : Intl.DateTimeFormat().resolvedOptions().timeZone
+ );
+
+ useEffect(() => {
+ const tz = settings?.['system_settings']?.value?.time_zone;
+ if (tz && tz !== timeZone) {
+ setTimeZone(tz);
+ }
+ }, [settings, timeZone, setTimeZone]);
+
+ return timeZone;
+};
+
+export const useTimeHelpers = () => {
+ const timeZone = useUserTimeZone();
+
+ const toUserTime = useCallback(
+ (value) => {
+ if (!value) return dayjs.invalid();
+ try {
+ return initializeTime(value).tz(timeZone);
+ } catch (error) {
+ return initializeTime(value);
+ }
+ },
+ [timeZone]
+ );
+
+ const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]);
+
+ return { timeZone, toUserTime, userNow };
+};
+
+export const RECURRING_DAY_OPTIONS = [
+ { value: 6, label: 'Sun' },
+ { value: 0, label: 'Mon' },
+ { value: 1, label: 'Tue' },
+ { value: 2, label: 'Wed' },
+ { value: 3, label: 'Thu' },
+ { value: 4, label: 'Fri' },
+ { value: 5, label: 'Sat' },
+];
+
+export const useDateTimeFormat = () => {
+ const [timeFormatSetting] = useLocalStorage('time-format', '12h');
+ const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
+ // Use user preference for time format
+ const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
+ const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
+
+ return [timeFormat, dateFormat];
+};
+
+export const toTimeString = (value) => {
+ if (!value) return '00:00';
+ if (typeof value === 'string') {
+ const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true);
+ if (parsed.isValid()) return parsed.format('HH:mm');
+ return value;
+ }
+ const parsed = initializeTime(value);
+ return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
+};
+
+export const parseDate = (value) => {
+ if (!value) return null;
+ const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
+ return parsed.isValid() ? parsed.toDate() : null;
+};
+
+const TIMEZONE_FALLBACKS = [
+ 'UTC',
+ 'America/New_York',
+ 'America/Chicago',
+ 'America/Denver',
+ 'America/Los_Angeles',
+ 'America/Phoenix',
+ 'America/Anchorage',
+ 'Pacific/Honolulu',
+ 'Europe/London',
+ 'Europe/Paris',
+ 'Europe/Berlin',
+ 'Europe/Madrid',
+ 'Europe/Warsaw',
+ 'Europe/Moscow',
+ 'Asia/Dubai',
+ 'Asia/Kolkata',
+ 'Asia/Shanghai',
+ 'Asia/Tokyo',
+ 'Asia/Seoul',
+ 'Australia/Sydney',
+];
+
+const getSupportedTimeZones = () => {
+ try {
+ if (typeof Intl.supportedValuesOf === 'function') {
+ return Intl.supportedValuesOf('timeZone');
+ }
+ } catch (error) {
+ console.warn('Unable to enumerate supported time zones:', error);
+ }
+ return TIMEZONE_FALLBACKS;
+};
+
+const getTimeZoneOffsetMinutes = (date, timeZone) => {
+ try {
+ const dtf = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ });
+ const parts = dtf.formatToParts(date).reduce((acc, part) => {
+ if (part.type !== 'literal') acc[part.type] = part.value;
+ return acc;
+ }, {});
+ const asUTC = Date.UTC(
+ Number(parts.year),
+ Number(parts.month) - 1,
+ Number(parts.day),
+ Number(parts.hour),
+ Number(parts.minute),
+ Number(parts.second)
+ );
+ return (asUTC - date.getTime()) / 60000;
+ } catch (error) {
+ console.warn(`Failed to compute offset for ${timeZone}:`, error);
+ return 0;
+ }
+};
+
+const formatOffset = (minutes) => {
+ const rounded = Math.round(minutes);
+ const sign = rounded < 0 ? '-' : '+';
+ const absolute = Math.abs(rounded);
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
+ const mins = String(absolute % 60).padStart(2, '0');
+ return `UTC${sign}${hours}:${mins}`;
+};
+
+export const buildTimeZoneOptions = (preferredZone) => {
+ const zones = getSupportedTimeZones();
+ const referenceYear = new Date().getUTCFullYear();
+ const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
+ const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
+
+ const options = zones
+ .map((zone) => {
+ const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
+ const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
+ const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
+ const minOffset = Math.min(janOffset, julOffset);
+ const maxOffset = Math.max(janOffset, julOffset);
+ const usesDst = minOffset !== maxOffset;
+ const labelParts = [`now ${formatOffset(currentOffset)}`];
+ if (usesDst) {
+ labelParts.push(
+ `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
+ );
+ }
+ return {
+ value: zone,
+ label: `${zone} (${labelParts.join(' | ')})`,
+ numericOffset: minOffset,
+ };
+ })
+ .sort((a, b) => {
+ if (a.numericOffset !== b.numericOffset) {
+ return a.numericOffset - b.numericOffset;
+ }
+ return a.value.localeCompare(b.value);
+ });
+ if (
+ preferredZone &&
+ !options.some((option) => option.value === preferredZone)
+ ) {
+ const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
+ options.push({
+ value: preferredZone,
+ label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
+ numericOffset: currentOffset,
+ });
+ options.sort((a, b) => {
+ if (a.numericOffset !== b.numericOffset) {
+ return a.numericOffset - b.numericOffset;
+ }
+ return a.value.localeCompare(b.value);
+ });
+ }
+ return options;
+};
+
+export const getDefaultTimeZone = () => {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ } catch (error) {
+ return 'UTC';
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
new file mode 100644
index 00000000..805bc006
--- /dev/null
+++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
@@ -0,0 +1,87 @@
+export const getStatRows = (stats) => {
+ return [
+ ['Video Codec', stats.video_codec],
+ [
+ 'Resolution',
+ stats.resolution ||
+ (stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
+ ],
+ ['FPS', stats.source_fps],
+ ['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
+ ['Audio Codec', stats.audio_codec],
+ ['Audio Channels', stats.audio_channels],
+ ['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
+ ['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
+ ].filter(([, v]) => v !== null && v !== undefined && v !== '');
+};
+
+export const getRating = (customProps, program) => {
+ return (
+ customProps.rating ||
+ customProps.rating_value ||
+ (program && program.custom_properties && program.custom_properties.rating)
+ );
+};
+
+const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
+ return arr.filter((r) => {
+ const cp = r.custom_properties || {};
+ const pr = cp.program || {};
+
+ if ((pr.tvg_id || '') !== tvid) return false;
+ if ((pr.title || '').toLowerCase() !== titleKey) return false;
+ const st = toUserTime(r.start_time);
+ return st.isAfter(userNow());
+ });
+}
+
+const dedupeByProgram = (filtered) => {
+ // Deduplicate by program.id if present, else by time+title
+ const seen = new Set();
+ const deduped = [];
+
+ for (const r of filtered) {
+ const cp = r.custom_properties || {};
+ const pr = cp.program || {};
+ // Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
+ const season = cp.season ?? pr?.custom_properties?.season;
+ const episode = cp.episode ?? pr?.custom_properties?.episode;
+ const onscreen =
+ cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
+
+ let key = null;
+ if (season != null && episode != null) key = `se:${season}:${episode}`;
+ else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
+ else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
+ else if (pr.id != null) key = `id:${pr.id}`;
+ else
+ key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
+
+ if (seen.has(key)) continue;
+ seen.add(key);
+ deduped.push(r);
+ }
+ return deduped;
+}
+
+export const getUpcomingEpisodes = (
+ isSeriesGroup,
+ allRecordings,
+ program,
+ toUserTime,
+ userNow
+) => {
+ if (!isSeriesGroup) return [];
+
+ const arr = Array.isArray(allRecordings)
+ ? allRecordings
+ : Object.values(allRecordings || {});
+ const tvid = program.tvg_id || '';
+ const titleKey = (program.title || '').toLowerCase();
+
+ const filtered = filterByUpcoming(arr, tvid, titleKey, toUserTime, userNow);
+
+ return dedupeByProgram(filtered).sort(
+ (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
+ );
+};
diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js
new file mode 100644
index 00000000..1eb9194a
--- /dev/null
+++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js
@@ -0,0 +1,66 @@
+import API from '../../api.js';
+import { toTimeString } from '../dateTimeUtils.js';
+import dayjs from 'dayjs';
+
+export const getChannelOptions = (channels) => {
+ return Object.values(channels || {})
+ .sort((a, b) => {
+ const aNum = Number(a.channel_number) || 0;
+ const bNum = Number(b.channel_number) || 0;
+ if (aNum === bNum) {
+ return (a.name || '').localeCompare(b.name || '');
+ }
+ return aNum - bNum;
+ })
+ .map((item) => ({
+ value: `${item.id}`,
+ label: item.name || `Channel ${item.id}`,
+ }));
+};
+
+export const getUpcomingOccurrences = (
+ recordings,
+ userNow,
+ ruleId,
+ toUserTime
+) => {
+ const list = Array.isArray(recordings)
+ ? recordings
+ : Object.values(recordings || {});
+ const now = userNow();
+ return list
+ .filter(
+ (rec) =>
+ rec?.custom_properties?.rule?.id === ruleId &&
+ toUserTime(rec.start_time).isAfter(now)
+ )
+ .sort(
+ (a, b) =>
+ toUserTime(a.start_time).valueOf() - toUserTime(b.start_time).valueOf()
+ );
+};
+
+export const updateRecurringRule = async (ruleId, values) => {
+ await API.updateRecurringRule(ruleId, {
+ channel: values.channel_id,
+ days_of_week: (values.days_of_week || []).map((d) => Number(d)),
+ start_time: toTimeString(values.start_time),
+ end_time: toTimeString(values.end_time),
+ start_date: values.start_date
+ ? dayjs(values.start_date).format('YYYY-MM-DD')
+ : null,
+ end_date: values.end_date
+ ? dayjs(values.end_date).format('YYYY-MM-DD')
+ : null,
+ name: values.rule_name?.trim() || '',
+ enabled: Boolean(values.enabled),
+ });
+};
+
+export const deleteRecurringRuleById = async (ruleId) => {
+ await API.deleteRecurringRule(ruleId);
+};
+
+export const updateRecurringRuleEnabled = async (ruleId, checked) => {
+ await API.updateRecurringRule(ruleId, { enabled: checked });
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
new file mode 100644
index 00000000..af85dce4
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
@@ -0,0 +1,633 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils';
+import dayjs from 'dayjs';
+
+describe('RecordingDetailsModalUtils', () => {
+ describe('getStatRows', () => {
+ it('should return all stats when all values are present', () => {
+ const stats = {
+ video_codec: 'H.264',
+ resolution: '1920x1080',
+ width: 1920,
+ height: 1080,
+ source_fps: 30,
+ video_bitrate: 5000,
+ audio_codec: 'AAC',
+ audio_channels: 2,
+ sample_rate: 48000,
+ audio_bitrate: 128
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['Resolution', '1920x1080'],
+ ['FPS', 30],
+ ['Video Bitrate', '5000 kb/s'],
+ ['Audio Codec', 'AAC'],
+ ['Audio Channels', 2],
+ ['Sample Rate', '48000 Hz'],
+ ['Audio Bitrate', '128 kb/s']
+ ]);
+ });
+
+ it('should use width x height when resolution is not present', () => {
+ const stats = {
+ width: 1280,
+ height: 720
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Resolution', '1280x720']
+ ]);
+ });
+
+ it('should prefer resolution over width/height', () => {
+ const stats = {
+ resolution: '1920x1080',
+ width: 1280,
+ height: 720
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Resolution', '1920x1080']
+ ]);
+ });
+
+ it('should filter out null values', () => {
+ const stats = {
+ video_codec: 'H.264',
+ resolution: null,
+ source_fps: 30
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['FPS', 30]
+ ]);
+ });
+
+ it('should filter out undefined values', () => {
+ const stats = {
+ video_codec: 'H.264',
+ source_fps: undefined,
+ audio_codec: 'AAC'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['Audio Codec', 'AAC']
+ ]);
+ });
+
+ it('should filter out empty strings', () => {
+ const stats = {
+ video_codec: '',
+ audio_codec: 'AAC'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Audio Codec', 'AAC']
+ ]);
+ });
+
+ it('should handle missing width or height gracefully', () => {
+ const stats = {
+ width: 1920,
+ video_codec: 'H.264'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264']
+ ]);
+ });
+
+ it('should format bitrates correctly', () => {
+ const stats = {
+ video_bitrate: 2500,
+ audio_bitrate: 192
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Bitrate', '2500 kb/s'],
+ ['Audio Bitrate', '192 kb/s']
+ ]);
+ });
+
+ it('should format sample rate correctly', () => {
+ const stats = {
+ sample_rate: 44100
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Sample Rate', '44100 Hz']
+ ]);
+ });
+
+ it('should return empty array when no valid stats', () => {
+ const stats = {
+ video_codec: null,
+ resolution: undefined,
+ source_fps: ''
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle empty stats object', () => {
+ const stats = {};
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getRating', () => {
+ it('should return rating from customProps', () => {
+ const customProps = { rating: 'TV-MA' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should return rating_value when rating is not present', () => {
+ const customProps = { rating_value: 'PG-13' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('PG-13');
+ });
+
+ it('should prefer rating over rating_value', () => {
+ const customProps = { rating: 'TV-MA', rating_value: 'PG-13' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should return rating from program custom_properties', () => {
+ const customProps = {};
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-14');
+ });
+
+ it('should prefer customProps rating over program rating', () => {
+ const customProps = { rating: 'TV-MA' };
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should prefer rating_value over program rating', () => {
+ const customProps = { rating_value: 'PG-13' };
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('PG-13');
+ });
+
+ it('should return undefined when no rating is available', () => {
+ const customProps = {};
+ const program = { custom_properties: {} };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should handle null program', () => {
+ const customProps = {};
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeNull();
+ });
+
+ it('should handle program without custom_properties', () => {
+ const customProps = {};
+ const program = { title: 'Test' };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getUpcomingEpisodes', () => {
+ let toUserTime;
+ let userNow;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ userNow = vi.fn(() => baseTime);
+ });
+
+ it('should return empty array when not a series group', () => {
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ false,
+ [],
+ {},
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array when allRecordings is empty', () => {
+ const program = { tvg_id: 'test', title: 'Test Show' };
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ [],
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should filter recordings by tvg_id and title', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show2', title: 'Other Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].custom_properties.program.tvg_id).toBe('show1');
+ });
+
+ it('should filter out past recordings', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2023-12-31T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ });
+
+ it('should deduplicate by season and episode', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ season: 1,
+ episode: 5,
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ season: 1,
+ episode: 5,
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by onscreen episode', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ onscreen_episode: 'S01E05',
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ onscreen_episode: 's01e05',
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by program sub_title', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ sub_title: 'The Beginning'
+ }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ sub_title: 'The Beginning'
+ }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by program id', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should sort by start time ascending', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-03T12:00:00',
+ end_time: '2024-01-03T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 3 }
+ }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ end_time: '2024-01-02T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
+ }
+ },
+ {
+ start_time: '2024-01-04T12:00:00',
+ end_time: '2024-01-04T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 4 }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ expect(result[1].start_time).toBe('2024-01-03T12:00:00');
+ expect(result[2].start_time).toBe('2024-01-04T12:00:00');
+ });
+
+ it('should handle allRecordings as object', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = {
+ rec1: {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
+ }
+ }
+ };
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should handle case-insensitive title matching', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'test show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should prefer season/episode from program custom_properties', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ custom_properties: { season: 2, episode: 3 }
+ }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ custom_properties: { season: 2, episode: 3 }
+ }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should handle missing custom_properties', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1'
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
new file mode 100644
index 00000000..e2cb95fd
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
@@ -0,0 +1,533 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as RecurringRuleModalUtils from '../RecurringRuleModalUtils';
+import API from '../../../api.js';
+import dayjs from 'dayjs';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateRecurringRule: vi.fn(),
+ deleteRecurringRule: vi.fn()
+ }
+}));
+
+describe('RecurringRuleModalUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getChannelOptions', () => {
+ it('should return sorted channel options by channel number', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10', name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' },
+ ch3: { id: 3, channel_number: '15', name: 'CBS' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'NBC' },
+ { value: '1', label: 'ABC' },
+ { value: '3', label: 'CBS' }
+ ]);
+ });
+
+ it('should sort alphabetically by name when channel numbers are equal', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10', name: 'ZBC' },
+ ch2: { id: 2, channel_number: '10', name: 'ABC' },
+ ch3: { id: 3, channel_number: '10', name: 'MBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'ABC' },
+ { value: '3', label: 'MBC' },
+ { value: '1', label: 'ZBC' }
+ ]);
+ });
+
+ it('should handle missing channel numbers', () => {
+ const channels = {
+ ch1: { id: 1, name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '1', label: 'ABC' },
+ { value: '2', label: 'NBC' }
+ ]);
+ });
+
+ it('should use fallback label when name is missing', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10' },
+ ch2: { id: 2, channel_number: '5', name: '' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'Channel 2' },
+ { value: '1', label: 'Channel 1' }
+ ]);
+ });
+
+ it('should handle empty channels object', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions({});
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null channels', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions(null);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle undefined channels', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions(undefined);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should convert channel id to string value', () => {
+ const channels = {
+ ch1: { id: 123, channel_number: '10', name: 'ABC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result[0].value).toBe('123');
+ expect(typeof result[0].value).toBe('string');
+ });
+
+ it('should handle non-numeric channel numbers', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ describe('getUpcomingOccurrences', () => {
+ let toUserTime;
+ let userNow;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ userNow = vi.fn(() => baseTime);
+ });
+
+ it('should filter recordings by rule id and future start time', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-04T12:00:00',
+ custom_properties: { rule: { id: 2 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].custom_properties.rule.id).toBe(1);
+ expect(result[1].custom_properties.rule.id).toBe(1);
+ });
+
+ it('should exclude past recordings', () => {
+ const recordings = [
+ {
+ start_time: '2023-12-31T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ });
+
+ it('should sort by start time ascending', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-04T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ expect(result[1].start_time).toBe('2024-01-03T12:00:00');
+ expect(result[2].start_time).toBe('2024-01-04T12:00:00');
+ });
+
+ it('should handle recordings as object', () => {
+ const recordings = {
+ rec1: {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ rec2: {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ };
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(2);
+ });
+
+ it('should handle empty recordings array', () => {
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ [],
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null recordings', () => {
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ null,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings without custom_properties', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00'
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings without rule', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: {}
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings with null rule', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: null }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('updateRecurringRule', () => {
+ it('should call API with formatted values', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: ['1', '3', '5'],
+ start_time: '14:30',
+ end_time: '16:00',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31',
+ rule_name: 'My Rule',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [1, 3, 5],
+ start_time: '14:30',
+ end_time: '16:00',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31',
+ name: 'My Rule',
+ enabled: true
+ });
+ });
+
+ it('should convert days_of_week to numbers', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: ['0', '6'],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: false
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [0, 6],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: false
+ });
+ });
+
+ it('should handle empty days_of_week', async () => {
+ const values = {
+ channel_id: '5',
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should format dates correctly', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: dayjs('2024-06-15'),
+ end_date: dayjs('2024-12-25'),
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: '2024-06-15',
+ end_date: '2024-12-25',
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should handle null dates', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should trim rule name', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ rule_name: ' Trimmed Name ',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: 'Trimmed Name',
+ enabled: true
+ });
+ });
+
+ it('should handle missing rule_name', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should convert enabled to boolean', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: 'true'
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+ });
+
+ describe('deleteRecurringRuleById', () => {
+ it('should call API deleteRecurringRule with rule id', async () => {
+ await RecurringRuleModalUtils.deleteRecurringRuleById(123);
+
+ expect(API.deleteRecurringRule).toHaveBeenCalledWith(123);
+ expect(API.deleteRecurringRule).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle string rule id', async () => {
+ await RecurringRuleModalUtils.deleteRecurringRuleById('456');
+
+ expect(API.deleteRecurringRule).toHaveBeenCalledWith('456');
+ });
+ });
+
+ describe('updateRecurringRuleEnabled', () => {
+ it('should call API updateRecurringRule with enabled true', async () => {
+ await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ enabled: true
+ });
+ });
+
+ it('should call API updateRecurringRule with enabled false', async () => {
+ await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ enabled: false
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
new file mode 100644
index 00000000..bbb1085a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
@@ -0,0 +1,22 @@
+import API from '../../../api.js';
+
+export const getComskipConfig = async () => {
+ return await API.getComskipConfig();
+};
+
+export const uploadComskipIni = async (file) => {
+ return await API.uploadComskipIni(file);
+};
+
+export const getDvrSettingsFormInitialValues = () => {
+ return {
+ 'tv_template': '',
+ 'movie_template': '',
+ 'tv_fallback_template': '',
+ 'movie_fallback_template': '',
+ 'comskip_enabled': false,
+ 'comskip_custom_path': '',
+ 'pre_offset_minutes': 0,
+ 'post_offset_minutes': 0,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
new file mode 100644
index 00000000..fe1eea8a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
@@ -0,0 +1,29 @@
+import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
+import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
+
+export const getNetworkAccessFormInitialValues = () => {
+ return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = '0.0.0.0/0,::/0';
+ return acc;
+ }, {});
+};
+
+export const getNetworkAccessFormValidation = () => {
+ return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = (value) => {
+ if (
+ value
+ .split(',')
+ .some(
+ (cidr) =>
+ !(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX))
+ )
+ ) {
+ return 'Invalid CIDR range';
+ }
+
+ return null;
+ };
+ return acc;
+ }, {});
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
new file mode 100644
index 00000000..864dd9b1
--- /dev/null
+++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
@@ -0,0 +1,18 @@
+import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js';
+
+export const getProxySettingsFormInitialValues = () => {
+ return Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => {
+ acc[key] = '';
+ return acc;
+ }, {});
+};
+
+export const getProxySettingDefaults = () => {
+ return {
+ buffering_timeout: 15,
+ buffering_speed: 1.0,
+ redis_chunk_ttl: 60,
+ channel_shutdown_delay: 0,
+ channel_init_grace_period: 5,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
new file mode 100644
index 00000000..db91480c
--- /dev/null
+++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
@@ -0,0 +1,19 @@
+import { isNotEmpty } from '@mantine/form';
+
+export const getStreamSettingsFormInitialValues = () => {
+ return {
+ default_user_agent: '',
+ default_stream_profile: '',
+ preferred_region: '',
+ auto_import_mapped_files: true,
+ m3u_hash_key: [],
+ };
+};
+
+export const getStreamSettingsFormValidation = () => {
+ return {
+ default_user_agent: isNotEmpty('Select a user agent'),
+ default_stream_profile: isNotEmpty('Select a stream profile'),
+ preferred_region: isNotEmpty('Select a region'),
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js
new file mode 100644
index 00000000..2d67fb75
--- /dev/null
+++ b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js
@@ -0,0 +1,5 @@
+export const getSystemSettingsFormInitialValues = () => {
+ return {
+ max_system_events: 100,
+ };
+};
diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
new file mode 100644
index 00000000..9d67039e
--- /dev/null
+++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
@@ -0,0 +1,17 @@
+import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
+
+export const saveTimeZoneSetting = async (tzValue, settings) => {
+ const existing = settings['system_settings'];
+ const currentValue = existing?.value || {};
+ const newValue = { ...currentValue, time_zone: tzValue };
+
+ if (existing?.id) {
+ await updateSetting({ ...existing, value: newValue });
+ } else {
+ await createSetting({
+ key: 'system_settings',
+ name: 'System Settings',
+ value: newValue,
+ });
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
new file mode 100644
index 00000000..49a43eb1
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as DvrSettingsFormUtils from '../DvrSettingsFormUtils';
+import API from '../../../../api.js';
+
+vi.mock('../../../../api.js');
+
+describe('DvrSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getComskipConfig', () => {
+ it('should call API.getComskipConfig and return result', async () => {
+ const mockConfig = {
+ enabled: true,
+ custom_path: '/path/to/comskip'
+ };
+ API.getComskipConfig.mockResolvedValue(mockConfig);
+
+ const result = await DvrSettingsFormUtils.getComskipConfig();
+
+ expect(API.getComskipConfig).toHaveBeenCalledWith();
+ expect(result).toEqual(mockConfig);
+ });
+
+ it('should handle API errors', async () => {
+ const error = new Error('API Error');
+ API.getComskipConfig.mockRejectedValue(error);
+
+ await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API Error');
+ });
+ });
+
+ describe('uploadComskipIni', () => {
+ it('should call API.uploadComskipIni with file and return result', async () => {
+ const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const mockResponse = { success: true };
+ API.uploadComskipIni.mockResolvedValue(mockResponse);
+
+ const result = await DvrSettingsFormUtils.uploadComskipIni(mockFile);
+
+ expect(API.uploadComskipIni).toHaveBeenCalledWith(mockFile);
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle API errors', async () => {
+ const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const error = new Error('Upload failed');
+ API.uploadComskipIni.mockRejectedValue(error);
+
+ await expect(DvrSettingsFormUtils.uploadComskipIni(mockFile)).rejects.toThrow('Upload failed');
+ });
+ });
+
+ describe('getDvrSettingsFormInitialValues', () => {
+ it('should return initial values with all DVR settings', () => {
+ const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'tv_template': '',
+ 'movie_template': '',
+ 'tv_fallback_template': '',
+ 'movie_fallback_template': '',
+ 'comskip_enabled': false,
+ 'comskip_custom_path': '',
+ 'pre_offset_minutes': 0,
+ 'post_offset_minutes': 0,
+ });
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+ const result2 = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have correct default types', () => {
+ const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(typeof result['tv_template']).toBe('string');
+ expect(typeof result['movie_template']).toBe('string');
+ expect(typeof result['tv_fallback_template']).toBe('string');
+ expect(typeof result['movie_fallback_template']).toBe('string');
+ expect(typeof result['comskip_enabled']).toBe('boolean');
+ expect(typeof result['comskip_custom_path']).toBe('string');
+ expect(typeof result['pre_offset_minutes']).toBe('number');
+ expect(typeof result['post_offset_minutes']).toBe('number');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
new file mode 100644
index 00000000..d924b430
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils';
+import * as constants from '../../../../constants.js';
+
+vi.mock('../../../../constants.js', () => ({
+ NETWORK_ACCESS_OPTIONS: {}
+}));
+
+vi.mock('../../../networkUtils.js', () => ({
+ IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
+ IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/
+}));
+
+describe('NetworkAccessFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getNetworkAccessFormInitialValues', () => {
+ it('should return initial values for all network access options', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access',
+ 'network-access-api': 'API Access',
+ 'network-access-streaming': 'Streaming Access'
+ };
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result).toEqual({
+ 'network-access-admin': '0.0.0.0/0,::/0',
+ 'network-access-api': '0.0.0.0/0,::/0',
+ 'network-access-streaming': '0.0.0.0/0,::/0'
+ });
+ });
+
+ it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result).toEqual({});
+ });
+
+ it('should return a new object each time', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access'
+ };
+
+ const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+ const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ describe('getNetworkAccessFormValidation', () => {
+ beforeEach(() => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access',
+ 'network-access-api': 'API Access'
+ };
+ });
+
+ it('should return validation functions for all network access options', () => {
+ const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+
+ expect(Object.keys(result)).toEqual(['network-access-admin', 'network-access-api']);
+ expect(typeof result['network-access-admin']).toBe('function');
+ expect(typeof result['network-access-api']).toBe('function');
+ });
+
+ it('should validate valid IPv4 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24')).toBeNull();
+ expect(validator('10.0.0.0/8')).toBeNull();
+ expect(validator('0.0.0.0/0')).toBeNull();
+ });
+
+ it('should validate valid IPv6 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('2001:db8::/32')).toBeNull();
+ expect(validator('::/0')).toBeNull();
+ });
+
+ it('should validate multiple CIDR ranges separated by commas', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
+ expect(validator('0.0.0.0/0,::/0')).toBeNull();
+ expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull();
+ });
+
+ it('should return error for invalid IPv4 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
+ expect(validator('invalid')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range');
+ });
+
+ it('should return error when any CIDR in comma-separated list is invalid', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
+ expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe('Invalid CIDR range');
+ });
+
+ it('should handle empty strings', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('')).toBe('Invalid CIDR range');
+ });
+
+ it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+
+ expect(result).toEqual({});
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
new file mode 100644
index 00000000..d6fe3008
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as ProxySettingsFormUtils from '../ProxySettingsFormUtils';
+import * as constants from '../../../../constants.js';
+
+vi.mock('../../../../constants.js', () => ({
+ PROXY_SETTINGS_OPTIONS: {}
+}));
+
+describe('ProxySettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getProxySettingsFormInitialValues', () => {
+ it('should return initial values for all proxy settings options', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
+ 'proxy-buffering-timeout': 'Buffering Timeout',
+ 'proxy-buffering-speed': 'Buffering Speed',
+ 'proxy-redis-chunk-ttl': 'Redis Chunk TTL'
+ };
+
+ const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'proxy-buffering-timeout': '',
+ 'proxy-buffering-speed': '',
+ 'proxy-redis-chunk-ttl': ''
+ });
+ });
+
+ it('should return empty object when PROXY_SETTINGS_OPTIONS is empty', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {};
+
+ const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result).toEqual({});
+ });
+
+ it('should return a new object each time', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
+ 'proxy-setting': 'Proxy Setting'
+ };
+
+ const result1 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+ const result2 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ describe('getProxySettingDefaults', () => {
+ it('should return default proxy settings', () => {
+ const result = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(result).toEqual({
+ buffering_timeout: 15,
+ buffering_speed: 1.0,
+ redis_chunk_ttl: 60,
+ channel_shutdown_delay: 0,
+ channel_init_grace_period: 5,
+ });
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = ProxySettingsFormUtils.getProxySettingDefaults();
+ const result2 = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have correct default types', () => {
+ const result = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(typeof result.buffering_timeout).toBe('number');
+ expect(typeof result.buffering_speed).toBe('number');
+ expect(typeof result.redis_chunk_ttl).toBe('number');
+ expect(typeof result.channel_shutdown_delay).toBe('number');
+ expect(typeof result.channel_init_grace_period).toBe('number');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
new file mode 100644
index 00000000..9cf87c9a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamSettingsFormUtils from '../StreamSettingsFormUtils';
+import { isNotEmpty } from '@mantine/form';
+
+vi.mock('@mantine/form', () => ({
+ isNotEmpty: vi.fn((message) => message)
+}));
+
+describe('StreamSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getStreamSettingsFormInitialValues', () => {
+ it('should return initial values with correct defaults', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'default_user_agent': '',
+ 'default_stream_profile': '',
+ 'preferred_region': '',
+ 'auto_import_mapped_files': true,
+ 'm3u_hash_key': []
+ });
+ });
+
+ it('should return boolean true for auto-import-mapped-files', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result['auto_import_mapped_files']).toBe(true);
+ expect(typeof result['auto_import_mapped_files']).toBe('boolean');
+ });
+
+ it('should return empty array for m3u-hash-key', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result['m3u_hash_key']).toEqual([]);
+ expect(Array.isArray(result['m3u_hash_key'])).toBe(true);
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should return a new array instance for m3u-hash-key each time', () => {
+ const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']);
+ });
+ });
+
+ describe('getStreamSettingsFormValidation', () => {
+ it('should return validation functions for required fields', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(Object.keys(result)).toEqual([
+ 'default_user_agent',
+ 'default_stream_profile',
+ 'preferred_region'
+ ]);
+ });
+
+ it('should use isNotEmpty validator for default_user_agent', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a user agent');
+ });
+
+ it('should use isNotEmpty validator for default_stream_profile', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
+ });
+
+ it('should use isNotEmpty validator for preferred_region', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
+ });
+
+ it('should not include validation for auto-import-mapped-files', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result).not.toHaveProperty('auto_import_mapped_files');
+ });
+
+ it('should not include validation for m3u-hash-key', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result).not.toHaveProperty('m3u_hash_key');
+ });
+
+ it('should return correct validation error messages', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result['default_user_agent']).toBe('Select a user agent');
+ expect(result['default_stream_profile']).toBe('Select a stream profile');
+ expect(result['preferred_region']).toBe('Select a region');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
new file mode 100644
index 00000000..1bed3529
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import * as SystemSettingsFormUtils from '../SystemSettingsFormUtils';
+
+describe('SystemSettingsFormUtils', () => {
+ describe('getSystemSettingsFormInitialValues', () => {
+ it('should return initial values with correct defaults', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'max_system_events': 100
+ });
+ });
+
+ it('should return number value for max-system-events', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result['max_system_events']).toBe(100);
+ expect(typeof result['max_system_events']).toBe('number');
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result2 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have max-system-events property', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result).toHaveProperty('max_system_events');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
new file mode 100644
index 00000000..c5471edc
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
@@ -0,0 +1,147 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as UiSettingsFormUtils from '../UiSettingsFormUtils';
+import * as SettingsUtils from '../../../pages/SettingsUtils.js';
+
+vi.mock('../../../pages/SettingsUtils.js', () => ({
+ createSetting: vi.fn(),
+ updateSetting: vi.fn()
+}));
+
+describe('UiSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('saveTimeZoneSetting', () => {
+ it('should update existing setting when id is present', async () => {
+ const tzValue = 'America/New_York';
+ const settings = {
+ 'system-time-zone': {
+ id: 123,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 123,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'America/New_York'
+ });
+ expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when existing setting has no id', async () => {
+ const tzValue = 'Europe/London';
+ const settings = {
+ 'system-time-zone': {
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Europe/London'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when system-time-zone does not exist', async () => {
+ const tzValue = 'Asia/Tokyo';
+ const settings = {};
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Asia/Tokyo'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when system-time-zone is null', async () => {
+ const tzValue = 'Pacific/Auckland';
+ const settings = {
+ 'system-time-zone': null
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Pacific/Auckland'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when id is undefined', async () => {
+ const tzValue = 'America/Los_Angeles';
+ const settings = {
+ 'system-time-zone': {
+ id: undefined,
+ key: 'system-time-zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should preserve existing properties when updating', async () => {
+ const tzValue = 'UTC';
+ const settings = {
+ 'system-time-zone': {
+ id: 456,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'America/New_York',
+ extraProp: 'should be preserved'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 456,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC',
+ extraProp: 'should be preserved'
+ });
+ });
+
+ it('should handle empty string timezone value', async () => {
+ const tzValue = '';
+ const settings = {
+ 'system-time-zone': {
+ id: 789
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 789,
+ value: ''
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js
new file mode 100644
index 00000000..8efd2254
--- /dev/null
+++ b/frontend/src/utils/networkUtils.js
@@ -0,0 +1,24 @@
+// IPv4 CIDR regex - validates IP address and prefix length (0-32)
+export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;
+
+// IPv6 CIDR regex - validates IPv6 address and prefix length (0-128)
+export const IPV6_CIDR_REGEX =
+ /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
+
+export function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
+}
+
+export function formatSpeed(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
+}
\ No newline at end of file
diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js
new file mode 100644
index 00000000..ba965343
--- /dev/null
+++ b/frontend/src/utils/notificationUtils.js
@@ -0,0 +1,9 @@
+import { notifications } from '@mantine/notifications';
+
+export function showNotification(notificationObject) {
+ return notifications.show(notificationObject);
+}
+
+export function updateNotification(notificationId, notificationObject) {
+ return notifications.update(notificationId, notificationObject);
+}
\ No newline at end of file
diff --git a/frontend/src/utils/pages/DVRUtils.js b/frontend/src/utils/pages/DVRUtils.js
new file mode 100644
index 00000000..139988d2
--- /dev/null
+++ b/frontend/src/utils/pages/DVRUtils.js
@@ -0,0 +1,90 @@
+// Deduplicate in-progress and upcoming by program id or channel+slot
+const dedupeByProgramOrSlot = (arr) => {
+ const out = [];
+ const sigs = new Set();
+
+ for (const r of arr) {
+ const cp = r.custom_properties || {};
+ const pr = cp.program || {};
+ const sig =
+ pr?.id != null
+ ? `id:${pr.id}`
+ : `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
+
+ if (sigs.has(sig)) continue;
+ sigs.add(sig);
+ out.push(r);
+ }
+ return out;
+};
+
+const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
+ // ID-based dedupe guard in case store returns duplicates
+ const seenIds = new Set();
+ for (const rec of list) {
+ if (rec && rec.id != null) {
+ const k = String(rec.id);
+ if (seenIds.has(k)) continue;
+ seenIds.add(k);
+ }
+
+ const s = toUserTime(rec.start_time);
+ const e = toUserTime(rec.end_time);
+ const status = rec.custom_properties?.status;
+
+ if (status === 'interrupted' || status === 'completed') {
+ completed.push(rec);
+ } else {
+ if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
+ else if (now.isBefore(s)) upcoming.push(rec);
+ else completed.push(rec);
+ }
+ }
+}
+
+export const categorizeRecordings = (recordings, toUserTime, now) => {
+ const inProgress = [];
+ const upcoming = [];
+ const completed = [];
+ const list = Array.isArray(recordings)
+ ? recordings
+ : Object.values(recordings || {});
+
+ dedupeById(list, toUserTime, completed, now, inProgress, upcoming);
+
+ const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort(
+ (a, b) => toUserTime(b.start_time) - toUserTime(a.start_time)
+ );
+
+ // Group upcoming by series title+tvg_id (keep only next episode)
+ const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort(
+ (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
+ );
+ const grouped = new Map();
+
+ for (const rec of upcomingDedup) {
+ const cp = rec.custom_properties || {};
+ const prog = cp.program || {};
+ const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
+ if (!grouped.has(key)) {
+ grouped.set(key, { rec, count: 1 });
+ } else {
+ const entry = grouped.get(key);
+ entry.count += 1;
+ }
+ }
+
+ const upcomingGrouped = Array.from(grouped.values()).map((e) => {
+ const item = { ...e.rec };
+ item._group_count = e.count;
+ return item;
+ });
+
+ completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time));
+
+ return {
+ inProgress: inProgressDedup,
+ upcoming: upcomingGrouped,
+ completed,
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js
new file mode 100644
index 00000000..bae98e93
--- /dev/null
+++ b/frontend/src/utils/pages/PluginsUtils.js
@@ -0,0 +1,17 @@
+import API from '../../api.js';
+
+export const updatePluginSettings = async (key, settings) => {
+ return await API.updatePluginSettings(key, settings);
+};
+export const runPluginAction = async (key, actionId) => {
+ return await API.runPluginAction(key, actionId);
+};
+export const setPluginEnabled = async (key, next) => {
+ return await API.setPluginEnabled(key, next);
+};
+export const importPlugin = async (importFile) => {
+ return await API.importPlugin(importFile);
+};
+export const deletePluginByKey = (key) => {
+ return API.deletePlugin(key);
+};
\ No newline at end of file
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
new file mode 100644
index 00000000..6ee12f60
--- /dev/null
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -0,0 +1,218 @@
+import API from '../../api.js';
+
+export const checkSetting = async (values) => {
+ return await API.checkSetting(values);
+};
+
+export const updateSetting = async (values) => {
+ return await API.updateSetting(values);
+};
+
+export const createSetting = async (values) => {
+ return await API.createSetting(values);
+};
+
+export const rehashStreams = async () => {
+ return await API.rehashStreams();
+};
+
+export const saveChangedSettings = async (settings, changedSettings) => {
+ // Group changes by their setting group based on field name prefixes
+ const groupedChanges = {
+ stream_settings: {},
+ dvr_settings: {},
+ backup_settings: {},
+ system_settings: {},
+ };
+
+ // Map of field prefixes to their groups
+ const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
+ const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
+ 'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
+ const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
+ 'retention_count', 'schedule_cron_expression'];
+ const systemFields = ['time_zone', 'max_system_events'];
+
+ for (const formKey in changedSettings) {
+ let value = changedSettings[formKey];
+
+ // Handle special grouped settings (proxy_settings and network_access)
+ if (formKey === 'proxy_settings') {
+ const existing = settings['proxy_settings'];
+ if (existing?.id) {
+ await updateSetting({ ...existing, value });
+ } else {
+ await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value });
+ }
+ continue;
+ }
+
+ if (formKey === 'network_access') {
+ const existing = settings['network_access'];
+ if (existing?.id) {
+ await updateSetting({ ...existing, value });
+ } else {
+ await createSetting({ key: 'network_access', name: 'Network Access', value });
+ }
+ continue;
+ }
+
+ // Type conversions for proper storage
+ if (formKey === 'm3u_hash_key' && Array.isArray(value)) {
+ value = value.join(',');
+ }
+
+ if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) {
+ value = parseInt(value, 10);
+ }
+
+ const numericFields = ['pre_offset_minutes', 'post_offset_minutes', 'retention_count', 'schedule_day_of_week', 'max_system_events'];
+ if (numericFields.includes(formKey) && value != null) {
+ value = typeof value === 'number' ? value : parseInt(value, 10);
+ }
+
+ const booleanFields = ['comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files'];
+ if (booleanFields.includes(formKey) && value != null) {
+ value = typeof value === 'boolean' ? value : Boolean(value);
+ }
+
+ // Route to appropriate group
+ if (streamFields.includes(formKey)) {
+ groupedChanges.stream_settings[formKey] = value;
+ } else if (dvrFields.includes(formKey)) {
+ groupedChanges.dvr_settings[formKey] = value;
+ } else if (backupFields.includes(formKey)) {
+ groupedChanges.backup_settings[formKey] = value;
+ } else if (systemFields.includes(formKey)) {
+ groupedChanges.system_settings[formKey] = value;
+ }
+ }
+
+ // Update each group that has changes
+ for (const [groupKey, changes] of Object.entries(groupedChanges)) {
+ if (Object.keys(changes).length === 0) continue;
+
+ const existing = settings[groupKey];
+ const currentValue = existing?.value || {};
+ const newValue = { ...currentValue, ...changes };
+
+ if (existing?.id) {
+ const result = await updateSetting({ ...existing, value: newValue });
+ if (!result) {
+ throw new Error(`Failed to update ${groupKey}`);
+ }
+ } else {
+ const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+ const result = await createSetting({ key: groupKey, name: name, value: newValue });
+ if (!result) {
+ throw new Error(`Failed to create ${groupKey}`);
+ }
+ }
+ }
+};
+
+export const getChangedSettings = (values, settings) => {
+ const changedSettings = {};
+
+ for (const settingKey in values) {
+ // Skip grouped settings that are handled by their own dedicated forms
+ if (settingKey === 'proxy_settings' || settingKey === 'network_access') {
+ continue;
+ }
+
+ // Only compare against existing value if the setting exists
+ const existing = settings[settingKey];
+
+ // Convert array values (like m3u_hash_key) to comma-separated strings for comparison
+ let compareValue;
+ let actualValue = values[settingKey];
+
+ if (Array.isArray(actualValue)) {
+ actualValue = actualValue.join(',');
+ compareValue = actualValue;
+ } else {
+ compareValue = String(actualValue);
+ }
+
+ // Skip empty values to avoid validation errors
+ if (!compareValue) {
+ continue;
+ }
+
+ if (!existing) {
+ // Create new setting on save - preserve original type
+ changedSettings[settingKey] = actualValue;
+ } else if (compareValue !== String(existing.value)) {
+ // If the user changed the setting's value from what's in the DB - preserve original type
+ changedSettings[settingKey] = actualValue;
+ }
+ }
+ return changedSettings;
+};
+
+export const parseSettings = (settings) => {
+ const parsed = {};
+
+ // Stream settings - direct mapping with underscore keys
+ const streamSettings = settings['stream_settings']?.value;
+ if (streamSettings && typeof streamSettings === 'object') {
+ // IDs must be strings for Select components
+ parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null;
+ parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null;
+ parsed.preferred_region = streamSettings.preferred_region;
+ parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
+
+ // m3u_hash_key should be array
+ const hashKey = streamSettings.m3u_hash_key;
+ if (typeof hashKey === 'string') {
+ parsed.m3u_hash_key = hashKey ? hashKey.split(',').filter((v) => v) : [];
+ } else if (Array.isArray(hashKey)) {
+ parsed.m3u_hash_key = hashKey;
+ } else {
+ parsed.m3u_hash_key = [];
+ }
+ }
+
+ // DVR settings - direct mapping with underscore keys
+ const dvrSettings = settings['dvr_settings']?.value;
+ if (dvrSettings && typeof dvrSettings === 'object') {
+ parsed.tv_template = dvrSettings.tv_template;
+ parsed.movie_template = dvrSettings.movie_template;
+ parsed.tv_fallback_dir = dvrSettings.tv_fallback_dir;
+ parsed.tv_fallback_template = dvrSettings.tv_fallback_template;
+ parsed.movie_fallback_template = dvrSettings.movie_fallback_template;
+ parsed.comskip_enabled = typeof dvrSettings.comskip_enabled === 'boolean' ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled);
+ parsed.comskip_custom_path = dvrSettings.comskip_custom_path;
+ parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes : parseInt(dvrSettings.pre_offset_minutes, 10) || 0;
+ parsed.post_offset_minutes = typeof dvrSettings.post_offset_minutes === 'number' ? dvrSettings.post_offset_minutes : parseInt(dvrSettings.post_offset_minutes, 10) || 0;
+ parsed.series_rules = dvrSettings.series_rules;
+ }
+
+ // Backup settings - direct mapping with underscore keys
+ const backupSettings = settings['backup_settings']?.value;
+ if (backupSettings && typeof backupSettings === 'object') {
+ parsed.schedule_enabled = typeof backupSettings.schedule_enabled === 'boolean' ? backupSettings.schedule_enabled : Boolean(backupSettings.schedule_enabled);
+ parsed.schedule_frequency = String(backupSettings.schedule_frequency || '');
+ parsed.schedule_time = String(backupSettings.schedule_time || '');
+ parsed.schedule_day_of_week = typeof backupSettings.schedule_day_of_week === 'number' ? backupSettings.schedule_day_of_week : parseInt(backupSettings.schedule_day_of_week, 10) || 0;
+ parsed.retention_count = typeof backupSettings.retention_count === 'number' ? backupSettings.retention_count : parseInt(backupSettings.retention_count, 10) || 0;
+ parsed.schedule_cron_expression = String(backupSettings.schedule_cron_expression || '');
+ }
+
+ // System settings - direct mapping with underscore keys
+ const systemSettings = settings['system_settings']?.value;
+ if (systemSettings && typeof systemSettings === 'object') {
+ parsed.time_zone = String(systemSettings.time_zone || '');
+ parsed.max_system_events = typeof systemSettings.max_system_events === 'number' ? systemSettings.max_system_events : parseInt(systemSettings.max_system_events, 10) || 100;
+ }
+
+ // Proxy and network access are already grouped objects
+ if (settings['proxy_settings']?.value) {
+ parsed.proxy_settings = settings['proxy_settings'].value;
+ }
+ if (settings['network_access']?.value) {
+ parsed.network_access = settings['network_access'].value;
+ }
+
+ return parsed;
+};
\ No newline at end of file
diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js
new file mode 100644
index 00000000..a25e33f0
--- /dev/null
+++ b/frontend/src/utils/pages/StatsUtils.js
@@ -0,0 +1,133 @@
+import API from '../../api.js';
+
+export const stopChannel = async (id) => {
+ await API.stopChannel(id);
+};
+
+export const stopClient = async (channelId, clientId) => {
+ await API.stopClient(channelId, clientId);
+};
+
+export const stopVODClient = async (clientId) => {
+ await API.stopVODClient(clientId);
+};
+
+export const fetchActiveChannelStats = async () => {
+ return await API.fetchActiveChannelStats();
+};
+
+export const getVODStats = async () => {
+ return await API.getVODStats();
+};
+
+export const getCombinedConnections = (channelHistory, vodConnections) => {
+ const activeStreams = Object.values(channelHistory).map((channel) => ({
+ type: 'stream',
+ data: channel,
+ id: channel.channel_id,
+ sortKey: channel.uptime || 0, // Use uptime for sorting streams
+ }));
+
+ // Flatten VOD connections so each individual client gets its own card
+ const vodItems = vodConnections.flatMap((vodContent) => {
+ return (vodContent.connections || []).map((connection, index) => ({
+ type: 'vod',
+ data: {
+ ...vodContent,
+ // Override the connections array to contain only this specific connection
+ connections: [connection],
+ connection_count: 1, // Each card now represents a single connection
+ // Add individual connection details at the top level for easier access
+ individual_connection: connection,
+ },
+ id: `${vodContent.content_type}-${vodContent.content_uuid}-${connection.client_id}-${index}`,
+ sortKey: connection.connected_at || Date.now() / 1000, // Use connection time for sorting
+ }));
+ });
+
+ // Combine and sort by newest connections first (higher sortKey = more recent)
+ return [...activeStreams, ...vodItems].sort((a, b) => b.sortKey - a.sortKey);
+};
+
+const getChannelWithMetadata = (
+ prevChannelHistory,
+ ch,
+ channelsByUUID,
+ channels,
+ streamProfiles
+) => {
+ let bitrates = [];
+ if (prevChannelHistory[ch.channel_id]) {
+ bitrates = [...(prevChannelHistory[ch.channel_id].bitrates || [])];
+ const bitrate =
+ ch.total_bytes - prevChannelHistory[ch.channel_id].total_bytes;
+ if (bitrate > 0) {
+ bitrates.push(bitrate);
+ }
+
+ if (bitrates.length > 15) {
+ bitrates = bitrates.slice(1);
+ }
+ }
+
+ // Find corresponding channel data
+ const channelData =
+ channelsByUUID && ch.channel_id
+ ? channels[channelsByUUID[ch.channel_id]]
+ : null;
+
+ // Find stream profile
+ const streamProfile = streamProfiles.find(
+ (profile) => profile.id == parseInt(ch.stream_profile)
+ );
+
+ return {
+ ...ch,
+ ...(channelData || {}), // Safely merge channel data if available
+ bitrates,
+ stream_profile: streamProfile || { name: 'Unknown' },
+ // Make sure stream_id is set from the active stream info
+ stream_id: ch.stream_id || null,
+ };
+};
+
+export const getClientStats = (stats) => {
+ return Object.values(stats).reduce((acc, ch) => {
+ if (ch.clients && Array.isArray(ch.clients)) {
+ return acc.concat(
+ ch.clients.map((client) => ({
+ ...client,
+ channel: ch,
+ }))
+ );
+ }
+ return acc;
+ }, []);
+};
+
+export const getStatsByChannelId = (
+ channelStats,
+ prevChannelHistory,
+ channelsByUUID,
+ channels,
+ streamProfiles
+) => {
+ const stats = {};
+
+ channelStats.channels.forEach((ch) => {
+ // Make sure we have a valid channel_id
+ if (!ch.channel_id) {
+ console.warn('Found channel without channel_id:', ch);
+ return;
+ }
+
+ stats[ch.channel_id] = getChannelWithMetadata(
+ prevChannelHistory,
+ ch,
+ channelsByUUID,
+ channels,
+ streamProfiles
+ );
+ });
+ return stats;
+};
diff --git a/frontend/src/utils/pages/VODsUtils.js b/frontend/src/utils/pages/VODsUtils.js
new file mode 100644
index 00000000..2e9455ea
--- /dev/null
+++ b/frontend/src/utils/pages/VODsUtils.js
@@ -0,0 +1,28 @@
+export const getCategoryOptions = (categories, filters) => {
+ return [
+ { value: '', label: 'All Categories' },
+ ...Object.values(categories)
+ .filter((cat) => {
+ if (filters.type === 'movies') return cat.category_type === 'movie';
+ if (filters.type === 'series') return cat.category_type === 'series';
+ return true; // 'all' shows all
+ })
+ .map((cat) => ({
+ value: `${cat.name}|${cat.category_type}`,
+ label: `${cat.name} (${cat.category_type})`,
+ })),
+ ];
+};
+
+export const filterCategoriesToEnabled = (allCategories) => {
+ return Object.keys(allCategories).reduce((acc, key) => {
+ const enabled = allCategories[key].m3u_accounts.find(
+ (account) => account.enabled === true
+ );
+ if (enabled) {
+ acc[key] = allCategories[key];
+ }
+
+ return acc;
+ }, {});
+};
diff --git a/frontend/src/utils/pages/__tests__/DVRUtils.test.js b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
new file mode 100644
index 00000000..9c5bb15f
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
@@ -0,0 +1,539 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as DVRUtils from '../DVRUtils';
+import dayjs from 'dayjs';
+
+describe('DVRUtils', () => {
+ describe('categorizeRecordings', () => {
+ let toUserTime;
+ let now;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ now = baseTime;
+ });
+
+ it('should categorize in-progress recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ expect(result.inProgress[0].id).toBe(1);
+ expect(result.upcoming).toHaveLength(0);
+ expect(result.completed).toHaveLength(0);
+ });
+
+ it('should categorize upcoming recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0].id).toBe(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.completed).toHaveLength(0);
+ });
+
+ it('should categorize completed recordings by status', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T11:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'completed' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.completed[0].id).toBe(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should categorize interrupted recordings as completed', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'interrupted' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should categorize past recordings without status as completed', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T09:00:00',
+ end_time: '2024-01-01T10:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should deduplicate in-progress by program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 100 }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { id: 100 }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should deduplicate in-progress by channel+slot when no program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should not deduplicate different channels', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(2);
+ });
+
+ it('should sort in-progress by start_time descending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: { program: { id: 1 } }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:30:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: { program: { id: 2 } }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch3',
+ custom_properties: { program: { id: 3 } }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress[0].id).toBe(2);
+ expect(result.inProgress[1].id).toBe(3);
+ expect(result.inProgress[2].id).toBe(1);
+ });
+
+ it('should group upcoming by series and keep first episode', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T16:00:00',
+ end_time: '2024-01-01T17:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0].id).toBe(1);
+ expect(result.upcoming[0]._group_count).toBe(3);
+ });
+
+ it('should group upcoming case-insensitively by title', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'show a' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(2);
+ });
+
+ it('should not group upcoming with different tvg_id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show2', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(2);
+ expect(result.upcoming[0]._group_count).toBe(1);
+ expect(result.upcoming[1]._group_count).toBe(1);
+ });
+
+ it('should sort upcoming by start_time ascending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T16:00:00',
+ end_time: '2024-01-01T17:00:00',
+ channel: 'ch1',
+ custom_properties: { program: { id: 1, tvg_id: 'show1', title: 'Show A' } }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch2',
+ custom_properties: { program: { id: 2, tvg_id: 'show2', title: 'Show B' } }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch3',
+ custom_properties: { program: { id: 3, tvg_id: 'show3', title: 'Show C' } }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming[0].id).toBe(2);
+ expect(result.upcoming[1].id).toBe(3);
+ expect(result.upcoming[2].id).toBe(1);
+ });
+
+
+ it('should sort completed by end_time descending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T08:00:00',
+ end_time: '2024-01-01T09:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'completed' }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T11:00:00',
+ channel: 'ch2',
+ custom_properties: { status: 'completed' }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T09:00:00',
+ end_time: '2024-01-01T10:00:00',
+ channel: 'ch3',
+ custom_properties: { status: 'completed' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed[0].id).toBe(2);
+ expect(result.completed[1].id).toBe(3);
+ expect(result.completed[2].id).toBe(1);
+ });
+
+ it('should handle recordings as object', () => {
+ const recordings = {
+ rec1: {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ };
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should handle empty recordings array', () => {
+ const result = DVRUtils.categorizeRecordings([], toUserTime, now);
+
+ expect(result.inProgress).toEqual([]);
+ expect(result.upcoming).toEqual([]);
+ expect(result.completed).toEqual([]);
+ });
+
+ it('should handle null recordings', () => {
+ const result = DVRUtils.categorizeRecordings(null, toUserTime, now);
+
+ expect(result.inProgress).toEqual([]);
+ expect(result.upcoming).toEqual([]);
+ expect(result.completed).toEqual([]);
+ });
+
+ it('should deduplicate by recording id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ },
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should handle recordings without custom_properties', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1'
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should handle recordings without program', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(1);
+ });
+
+ it('should handle recording without id', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should deduplicate upcoming by program id before grouping', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 101, tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(2);
+ });
+
+ it('should preserve _group_count property on grouped recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming[0]._group_count).toBe(1);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
new file mode 100644
index 00000000..5d305290
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as PluginsUtils from '../PluginsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updatePluginSettings: vi.fn(),
+ runPluginAction: vi.fn(),
+ setPluginEnabled: vi.fn(),
+ importPlugin: vi.fn(),
+ deletePlugin: vi.fn()
+ }
+}));
+
+describe('PluginsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('updatePluginSettings', () => {
+ it('should call API updatePluginSettings with key and settings', async () => {
+ const key = 'test-plugin';
+ const settings = { option1: 'value1', option2: true };
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, settings);
+ expect(API.updatePluginSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const settings = { enabled: true };
+ const mockResponse = { success: true };
+
+ API.updatePluginSettings.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle empty settings object', async () => {
+ const key = 'test-plugin';
+ const settings = {};
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, {});
+ });
+
+ it('should handle null settings', async () => {
+ const key = 'test-plugin';
+ const settings = null;
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, null);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const settings = { enabled: true };
+ const error = new Error('API error');
+
+ API.updatePluginSettings.mockRejectedValue(error);
+
+ await expect(PluginsUtils.updatePluginSettings(key, settings)).rejects.toThrow('API error');
+ });
+ });
+
+ describe('runPluginAction', () => {
+ it('should call API runPluginAction with key and actionId', async () => {
+ const key = 'test-plugin';
+ const actionId = 'refresh-data';
+
+ await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(API.runPluginAction).toHaveBeenCalledWith(key, actionId);
+ expect(API.runPluginAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const actionId = 'sync';
+ const mockResponse = { status: 'completed' };
+
+ API.runPluginAction.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle numeric actionId', async () => {
+ const key = 'test-plugin';
+ const actionId = 123;
+
+ await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(API.runPluginAction).toHaveBeenCalledWith(key, 123);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const actionId = 'invalid-action';
+ const error = new Error('Action not found');
+
+ API.runPluginAction.mockRejectedValue(error);
+
+ await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow('Action not found');
+ });
+ });
+
+ describe('setPluginEnabled', () => {
+ it('should call API setPluginEnabled with key and next value', async () => {
+ const key = 'test-plugin';
+ const next = true;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, true);
+ expect(API.setPluginEnabled).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle false value', async () => {
+ const key = 'test-plugin';
+ const next = false;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, false);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const next = true;
+ const mockResponse = { enabled: true };
+
+ API.setPluginEnabled.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle truthy values', async () => {
+ const key = 'test-plugin';
+ const next = 'yes';
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, 'yes');
+ });
+
+ it('should handle falsy values', async () => {
+ const key = 'test-plugin';
+ const next = 0;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, 0);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const next = true;
+ const error = new Error('Plugin not found');
+
+ API.setPluginEnabled.mockRejectedValue(error);
+
+ await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow('Plugin not found');
+ });
+ });
+
+ describe('importPlugin', () => {
+ it('should call API importPlugin with importFile', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+
+ await PluginsUtils.importPlugin(importFile);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(importFile);
+ expect(API.importPlugin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const mockResponse = { key: 'imported-plugin', success: true };
+
+ API.importPlugin.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.importPlugin(importFile);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle string file path', async () => {
+ const importFile = '/path/to/plugin.zip';
+
+ await PluginsUtils.importPlugin(importFile);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(importFile);
+ });
+
+ it('should handle FormData', async () => {
+ const formData = new FormData();
+ formData.append('file', new File(['content'], 'plugin.zip'));
+
+ await PluginsUtils.importPlugin(formData);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(formData);
+ });
+
+ it('should propagate API errors', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const error = new Error('Invalid plugin format');
+
+ API.importPlugin.mockRejectedValue(error);
+
+ await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow('Invalid plugin format');
+ });
+ });
+
+ describe('deletePluginByKey', () => {
+ it('should call API deletePlugin with key', () => {
+ const key = 'test-plugin';
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(key);
+ expect(API.deletePlugin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', () => {
+ const key = 'test-plugin';
+ const mockResponse = { success: true };
+
+ API.deletePlugin.mockReturnValue(mockResponse);
+
+ const result = PluginsUtils.deletePluginByKey(key);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle numeric key', () => {
+ const key = 123;
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(123);
+ });
+
+ it('should handle empty string key', () => {
+ const key = '';
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith('');
+ });
+
+ it('should handle null key', () => {
+ const key = null;
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(null);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
new file mode 100644
index 00000000..1611c7d3
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
@@ -0,0 +1,411 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as SettingsUtils from '../SettingsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ checkSetting: vi.fn(),
+ updateSetting: vi.fn(),
+ createSetting: vi.fn(),
+ rehashStreams: vi.fn()
+ }
+}));
+
+describe('SettingsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('checkSetting', () => {
+ it('should call API checkSetting with values', async () => {
+ const values = { key: 'test-setting', value: 'test-value' };
+ await SettingsUtils.checkSetting(values);
+ expect(API.checkSetting).toHaveBeenCalledWith(values);
+ expect(API.checkSetting).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('updateSetting', () => {
+ it('should call API updateSetting with values', async () => {
+ const values = { id: 1, key: 'test-setting', value: 'new-value' };
+ await SettingsUtils.updateSetting(values);
+ expect(API.updateSetting).toHaveBeenCalledWith(values);
+ expect(API.updateSetting).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('createSetting', () => {
+ it('should call API createSetting with values', async () => {
+ const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
+ await SettingsUtils.createSetting(values);
+ expect(API.createSetting).toHaveBeenCalledWith(values);
+ expect(API.createSetting).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('rehashStreams', () => {
+ it('should call API rehashStreams', async () => {
+ await SettingsUtils.rehashStreams();
+ expect(API.rehashStreams).toHaveBeenCalledWith();
+ expect(API.rehashStreams).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('saveChangedSettings', () => {
+ it('should group stream settings correctly and update', async () => {
+ const settings = {
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 5,
+ m3u_hash_key: 'channel_name'
+ }
+ }
+ };
+ const changedSettings = {
+ default_user_agent: 7,
+ preferred_region: 'UK'
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 7,
+ m3u_hash_key: 'channel_name',
+ preferred_region: 'UK'
+ }
+ });
+ });
+
+ it('should convert m3u_hash_key array to comma-separated string', async () => {
+ const settings = {
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {}
+ }
+ };
+ const changedSettings = {
+ m3u_hash_key: ['channel_name', 'channel_number']
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ m3u_hash_key: 'channel_name,channel_number'
+ }
+ });
+ });
+
+ it('should convert ID fields to integers', async () => {
+ const settings = {
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {}
+ }
+ };
+ const changedSettings = {
+ default_user_agent: '5',
+ default_stream_profile: '3'
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 5,
+ default_stream_profile: 3
+ }
+ });
+ });
+
+ it('should preserve boolean types', async () => {
+ const settings = {
+ dvr_settings: {
+ id: 2,
+ key: 'dvr_settings',
+ value: {}
+ },
+ stream_settings: {
+ id: 1,
+ key: 'stream_settings',
+ value: {}
+ }
+ };
+ const changedSettings = {
+ comskip_enabled: true,
+ auto_import_mapped_files: false
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle proxy_settings specially', async () => {
+ const settings = {
+ proxy_settings: {
+ id: 5,
+ key: 'proxy_settings',
+ value: {
+ buffering_speed: 1.0
+ }
+ }
+ };
+ const changedSettings = {
+ proxy_settings: {
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ }
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 5,
+ key: 'proxy_settings',
+ value: {
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ }
+ });
+ });
+
+ it('should create proxy_settings if it does not exist', async () => {
+ const settings = {};
+ const changedSettings = {
+ proxy_settings: {
+ buffering_speed: 2.5
+ }
+ };
+
+ API.createSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.createSetting).toHaveBeenCalledWith({
+ key: 'proxy_settings',
+ name: 'Proxy Settings',
+ value: {
+ buffering_speed: 2.5
+ }
+ });
+ });
+
+ it('should handle network_access specially', async () => {
+ const settings = {
+ network_access: {
+ id: 6,
+ key: 'network_access',
+ value: []
+ }
+ };
+ const changedSettings = {
+ network_access: ['192.168.1.0/24', '10.0.0.0/8']
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 6,
+ key: 'network_access',
+ value: ['192.168.1.0/24', '10.0.0.0/8']
+ });
+ });
+ });
+
+ describe('parseSettings', () => {
+ it('should parse grouped settings correctly', () => {
+ const mockSettings = {
+ 'stream_settings': {
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ default_user_agent: 5,
+ default_stream_profile: 3,
+ m3u_hash_key: 'channel_name,channel_number',
+ preferred_region: 'US',
+ auto_import_mapped_files: true
+ }
+ },
+ 'dvr_settings': {
+ id: 2,
+ key: 'dvr_settings',
+ value: {
+ tv_template: '/media/tv/{show}/{season}/',
+ comskip_enabled: false,
+ pre_offset_minutes: 2,
+ post_offset_minutes: 5
+ }
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+
+ // Check stream settings
+ expect(result.default_user_agent).toBe('5');
+ expect(result.default_stream_profile).toBe('3');
+ expect(result.m3u_hash_key).toEqual(['channel_name', 'channel_number']);
+ expect(result.preferred_region).toBe('US');
+ expect(result.auto_import_mapped_files).toBe(true);
+
+ // Check DVR settings
+ expect(result.tv_template).toBe('/media/tv/{show}/{season}/');
+ expect(result.comskip_enabled).toBe(false);
+ expect(result.pre_offset_minutes).toBe(2);
+ expect(result.post_offset_minutes).toBe(5);
+ });
+
+ it('should handle empty m3u_hash_key', () => {
+ const mockSettings = {
+ 'stream_settings': {
+ id: 1,
+ key: 'stream_settings',
+ value: {
+ m3u_hash_key: ''
+ }
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+ expect(result.m3u_hash_key).toEqual([]);
+ });
+
+ it('should handle proxy_settings', () => {
+ const mockSettings = {
+ 'proxy_settings': {
+ id: 5,
+ key: 'proxy_settings',
+ value: {
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ }
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+ expect(result.proxy_settings).toEqual({
+ buffering_speed: 2.5,
+ buffering_timeout: 15
+ });
+ });
+
+ it('should handle network_access', () => {
+ const mockSettings = {
+ 'network_access': {
+ id: 6,
+ key: 'network_access',
+ value: ['192.168.1.0/24', '10.0.0.0/8']
+ }
+ };
+
+ const result = SettingsUtils.parseSettings(mockSettings);
+ expect(result.network_access).toEqual(['192.168.1.0/24', '10.0.0.0/8']);
+ });
+ });
+
+ describe('getChangedSettings', () => {
+ it('should detect changes in primitive values', () => {
+ const values = {
+ time_zone: 'America/New_York',
+ max_system_events: 2000,
+ comskip_enabled: true
+ };
+ const settings = {
+ time_zone: { value: 'UTC' },
+ max_system_events: { value: 1000 },
+ comskip_enabled: { value: false }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(changes).toEqual({
+ time_zone: 'America/New_York',
+ max_system_events: 2000,
+ comskip_enabled: true
+ });
+ });
+
+ it('should not detect unchanged values', () => {
+ const values = {
+ time_zone: 'UTC',
+ max_system_events: 1000
+ };
+ const settings = {
+ time_zone: { value: 'UTC' },
+ max_system_events: { value: 1000 }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(changes).toEqual({});
+ });
+
+ it('should preserve type of numeric values', () => {
+ const values = {
+ max_system_events: 2000
+ };
+ const settings = {
+ max_system_events: { value: 1000 }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(typeof changes.max_system_events).toBe('number');
+ expect(changes.max_system_events).toBe(2000);
+ });
+
+ it('should detect changes in array values', () => {
+ const values = {
+ m3u_hash_key: ['channel_name', 'channel_number']
+ };
+ const settings = {
+ m3u_hash_key: { value: 'channel_name' }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ // Arrays are converted to comma-separated strings internally
+ expect(changes).toEqual({
+ m3u_hash_key: 'channel_name,channel_number'
+ });
+ });
+
+ it('should skip proxy_settings and network_access', () => {
+ const values = {
+ time_zone: 'America/New_York',
+ proxy_settings: {
+ buffering_speed: 2.5
+ },
+ network_access: ['192.168.1.0/24']
+ };
+ const settings = {
+ time_zone: { value: 'UTC' }
+ };
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(changes.proxy_settings).toBeUndefined();
+ expect(changes.network_access).toBeUndefined();
+ expect(changes.time_zone).toBe('America/New_York');
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/StatsUtils.test.js b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
new file mode 100644
index 00000000..ccd422b1
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
@@ -0,0 +1,654 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StatsUtils from '../StatsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ stopChannel: vi.fn(),
+ stopClient: vi.fn(),
+ stopVODClient: vi.fn(),
+ fetchActiveChannelStats: vi.fn(),
+ getVODStats: vi.fn()
+ }
+}));
+
+describe('StatsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('stopChannel', () => {
+ it('should call API stopChannel with id', async () => {
+ const id = 'channel-123';
+
+ await StatsUtils.stopChannel(id);
+
+ expect(API.stopChannel).toHaveBeenCalledWith('channel-123');
+ expect(API.stopChannel).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric id', async () => {
+ const id = 123;
+
+ await StatsUtils.stopChannel(id);
+
+ expect(API.stopChannel).toHaveBeenCalledWith(123);
+ });
+
+ it('should propagate API errors', async () => {
+ const id = 'channel-123';
+ const error = new Error('Failed to stop channel');
+
+ API.stopChannel.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopChannel(id)).rejects.toThrow('Failed to stop channel');
+ });
+ });
+
+ describe('stopClient', () => {
+ it('should call API stopClient with channelId and clientId', async () => {
+ const channelId = 'channel-123';
+ const clientId = 'client-456';
+
+ await StatsUtils.stopClient(channelId, clientId);
+
+ expect(API.stopClient).toHaveBeenCalledWith('channel-123', 'client-456');
+ expect(API.stopClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric ids', async () => {
+ const channelId = 123;
+ const clientId = 456;
+
+ await StatsUtils.stopClient(channelId, clientId);
+
+ expect(API.stopClient).toHaveBeenCalledWith(123, 456);
+ });
+
+ it('should propagate API errors', async () => {
+ const channelId = 'channel-123';
+ const clientId = 'client-456';
+ const error = new Error('Failed to stop client');
+
+ API.stopClient.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow('Failed to stop client');
+ });
+ });
+
+ describe('stopVODClient', () => {
+ it('should call API stopVODClient with clientId', async () => {
+ const clientId = 'vod-client-123';
+
+ await StatsUtils.stopVODClient(clientId);
+
+ expect(API.stopVODClient).toHaveBeenCalledWith('vod-client-123');
+ expect(API.stopVODClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric clientId', async () => {
+ const clientId = 123;
+
+ await StatsUtils.stopVODClient(clientId);
+
+ expect(API.stopVODClient).toHaveBeenCalledWith(123);
+ });
+
+ it('should propagate API errors', async () => {
+ const clientId = 'vod-client-123';
+ const error = new Error('Failed to stop VOD client');
+
+ API.stopVODClient.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow('Failed to stop VOD client');
+ });
+ });
+
+ describe('fetchActiveChannelStats', () => {
+ it('should call API fetchActiveChannelStats', async () => {
+ const mockStats = { channels: [] };
+
+ API.fetchActiveChannelStats.mockResolvedValue(mockStats);
+
+ const result = await StatsUtils.fetchActiveChannelStats();
+
+ expect(API.fetchActiveChannelStats).toHaveBeenCalledWith();
+ expect(API.fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockStats);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Failed to fetch stats');
+
+ API.fetchActiveChannelStats.mockRejectedValue(error);
+
+ await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow('Failed to fetch stats');
+ });
+ });
+
+ describe('getVODStats', () => {
+ it('should call API getVODStats', async () => {
+ const mockStats = [{ content_type: 'movie', connections: [] }];
+
+ API.getVODStats.mockResolvedValue(mockStats);
+
+ const result = await StatsUtils.getVODStats();
+
+ expect(API.getVODStats).toHaveBeenCalledWith();
+ expect(API.getVODStats).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockStats);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Failed to fetch VOD stats');
+
+ API.getVODStats.mockRejectedValue(error);
+
+ await expect(StatsUtils.getVODStats()).rejects.toThrow('Failed to fetch VOD stats');
+ });
+ });
+
+ describe('getCombinedConnections', () => {
+ it('should combine channel history and VOD connections', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 100 }
+ };
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 50 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].type).toBe('stream');
+ expect(result[1].type).toBe('vod');
+ });
+
+ it('should sort by sortKey descending (newest first)', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 50 }
+ };
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+
+ expect(result[0].sortKey).toBe(100);
+ expect(result[1].sortKey).toBe(50);
+ });
+
+ it('should flatten VOD connections to individual cards', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 },
+ { client_id: 'client2', connected_at: 200 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].data.connections).toHaveLength(1);
+ expect(result[0].data.connection_count).toBe(1);
+ expect(result[0].data.individual_connection.client_id).toBe('client2');
+ expect(result[1].data.individual_connection.client_id).toBe('client1');
+ });
+
+ it('should create unique IDs for VOD items', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 },
+ { client_id: 'client2', connected_at: 200 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result[0].id).toBe('movie-uuid1-client2-1');
+ expect(result[1].id).toBe('movie-uuid1-client1-0');
+ });
+
+ it('should use uptime for stream sortKey', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 150 }
+ };
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, []);
+
+ expect(result[0].sortKey).toBe(150);
+ });
+
+ it('should default to 0 for missing uptime', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1' }
+ };
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, []);
+
+ expect(result[0].sortKey).toBe(0);
+ });
+
+ it('should use connected_at for VOD sortKey', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 250 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result[0].sortKey).toBe(250);
+ });
+
+ it('should handle empty connections array', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: []
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should handle empty inputs', () => {
+ const result = StatsUtils.getCombinedConnections({}, []);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null connections', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: null
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ describe('getClientStats', () => {
+ it('should extract clients from channel stats', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: [
+ { client_id: 'client1' },
+ { client_id: 'client2' }
+ ]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].client_id).toBe('client1');
+ expect(result[0].channel.channel_id).toBe('ch1');
+ });
+
+ it('should attach channel reference to each client', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ name: 'Channel 1',
+ clients: [
+ { client_id: 'client1' }
+ ]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result[0].channel).toEqual({
+ channel_id: 'ch1',
+ name: 'Channel 1',
+ clients: [{ client_id: 'client1' }]
+ });
+ });
+
+ it('should handle channels without clients array', () => {
+ const stats = {
+ 'ch1': { channel_id: 'ch1' },
+ 'ch2': { channel_id: 'ch2', clients: null }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle empty clients array', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: []
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should combine clients from multiple channels', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: [{ client_id: 'client1' }]
+ },
+ 'ch2': {
+ channel_id: 'ch2',
+ clients: [{ client_id: 'client2' }]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].channel.channel_id).toBe('ch1');
+ expect(result[1].channel.channel_id).toBe('ch2');
+ });
+
+ it('should handle empty stats object', () => {
+ const result = StatsUtils.getClientStats({});
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getStatsByChannelId', () => {
+ it('should create stats indexed by channel_id', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 1000 }
+ ]
+ };
+ const prevChannelHistory = {};
+ const channelsByUUID = {};
+ const channels = {};
+ const streamProfiles = [];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ channelsByUUID,
+ channels,
+ streamProfiles
+ );
+
+ expect(result).toHaveProperty('ch1');
+ expect(result.ch1.channel_id).toBe('ch1');
+ });
+
+ it('should calculate bitrates from previous history', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: [500]
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([500, 1000]);
+ });
+
+ it('should limit bitrates array to 15 entries', () => {
+ const prevBitrates = new Array(15).fill(100);
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: prevBitrates
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toHaveLength(15);
+ expect(result.ch1.bitrates[0]).toBe(100);
+ expect(result.ch1.bitrates[14]).toBe(1000);
+ });
+
+ it('should skip negative bitrates', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 500 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: []
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([]);
+ });
+
+ it('should merge channel data from channelsByUUID', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'uuid1', total_bytes: 1000 }
+ ]
+ };
+ const channelsByUUID = {
+ 'uuid1': 'channel-key-1'
+ };
+ const channels = {
+ 'channel-key-1': {
+ name: 'Channel 1',
+ logo: 'logo.png'
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ channelsByUUID,
+ channels,
+ []
+ );
+
+ expect(result.uuid1.name).toBe('Channel 1');
+ expect(result.uuid1.logo).toBe('logo.png');
+ });
+
+ it('should find and attach stream profile', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_profile: '1' }
+ ]
+ };
+ const streamProfiles = [
+ { id: 1, name: 'HD Profile' },
+ { id: 2, name: 'SD Profile' }
+ ];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ streamProfiles
+ );
+
+ expect(result.ch1.stream_profile.name).toBe('HD Profile');
+ });
+
+ it('should default to Unknown for missing stream profile', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_profile: '999' }
+ ]
+ };
+ const streamProfiles = [
+ { id: 1, name: 'HD Profile' }
+ ];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ streamProfiles
+ );
+
+ expect(result.ch1.stream_profile.name).toBe('Unknown');
+ });
+
+ it('should preserve stream_id from channel stats', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_id: 'stream-123' }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.stream_id).toBe('stream-123');
+ });
+
+ it('should set stream_id to null if missing', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1' }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.stream_id).toBeNull();
+ });
+
+ it('should skip channels without channel_id', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const channelStats = {
+ channels: [
+ { total_bytes: 1000 },
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result).not.toHaveProperty('undefined');
+ expect(result).toHaveProperty('ch1');
+ expect(consoleSpy).toHaveBeenCalledWith('Found channel without channel_id:', { total_bytes: 1000 });
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle empty channels array', () => {
+ const channelStats = { channels: [] };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result).toEqual({});
+ });
+
+ it('should initialize empty bitrates array for new channels', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 1000 }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/VODsUtils.test.js b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
new file mode 100644
index 00000000..e058ff0e
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
@@ -0,0 +1,272 @@
+import { describe, it, expect } from 'vitest';
+import * as VODsUtils from '../VODsUtils';
+
+describe('VODsUtils', () => {
+ describe('getCategoryOptions', () => {
+ it('should return all categories option plus formatted categories', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1]).toEqual({ value: 'Action|movie', label: 'Action (movie)' });
+ expect(result[2]).toEqual({ value: 'Drama|series', label: 'Drama (series)' });
+ });
+
+ it('should filter to only movies when type is movies', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Comedy', category_type: 'movie' }
+ };
+ const filters = { type: 'movies' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1].label).toContain('(movie)');
+ expect(result[2].label).toContain('(movie)');
+ });
+
+ it('should filter to only series when type is series', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Sitcom', category_type: 'series' }
+ };
+ const filters = { type: 'series' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1].label).toContain('(series)');
+ expect(result[2].label).toContain('(series)');
+ });
+
+ it('should show all categories when type is all', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should handle empty categories object', () => {
+ const categories = {};
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ });
+
+ it('should create value with name and category_type separated by pipe', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result[1].value).toBe('Action|movie');
+ });
+
+ it('should handle undefined type filter', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = {};
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should filter out categories that do not match type', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Comedy', category_type: 'movie' }
+ };
+ const filters = { type: 'series' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].value).toBe('Drama|series');
+ });
+ });
+
+ describe('filterCategoriesToEnabled', () => {
+ it('should return only categories with enabled m3u_accounts', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: true }
+ ]
+ },
+ 'cat2': {
+ name: 'Drama',
+ m3u_accounts: [
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).toHaveProperty('cat1');
+ expect(result).not.toHaveProperty('cat2');
+ });
+
+ it('should include category if any m3u_account is enabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: false },
+ { id: 2, enabled: true },
+ { id: 3, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).toHaveProperty('cat1');
+ });
+
+ it('should exclude category if all m3u_accounts are disabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: false },
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should exclude category with empty m3u_accounts array', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: []
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should preserve original category data', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ category_type: 'movie',
+ m3u_accounts: [
+ { id: 1, enabled: true }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result.cat1).toEqual(allCategories.cat1);
+ });
+
+ it('should handle empty allCategories object', () => {
+ const result = VODsUtils.filterCategoriesToEnabled({});
+
+ expect(result).toEqual({});
+ });
+
+ it('should filter multiple categories correctly', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [{ id: 1, enabled: true }]
+ },
+ 'cat2': {
+ name: 'Drama',
+ m3u_accounts: [{ id: 2, enabled: false }]
+ },
+ 'cat3': {
+ name: 'Comedy',
+ m3u_accounts: [{ id: 3, enabled: true }]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(Object.keys(result)).toHaveLength(2);
+ expect(result).toHaveProperty('cat1');
+ expect(result).toHaveProperty('cat3');
+ expect(result).not.toHaveProperty('cat2');
+ });
+
+ it('should handle category with null m3u_accounts', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: null
+ }
+ };
+
+ expect(() => {
+ VODsUtils.filterCategoriesToEnabled(allCategories);
+ }).toThrow();
+ });
+
+ it('should handle truthy enabled values', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: 1 },
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should only match strict true for enabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: 'true' }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+ });
+});
diff --git a/requirements.txt b/requirements.txt
index 9d7c1965..3416804d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,32 +1,32 @@
-Django==5.2.4
-psycopg2-binary==2.9.10
-celery[redis]==5.5.3
-djangorestframework==3.16.0
-requests==2.32.4
-psutil==7.0.0
+Django==5.2.9
+psycopg2-binary==2.9.11
+celery[redis]==5.6.0
+djangorestframework==3.16.1
+requests==2.32.5
+psutil==7.1.3
pillow
-drf-yasg>=1.20.0
+drf-yasg>=1.21.11
streamlink
python-vlc
yt-dlp
-gevent==25.5.1
+gevent==25.9.1
daphne
uwsgi
django-cors-headers
djangorestframework-simplejwt
m3u8
-rapidfuzz==3.13.0
+rapidfuzz==3.14.3
regex # Required by transformers but also used for advanced regex features
tzlocal
# PyTorch dependencies (CPU only)
--extra-index-url https://download.pytorch.org/whl/cpu/
-torch==2.7.1+cpu
+torch==2.9.1+cpu
# ML/NLP dependencies
-sentence-transformers==5.1.0
+sentence-transformers==5.2.0
channels
channels-redis==4.3.0
django-filter
django-celery-beat
-lxml==6.0.0
+lxml==6.0.2
diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py
new file mode 100644
index 00000000..1579a1f4
--- /dev/null
+++ b/scripts/update_changelog.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+"""
+Updates the CHANGELOG.md file for a new release.
+Renames [Unreleased] section to the new version with current date.
+Usage: python update_changelog.py
+"""
+import re
+import sys
+from datetime import datetime
+from pathlib import Path
+
+
+def update_changelog(version):
+ """Update CHANGELOG.md with new version and date."""
+ changelog_file = Path(__file__).parent.parent / "CHANGELOG.md"
+
+ if not changelog_file.exists():
+ print("CHANGELOG.md not found")
+ sys.exit(1)
+
+ content = changelog_file.read_text(encoding='utf-8')
+
+ # Check if there's an Unreleased section
+ if '## [Unreleased]' not in content:
+ print("No [Unreleased] section found in CHANGELOG.md")
+ sys.exit(1)
+
+ # Get current date in YYYY-MM-DD format
+ today = datetime.now().strftime('%Y-%m-%d')
+
+ # Replace [Unreleased] with new version and date, and add new [Unreleased] section
+ # This pattern preserves everything after [Unreleased] until the next version or end
+ new_content = re.sub(
+ r'## \[Unreleased\]',
+ f'## [Unreleased]\n\n## [{version}] - {today}',
+ content,
+ count=1
+ )
+
+ if new_content == content:
+ print("Failed to update CHANGELOG.md")
+ sys.exit(1)
+
+ changelog_file.write_text(new_content, encoding='utf-8')
+ print(f"CHANGELOG.md updated for version {version} ({today})")
+ return True
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python update_changelog.py ")
+ print("Example: python update_changelog.py 0.13.0")
+ sys.exit(1)
+
+ version = sys.argv[1]
+ # Remove 'v' prefix if present
+ version = version.lstrip('v')
+
+ update_changelog(version)
diff --git a/version.py b/version.py
index 504ffa0c..1aae4039 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.12.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process