mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +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 useSettingsStore from '../../store/settings.jsx';
|
||||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||||
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
||||||
import API from '../../api.js';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,6 +21,17 @@ import {
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { AlertTriangle, SquareX } from 'lucide-react';
|
import { AlertTriangle, SquareX } from 'lucide-react';
|
||||||
import { RecordingSynopsis } from '../RecordingSynopsis.jsx';
|
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 }) => {
|
export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
||||||
const channels = useChannelsStore((s) => s.channels);
|
const channels = useChannelsStore((s) => s.channels);
|
||||||
|
|
@ -33,24 +43,6 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
|
|
||||||
const channel = channels?.[recording.channel];
|
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 customProps = recording.custom_properties || {};
|
||||||
const program = customProps.program || {};
|
const program = customProps.program || {};
|
||||||
const recordingName = program.title || 'Custom Recording';
|
const recordingName = program.title || 'Custom Recording';
|
||||||
|
|
@ -60,13 +52,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
|
|
||||||
// Poster or channel logo
|
// Poster or channel logo
|
||||||
const posterLogoId = customProps.poster_logo_id;
|
const posterLogoId = customProps.poster_logo_id;
|
||||||
let posterUrl = posterLogoId
|
const posterUrl = getPosterUrl(posterLogoId, customProps, channel, env_mode);
|
||||||
? `/api/channels/logos/${posterLogoId}/cache/`
|
|
||||||
: customProps.poster_url || channel?.logo?.cache_url || '/logo.png';
|
|
||||||
// Prefix API host in dev if using a relative path
|
|
||||||
if (env_mode === 'dev' && posterUrl && posterUrl.startsWith('/')) {
|
|
||||||
posterUrl = `${window.location.protocol}//${window.location.hostname}:5656${posterUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = toUserTime(recording.start_time);
|
const start = toUserTime(recording.start_time);
|
||||||
const end = toUserTime(recording.end_time);
|
const end = toUserTime(recording.end_time);
|
||||||
|
|
@ -85,27 +71,18 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
const onscreen =
|
const onscreen =
|
||||||
customProps.onscreen_episode ??
|
customProps.onscreen_episode ??
|
||||||
program?.custom_properties?.onscreen_episode;
|
program?.custom_properties?.onscreen_episode;
|
||||||
const seLabel =
|
const seLabel = getSeasonLabel(season, episode, onscreen);
|
||||||
season && episode
|
|
||||||
? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
|
|
||||||
: onscreen || null;
|
|
||||||
|
|
||||||
const handleWatchLive = () => {
|
const handleWatchLive = () => {
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
let url = `/proxy/ts/stream/${channel.uuid}`;
|
showVideo(getShowVideoUrl(channel, env_mode), 'live');
|
||||||
if (env_mode === 'dev') {
|
|
||||||
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
|
|
||||||
}
|
|
||||||
showVideo(url, 'live');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWatchRecording = () => {
|
const handleWatchRecording = () => {
|
||||||
// Only enable if backend provides a playable file URL in custom properties
|
// 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 (!fileUrl) return;
|
||||||
if (env_mode === 'dev' && fileUrl.startsWith('/')) {
|
|
||||||
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
|
|
||||||
}
|
|
||||||
showVideo(fileUrl, 'vod', {
|
showVideo(fileUrl, 'vod', {
|
||||||
name: recordingName,
|
name: recordingName,
|
||||||
logo: { url: posterUrl },
|
logo: { url: posterUrl },
|
||||||
|
|
@ -115,7 +92,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
const handleRunComskip = async (e) => {
|
const handleRunComskip = async (e) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
try {
|
try {
|
||||||
await API.runComskip(recording.id);
|
await runComSkip(recording);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Removing commercials',
|
title: 'Removing commercials',
|
||||||
message: 'Queued comskip for this recording',
|
message: 'Queued comskip for this recording',
|
||||||
|
|
@ -139,20 +116,16 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
if (isSeriesGroup) {
|
if (isSeriesGroup) {
|
||||||
setCancelOpen(true);
|
setCancelOpen(true);
|
||||||
} else {
|
} else {
|
||||||
deleteRecording(recording.id);
|
removeRecording(recording.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const seriesInfo = (() => {
|
const seriesInfo = getSeriesInfo(customProps);
|
||||||
const cp = customProps || {};
|
|
||||||
const pr = cp.program || {};
|
|
||||||
return { tvg_id: pr.tvg_id, title: pr.title };
|
|
||||||
})();
|
|
||||||
|
|
||||||
const removeUpcomingOnly = async () => {
|
const removeUpcomingOnly = async () => {
|
||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
await API.deleteRecording(recording.id);
|
await deleteRecordingById(recording.id);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setCancelOpen(false);
|
setCancelOpen(false);
|
||||||
|
|
@ -167,23 +140,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
const removeSeriesAndRule = async () => {
|
const removeSeriesAndRule = async () => {
|
||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const { tvg_id, title } = seriesInfo;
|
await deleteSeriesAndRule(seriesInfo);
|
||||||
if (tvg_id) {
|
|
||||||
try {
|
|
||||||
await API.bulkRemoveSeriesRecordings({
|
|
||||||
tvg_id,
|
|
||||||
title,
|
|
||||||
scope: 'title',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove series recordings', error);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await API.deleteSeriesRule(tvg_id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete series rule', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setCancelOpen(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 = (
|
const MainCard = (
|
||||||
<Card
|
<Card
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
|
|
@ -211,16 +213,10 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
height: '100%',
|
height: '100%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={handleOnMainCardClick}
|
||||||
if (isRecurringRule) {
|
|
||||||
onOpenRecurring?.(recording, false);
|
|
||||||
} else {
|
|
||||||
onOpenDetails?.(recording);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center" style={{ paddingBottom: 8 }}>
|
<Flex justify="space-between" align="center" pb={8}>
|
||||||
<Group gap={8} style={{ flex: 1, minWidth: 0 }}>
|
<Group gap={8} flex={1} miw={0}>
|
||||||
<Badge
|
<Badge
|
||||||
color={
|
color={
|
||||||
isInterrupted
|
isInterrupted
|
||||||
|
|
@ -241,7 +237,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
: 'Completed'}
|
: 'Completed'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isInterrupted && <AlertTriangle size={16} color="#ffa94d" />}
|
{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">
|
<Group gap={8} wrap="nowrap">
|
||||||
<Text fw={600} lineClamp={1} title={recordingName}>
|
<Text fw={600} lineClamp={1} title={recordingName}>
|
||||||
{recordingName}
|
{recordingName}
|
||||||
|
|
@ -289,7 +285,7 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
alt={recordingName}
|
alt={recordingName}
|
||||||
fallbackSrc="/logo.png"
|
fallbackSrc="/logo.png"
|
||||||
/>
|
/>
|
||||||
<Stack gap={6} style={{ flex: 1 }}>
|
<Stack gap={6} flex={1}>
|
||||||
{!isSeriesGroup && subTitle && (
|
{!isSeriesGroup && subTitle && (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
|
|
@ -332,43 +328,9 @@ export const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) =>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="xs" pt={4}>
|
<Group justify="flex-end" gap="xs" pt={4}>
|
||||||
{isInProgress && (
|
{isInProgress && <WatchLive />}
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleWatchLive();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Watch Live
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isUpcoming && (
|
{!isUpcoming && <WatchRecording />}
|
||||||
<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 &&
|
{!isUpcoming &&
|
||||||
customProps?.status === 'completed' &&
|
customProps?.status === 'completed' &&
|
||||||
(!customProps?.comskip ||
|
(!customProps?.comskip ||
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
import useChannelsStore from '../../store/channels.jsx';
|
import useChannelsStore from '../../store/channels.jsx';
|
||||||
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
||||||
import React from 'react';
|
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 useVideoStore from '../../store/useVideoStore.jsx';
|
||||||
import { notifications } from '@mantine/notifications';
|
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 = ({
|
export const RecordingDetailsModal = ({
|
||||||
opened,
|
opened,
|
||||||
|
|
@ -43,26 +42,10 @@ export const RecordingDetailsModal = ({
|
||||||
const end = toUserTime(safeRecording.end_time);
|
const end = toUserTime(safeRecording.end_time);
|
||||||
const stats = customProps.stream_info || {};
|
const stats = customProps.stream_info || {};
|
||||||
|
|
||||||
const statRows = [
|
const statRows = getStatRows(stats);
|
||||||
['Video Codec', stats.video_codec],
|
|
||||||
[
|
|
||||||
'Resolution',
|
|
||||||
stats.resolution ||
|
|
||||||
(stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
|
|
||||||
],
|
|
||||||
['FPS', stats.source_fps],
|
|
||||||
['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
|
|
||||||
['Audio Codec', stats.audio_codec],
|
|
||||||
['Audio Channels', stats.audio_channels],
|
|
||||||
['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
|
|
||||||
['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
|
|
||||||
].filter(([, v]) => v !== null && v !== undefined && v !== '');
|
|
||||||
|
|
||||||
// Rating (if available)
|
// Rating (if available)
|
||||||
const rating =
|
const rating = getRating(customProps, program);
|
||||||
customProps.rating ||
|
|
||||||
customProps.rating_value ||
|
|
||||||
(program && program.custom_properties && program.custom_properties.rating);
|
|
||||||
const ratingSystem = customProps.rating_system || 'MPAA';
|
const ratingSystem = customProps.rating_system || 'MPAA';
|
||||||
|
|
||||||
const fileUrl = customProps.file_url || customProps.output_file_url;
|
const fileUrl = customProps.file_url || customProps.output_file_url;
|
||||||
|
|
@ -71,61 +54,11 @@ export const RecordingDetailsModal = ({
|
||||||
customProps.status === 'interrupted') &&
|
customProps.status === 'interrupted') &&
|
||||||
Boolean(fileUrl);
|
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(
|
const isSeriesGroup = Boolean(
|
||||||
safeRecording._group_count && safeRecording._group_count > 1
|
safeRecording._group_count && safeRecording._group_count > 1
|
||||||
);
|
);
|
||||||
const upcomingEpisodes = React.useMemo(() => {
|
const upcomingEpisodes = React.useMemo(() => {
|
||||||
if (!isSeriesGroup) return [];
|
return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow);
|
||||||
const arr = Array.isArray(allRecordings)
|
|
||||||
? allRecordings
|
|
||||||
: Object.values(allRecordings || {});
|
|
||||||
const tvid = program.tvg_id || '';
|
|
||||||
const titleKey = (program.title || '').toLowerCase();
|
|
||||||
const filtered = arr.filter((r) => {
|
|
||||||
const cp = r.custom_properties || {};
|
|
||||||
const pr = cp.program || {};
|
|
||||||
if ((pr.tvg_id || '') !== tvid) return false;
|
|
||||||
if ((pr.title || '').toLowerCase() !== titleKey) return false;
|
|
||||||
const st = toUserTime(r.start_time);
|
|
||||||
return st.isAfter(userNow());
|
|
||||||
});
|
|
||||||
// Deduplicate by program.id if present, else by time+title
|
|
||||||
const seen = new Set();
|
|
||||||
const deduped = [];
|
|
||||||
for (const r of filtered) {
|
|
||||||
const cp = r.custom_properties || {};
|
|
||||||
const pr = cp.program || {};
|
|
||||||
// Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
|
|
||||||
const season = cp.season ?? pr?.custom_properties?.season;
|
|
||||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
|
||||||
const onscreen =
|
|
||||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
|
||||||
let key = null;
|
|
||||||
if (season != null && episode != null) key = `se:${season}:${episode}`;
|
|
||||||
else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
|
|
||||||
else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
|
|
||||||
else if (pr.id != null) key = `id:${pr.id}`;
|
|
||||||
else
|
|
||||||
key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
deduped.push(r);
|
|
||||||
}
|
|
||||||
return deduped.sort(
|
|
||||||
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
|
|
||||||
);
|
|
||||||
}, [
|
}, [
|
||||||
allRecordings,
|
allRecordings,
|
||||||
isSeriesGroup,
|
isSeriesGroup,
|
||||||
|
|
@ -146,27 +79,14 @@ export const RecordingDetailsModal = ({
|
||||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||||
const onscreen =
|
const onscreen =
|
||||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||||
const se =
|
const se = getSeasonLabel(season, episode, onscreen);
|
||||||
season && episode
|
|
||||||
? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
|
|
||||||
: onscreen || null;
|
|
||||||
const posterLogoId = cp.poster_logo_id;
|
const posterLogoId = cp.poster_logo_id;
|
||||||
let purl = posterLogoId
|
const purl = getPosterUrl(posterLogoId, cp, posterUrl);
|
||||||
? `/api/channels/logos/${posterLogoId}/cache/`
|
|
||||||
: cp.poster_url || posterUrl || '/logo.png';
|
|
||||||
if (
|
|
||||||
typeof import.meta !== 'undefined' &&
|
|
||||||
import.meta.env &&
|
|
||||||
import.meta.env.DEV &&
|
|
||||||
purl &&
|
|
||||||
purl.startsWith('/')
|
|
||||||
) {
|
|
||||||
purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
|
|
||||||
}
|
|
||||||
const onRemove = async (e) => {
|
const onRemove = async (e) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
try {
|
try {
|
||||||
await API.deleteRecording(rec.id);
|
await deleteRecordingById(rec.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete upcoming recording', 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);
|
console.error('Failed to refresh recordings after delete', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOnMainCardClick = () => {
|
||||||
|
setChildRec(rec);
|
||||||
|
setChildOpen(true);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
padding="sm"
|
padding="sm"
|
||||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={handleOnMainCardClick}
|
||||||
setChildRec(rec);
|
|
||||||
setChildOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Flex gap="sm" align="center">
|
<Flex gap="sm" align="center">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -197,7 +119,7 @@ export const RecordingDetailsModal = ({
|
||||||
alt={pr.title || recordingName}
|
alt={pr.title || recordingName}
|
||||||
fallbackSrc="/logo.png"
|
fallbackSrc="/logo.png"
|
||||||
/>
|
/>
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} flex={1}>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text
|
<Text
|
||||||
fw={600}
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
|
|
@ -263,56 +269,21 @@ export const RecordingDetailsModal = ({
|
||||||
onClose={() => setChildOpen(false)}
|
onClose={() => setChildOpen(false)}
|
||||||
recording={childRec}
|
recording={childRec}
|
||||||
channel={channelMap[childRec.channel]}
|
channel={channelMap[childRec.channel]}
|
||||||
posterUrl={
|
posterUrl={getPosterUrl(
|
||||||
(childRec.custom_properties?.poster_logo_id
|
childRec.custom_properties?.poster_logo_id,
|
||||||
? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
|
childRec.custom_properties,
|
||||||
: childRec.custom_properties?.poster_url ||
|
channelMap[childRec.channel]?.logo?.cache_url
|
||||||
channelMap[childRec.channel]?.logo?.cache_url) ||
|
)}
|
||||||
'/logo.png'
|
|
||||||
}
|
|
||||||
env_mode={env_mode}
|
env_mode={env_mode}
|
||||||
onWatchLive={() => {
|
onWatchLive={handleOnWatchLive}
|
||||||
const rec = childRec;
|
onWatchRecording={handleOnWatchRecording}
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Flex gap="lg" align="flex-start">
|
<Flex gap="lg" align="flex-start">
|
||||||
<Image
|
<Image
|
||||||
src={resolvedPosterUrl}
|
src={posterUrl}
|
||||||
w={180}
|
w={180}
|
||||||
h={240}
|
h={240}
|
||||||
fit="contain"
|
fit="contain"
|
||||||
|
|
@ -326,44 +297,9 @@ export const RecordingDetailsModal = ({
|
||||||
{channel ? `${channel.channel_number} • ${channel.name}` : '—'}
|
{channel ? `${channel.channel_number} • ${channel.name}` : '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
{onWatchLive && (
|
{onWatchLive && <WatchLive />}
|
||||||
<Button
|
{onWatchRecording && <WatchRecording />}
|
||||||
size="xs"
|
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{customProps.status === 'completed' &&
|
{customProps.status === 'completed' &&
|
||||||
(!customProps?.comskip ||
|
(!customProps?.comskip ||
|
||||||
customProps?.comskip?.status !== 'completed') && (
|
customProps?.comskip?.status !== 'completed') && (
|
||||||
|
|
@ -371,20 +307,7 @@ export const RecordingDetailsModal = ({
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="teal"
|
color="teal"
|
||||||
onClick={async (e) => {
|
onClick={handleRunComskip}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Remove commercials
|
Remove commercials
|
||||||
</Button>
|
</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