mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
Extracted component and util logic
This commit is contained in:
parent
ffd8d9fe6b
commit
6c1b0f9a60
4 changed files with 373 additions and 309 deletions
|
|
@ -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 <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const WatchRecording = () => {
|
||||
return <Tooltip
|
||||
label={
|
||||
customProps.file_url || customProps.output_file_url
|
||||
? 'Watch recording'
|
||||
: 'Recording playback not available yet'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchRecording();
|
||||
}}
|
||||
disabled={
|
||||
customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)
|
||||
}
|
||||
>
|
||||
Watch
|
||||
</Button>
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
const MainCard = (
|
||||
<Card
|
||||
shadow="sm"
|
||||
|
|
@ -211,16 +213,10 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
|||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isRecurringRule) {
|
||||
onOpenRecurring?.(recording, false);
|
||||
} else {
|
||||
onOpenDetails?.(recording);
|
||||
}
|
||||
}}
|
||||
onClick={handleOnMainCardClick}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ paddingBottom: 8 }}>
|
||||
<Group gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Flex justify="space-between" align="center" pb={8}>
|
||||
<Group gap={8} flex={1} miw={0}>
|
||||
<Badge
|
||||
color={
|
||||
isInterrupted
|
||||
|
|
@ -241,7 +237,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
|||
: 'Completed'}
|
||||
</Badge>
|
||||
{isInterrupted && <AlertTriangle size={16} color="#ffa94d" />}
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack gap={2} flex={1} miw={0}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} lineClamp={1} title={recordingName}>
|
||||
{recordingName}
|
||||
|
|
@ -289,7 +285,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
|||
alt={recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
/>
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<Stack gap={6} flex={1}>
|
||||
{!isSeriesGroup && subTitle && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
|
|
@ -332,43 +328,9 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
|||
)}
|
||||
|
||||
<Group justify="flex-end" gap="xs" pt={4}>
|
||||
{isInProgress && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>
|
||||
)}
|
||||
{isInProgress && <WatchLive />}
|
||||
|
||||
{!isUpcoming && (
|
||||
<Tooltip
|
||||
label={
|
||||
customProps.file_url || customProps.output_file_url
|
||||
? 'Watch recording'
|
||||
: 'Recording playback not available yet'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchRecording();
|
||||
}}
|
||||
disabled={
|
||||
customProps.status === 'recording' ||
|
||||
!(customProps.file_url || customProps.output_file_url)
|
||||
}
|
||||
>
|
||||
Watch
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isUpcoming && <WatchRecording />}
|
||||
{!isUpcoming &&
|
||||
customProps?.status === 'completed' &&
|
||||
(!customProps?.comskip ||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
}}
|
||||
onClick={handleOnMainCardClick}
|
||||
>
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
|
|
@ -197,7 +119,7 @@ export const RecordingDetailsModal = ({
|
|||
alt={pr.title || recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
/>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
<Text
|
||||
fw={600}
|
||||
|
|
@ -227,6 +149,90 @@ export const RecordingDetailsModal = ({
|
|||
);
|
||||
};
|
||||
|
||||
const handleOnWatchLive = () => {
|
||||
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 <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const WatchRecording = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchRecording();
|
||||
}}
|
||||
disabled={!canWatchRecording}
|
||||
>
|
||||
Watch
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const Edit = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onEdit(recording);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
|
|
@ -263,56 +269,21 @@ export const RecordingDetailsModal = ({
|
|||
onClose={() => 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}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Flex gap="lg" align="flex-start">
|
||||
<Image
|
||||
src={resolvedPosterUrl}
|
||||
src={posterUrl}
|
||||
w={180}
|
||||
h={240}
|
||||
fit="contain"
|
||||
|
|
@ -326,44 +297,9 @@ export const RecordingDetailsModal = ({
|
|||
{channel ? `${channel.channel_number} • ${channel.name}` : '—'}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
{onWatchLive && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>
|
||||
)}
|
||||
{onWatchRecording && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchRecording();
|
||||
}}
|
||||
disabled={!canWatchRecording}
|
||||
>
|
||||
Watch
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && start.isAfter(userNow()) && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onEdit(recording);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{onWatchLive && <WatchLive />}
|
||||
{onWatchRecording && <WatchRecording />}
|
||||
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||
{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
|
||||
</Button>
|
||||
|
|
|
|||
92
frontend/src/utils/cards/RecordingCardUtils.js
Normal file
92
frontend/src/utils/cards/RecordingCardUtils.js
Normal file
|
|
@ -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 };
|
||||
};
|
||||
87
frontend/src/utils/forms/RecordingDetailsModalUtils.js
Normal file
87
frontend/src/utils/forms/RecordingDetailsModalUtils.js
Normal file
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue