From 6c1b0f9a60be6a337f80f266861bc548d5db8206 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 16 Dec 2025 11:55:22 -0800
Subject: [PATCH] Extracted component and util logic
---
.../src/components/cards/RecordingCard.jsx | 184 ++++------
.../forms/RecordingDetailsModal.jsx | 319 +++++++-----------
.../src/utils/cards/RecordingCardUtils.js | 92 +++++
.../utils/forms/RecordingDetailsModalUtils.js | 87 +++++
4 files changed, 373 insertions(+), 309 deletions(-)
create mode 100644 frontend/src/utils/cards/RecordingCardUtils.js
create mode 100644 frontend/src/utils/forms/RecordingDetailsModalUtils.js
diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx
index 1a0fe307..96dcea11 100644
--- a/frontend/src/components/cards/RecordingCard.jsx
+++ b/frontend/src/components/cards/RecordingCard.jsx
@@ -2,7 +2,6 @@ import useChannelsStore from '../../store/channels.jsx';
import useSettingsStore from '../../store/settings.jsx';
import useVideoStore from '../../store/useVideoStore.jsx';
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
-import API from '../../api.js';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
@@ -22,6 +21,17 @@ import {
} from '@mantine/core';
import { AlertTriangle, SquareX } from 'lucide-react';
import { RecordingSynopsis } from '../RecordingSynopsis.jsx';
+import {
+ deleteRecordingById,
+ deleteSeriesAndRule,
+ getPosterUrl,
+ getRecordingUrl,
+ getSeasonLabel,
+ getSeriesInfo,
+ getShowVideoUrl,
+ removeRecording,
+ runComSkip,
+} from './../../utils/cards/RecordingCardUtils.js';
export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
const channels = useChannelsStore((s) => s.channels);
@@ -33,24 +43,6 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
const channel = channels?.[recording.channel];
- const deleteRecording = (id) => {
- // Optimistically remove immediately from UI
- try {
- useChannelsStore.getState().removeRecording(id);
- } catch (error) {
- console.error('Failed to optimistically remove recording', error);
- }
- // Fire-and-forget server delete; websocket will keep others in sync
- API.deleteRecording(id).catch(() => {
- // On failure, fallback to refetch to restore state
- try {
- useChannelsStore.getState().fetchRecordings();
- } catch (error) {
- console.error('Failed to refresh recordings after delete', error);
- }
- });
- };
-
const customProps = recording.custom_properties || {};
const program = customProps.program || {};
const recordingName = program.title || 'Custom Recording';
@@ -60,13 +52,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
// Poster or channel logo
const posterLogoId = customProps.poster_logo_id;
- let posterUrl = posterLogoId
- ? `/api/channels/logos/${posterLogoId}/cache/`
- : customProps.poster_url || channel?.logo?.cache_url || '/logo.png';
- // Prefix API host in dev if using a relative path
- if (env_mode === 'dev' && posterUrl && posterUrl.startsWith('/')) {
- posterUrl = `${window.location.protocol}//${window.location.hostname}:5656${posterUrl}`;
- }
+ const posterUrl = getPosterUrl(posterLogoId, customProps, channel, env_mode);
const start = toUserTime(recording.start_time);
const end = toUserTime(recording.end_time);
@@ -85,27 +71,18 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
const onscreen =
customProps.onscreen_episode ??
program?.custom_properties?.onscreen_episode;
- const seLabel =
- season && episode
- ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
- : onscreen || null;
+ const seLabel = getSeasonLabel(season, episode, onscreen);
const handleWatchLive = () => {
if (!channel) return;
- let url = `/proxy/ts/stream/${channel.uuid}`;
- if (env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- showVideo(url, 'live');
+ showVideo(getShowVideoUrl(channel, env_mode), 'live');
};
const handleWatchRecording = () => {
// Only enable if backend provides a playable file URL in custom properties
- let fileUrl = customProps.file_url || customProps.output_file_url;
+ const fileUrl = getRecordingUrl(customProps, env_mode);
if (!fileUrl) return;
- if (env_mode === 'dev' && fileUrl.startsWith('/')) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
+
showVideo(fileUrl, 'vod', {
name: recordingName,
logo: { url: posterUrl },
@@ -115,7 +92,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
const handleRunComskip = async (e) => {
e?.stopPropagation?.();
try {
- await API.runComskip(recording.id);
+ await runComSkip(recording);
notifications.show({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
@@ -139,20 +116,16 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
if (isSeriesGroup) {
setCancelOpen(true);
} else {
- deleteRecording(recording.id);
+ removeRecording(recording.id);
}
};
- const seriesInfo = (() => {
- const cp = customProps || {};
- const pr = cp.program || {};
- return { tvg_id: pr.tvg_id, title: pr.title };
- })();
+ const seriesInfo = getSeriesInfo(customProps);
const removeUpcomingOnly = async () => {
try {
setBusy(true);
- await API.deleteRecording(recording.id);
+ await deleteRecordingById(recording.id);
} finally {
setBusy(false);
setCancelOpen(false);
@@ -167,23 +140,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
const removeSeriesAndRule = async () => {
try {
setBusy(true);
- const { tvg_id, title } = seriesInfo;
- if (tvg_id) {
- try {
- await API.bulkRemoveSeriesRecordings({
- tvg_id,
- title,
- scope: 'title',
- });
- } catch (error) {
- console.error('Failed to remove series recordings', error);
- }
- try {
- await API.deleteSeriesRule(tvg_id);
- } catch (error) {
- console.error('Failed to delete series rule', error);
- }
- }
+ await deleteSeriesAndRule(seriesInfo);
} finally {
setBusy(false);
setCancelOpen(false);
@@ -198,6 +155,51 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
}
};
+ const handleOnMainCardClick = () => {
+ if (isRecurringRule) {
+ onOpenRecurring?.(recording, false);
+ } else {
+ onOpenDetails?.(recording);
+ }
+ }
+
+ const WatchLive = () => {
+ return ;
+ }
+
+ const WatchRecording = () => {
+ return
+
+ ;
+ }
+
const MainCard = (
height: '100%',
cursor: 'pointer',
}}
- onClick={() => {
- if (isRecurringRule) {
- onOpenRecurring?.(recording, false);
- } else {
- onOpenDetails?.(recording);
- }
- }}
+ onClick={handleOnMainCardClick}
>
-
-
+
+
: 'Completed'}
{isInterrupted && }
-
+
{recordingName}
@@ -289,7 +285,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
alt={recordingName}
fallbackSrc="/logo.png"
/>
-
+
{!isSeriesGroup && subTitle && (
@@ -332,43 +328,9 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
)}
- {isInProgress && (
-
- )}
+ {isInProgress && }
- {!isUpcoming && (
-
-
-
- )}
+ {!isUpcoming && }
{!isUpcoming &&
customProps?.status === 'completed' &&
(!customProps?.comskip ||
diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx
index 9b01945c..36410b6f 100644
--- a/frontend/src/components/forms/RecordingDetailsModal.jsx
+++ b/frontend/src/components/forms/RecordingDetailsModal.jsx
@@ -1,20 +1,19 @@
import useChannelsStore from '../../store/channels.jsx';
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
import React from 'react';
-import API from '../../api.js';
-import {
- Badge,
- Button,
- Card,
- Flex,
- Group,
- Image,
- Modal,
- Stack,
- Text,
-} from '@mantine/core';
+import { Badge, Button, Card, Flex, Group, Image, Modal, Stack, Text, } from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
import { notifications } from '@mantine/notifications';
+import {
+ deleteRecordingById,
+ getPosterUrl, getRecordingUrl,
+ getSeasonLabel, getShowVideoUrl, runComSkip,
+} from '../../utils/cards/RecordingCardUtils.js';
+import {
+ getRating,
+ getStatRows,
+ getUpcomingEpisodes,
+} from '../../utils/forms/RecordingDetailsModalUtils.js';
export const RecordingDetailsModal = ({
opened,
@@ -43,26 +42,10 @@ export const RecordingDetailsModal = ({
const end = toUserTime(safeRecording.end_time);
const stats = customProps.stream_info || {};
- const statRows = [
- ['Video Codec', stats.video_codec],
- [
- 'Resolution',
- stats.resolution ||
- (stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
- ],
- ['FPS', stats.source_fps],
- ['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
- ['Audio Codec', stats.audio_codec],
- ['Audio Channels', stats.audio_channels],
- ['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
- ['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
- ].filter(([, v]) => v !== null && v !== undefined && v !== '');
+ const statRows = getStatRows(stats);
// Rating (if available)
- const rating =
- customProps.rating ||
- customProps.rating_value ||
- (program && program.custom_properties && program.custom_properties.rating);
+ const rating = getRating(customProps, program);
const ratingSystem = customProps.rating_system || 'MPAA';
const fileUrl = customProps.file_url || customProps.output_file_url;
@@ -71,61 +54,11 @@ export const RecordingDetailsModal = ({
customProps.status === 'interrupted') &&
Boolean(fileUrl);
- // Prefix in dev (Vite) if needed
- let resolvedPosterUrl = posterUrl;
- if (
- typeof import.meta !== 'undefined' &&
- import.meta.env &&
- import.meta.env.DEV
- ) {
- if (resolvedPosterUrl && resolvedPosterUrl.startsWith('/')) {
- resolvedPosterUrl = `${window.location.protocol}//${window.location.hostname}:5656${resolvedPosterUrl}`;
- }
- }
-
const isSeriesGroup = Boolean(
safeRecording._group_count && safeRecording._group_count > 1
);
const upcomingEpisodes = React.useMemo(() => {
- if (!isSeriesGroup) return [];
- const arr = Array.isArray(allRecordings)
- ? allRecordings
- : Object.values(allRecordings || {});
- const tvid = program.tvg_id || '';
- const titleKey = (program.title || '').toLowerCase();
- const filtered = arr.filter((r) => {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- if ((pr.tvg_id || '') !== tvid) return false;
- if ((pr.title || '').toLowerCase() !== titleKey) return false;
- const st = toUserTime(r.start_time);
- return st.isAfter(userNow());
- });
- // Deduplicate by program.id if present, else by time+title
- const seen = new Set();
- const deduped = [];
- for (const r of filtered) {
- const cp = r.custom_properties || {};
- const pr = cp.program || {};
- // Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
- const season = cp.season ?? pr?.custom_properties?.season;
- const episode = cp.episode ?? pr?.custom_properties?.episode;
- const onscreen =
- cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- let key = null;
- if (season != null && episode != null) key = `se:${season}:${episode}`;
- else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
- else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
- else if (pr.id != null) key = `id:${pr.id}`;
- else
- key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
- if (seen.has(key)) continue;
- seen.add(key);
- deduped.push(r);
- }
- return deduped.sort(
- (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
- );
+ return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow);
}, [
allRecordings,
isSeriesGroup,
@@ -146,27 +79,14 @@ export const RecordingDetailsModal = ({
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- const se =
- season && episode
- ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
- : onscreen || null;
+ const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
- let purl = posterLogoId
- ? `/api/channels/logos/${posterLogoId}/cache/`
- : cp.poster_url || posterUrl || '/logo.png';
- if (
- typeof import.meta !== 'undefined' &&
- import.meta.env &&
- import.meta.env.DEV &&
- purl &&
- purl.startsWith('/')
- ) {
- purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
- }
+ const purl = getPosterUrl(posterLogoId, cp, posterUrl);
+
const onRemove = async (e) => {
e?.stopPropagation?.();
try {
- await API.deleteRecording(rec.id);
+ await deleteRecordingById(rec.id);
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
@@ -176,16 +96,18 @@ export const RecordingDetailsModal = ({
console.error('Failed to refresh recordings after delete', error);
}
};
+
+ const handleOnMainCardClick = () => {
+ setChildRec(rec);
+ setChildOpen(true);
+ }
return (
{
- setChildRec(rec);
- setChildOpen(true);
- }}
+ onClick={handleOnMainCardClick}
>
-
+
{
+ const rec = childRec;
+ const now = userNow();
+ const s = toUserTime(rec.start_time);
+ const e = toUserTime(rec.end_time);
+
+ if (now.isAfter(s) && now.isBefore(e)) {
+ if (!channelMap[rec.channel]) return;
+ useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
+ }
+ }
+
+ const handleOnWatchRecording = () => {
+ let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode)
+ if (!fileUrl) return;
+
+ useVideoStore.getState().showVideo(fileUrl, 'vod', {
+ name:
+ childRec.custom_properties?.program?.title || 'Recording',
+ logo: {
+ url: getPosterUrl(
+ childRec.custom_properties?.poster_logo_id,
+ undefined,
+ channelMap[childRec.channel]?.logo?.cache_url
+ )
+ },
+ });
+ }
+
+ const WatchLive = () => {
+ return ;
+ }
+
+ const WatchRecording = () => {
+ return ;
+ }
+
+ const Edit = () => {
+ return ;
+ }
+
+ const handleRunComskip = async (e) => {
+ e.stopPropagation?.();
+ try {
+ await runComSkip(recording)
+ notifications.show({
+ title: 'Removing commercials',
+ message: 'Queued comskip for this recording',
+ color: 'blue.5',
+ autoClose: 2000,
+ });
+ } catch (error) {
+ console.error('Failed to run comskip', error);
+ }
+ }
return (
setChildOpen(false)}
recording={childRec}
channel={channelMap[childRec.channel]}
- posterUrl={
- (childRec.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
- : childRec.custom_properties?.poster_url ||
- channelMap[childRec.channel]?.logo?.cache_url) ||
- '/logo.png'
- }
+ posterUrl={getPosterUrl(
+ childRec.custom_properties?.poster_logo_id,
+ childRec.custom_properties,
+ channelMap[childRec.channel]?.logo?.cache_url
+ )}
env_mode={env_mode}
- onWatchLive={() => {
- const rec = childRec;
- const now = userNow();
- const s = toUserTime(rec.start_time);
- const e = toUserTime(rec.end_time);
- if (now.isAfter(s) && now.isBefore(e)) {
- const ch = channelMap[rec.channel];
- if (!ch) return;
- let url = `/proxy/ts/stream/${ch.uuid}`;
- if (env_mode === 'dev') {
- url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
- }
- useVideoStore.getState().showVideo(url, 'live');
- }
- }}
- onWatchRecording={() => {
- let fileUrl =
- childRec.custom_properties?.file_url ||
- childRec.custom_properties?.output_file_url;
- if (!fileUrl) return;
- if (env_mode === 'dev' && fileUrl.startsWith('/')) {
- fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
- }
- useVideoStore.getState().showVideo(fileUrl, 'vod', {
- name:
- childRec.custom_properties?.program?.title || 'Recording',
- logo: {
- url:
- (childRec.custom_properties?.poster_logo_id
- ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
- : channelMap[childRec.channel]?.logo?.cache_url) ||
- '/logo.png',
- },
- });
- }}
+ onWatchLive={handleOnWatchLive}
+ onWatchRecording={handleOnWatchRecording}
/>
)}
) : (
- {onWatchLive && (
-
- )}
- {onWatchRecording && (
-
- )}
- {onEdit && start.isAfter(userNow()) && (
-
- )}
+ {onWatchLive && }
+ {onWatchRecording && }
+ {onEdit && start.isAfter(userNow()) && }
{customProps.status === 'completed' &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
@@ -371,20 +307,7 @@ export const RecordingDetailsModal = ({
size="xs"
variant="light"
color="teal"
- onClick={async (e) => {
- e.stopPropagation?.();
- try {
- await API.runComskip(recording.id);
- notifications.show({
- title: 'Removing commercials',
- message: 'Queued comskip for this recording',
- color: 'blue.5',
- autoClose: 2000,
- });
- } catch (error) {
- console.error('Failed to run comskip', error);
- }
- }}
+ onClick={handleRunComskip}
>
Remove commercials
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/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)
+ );
+};