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
131ebf9f55
commit
21c0758cc9
13 changed files with 1871 additions and 1725 deletions
85
frontend/src/components/cards/SeriesCard.jsx
Normal file
85
frontend/src/components/cards/SeriesCard.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
CardSection,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import {Calendar, Play, Star} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
const SeriesCard = ({ series, onClick }) => {
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
|
||||
onClick={() => onClick(series)}
|
||||
>
|
||||
<CardSection>
|
||||
<Box pos="relative" h={300}>
|
||||
{series.logo?.url ? (
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
height={300}
|
||||
alt={series.name}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: '#404040',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
h={300}
|
||||
display="flex"
|
||||
>
|
||||
<Play size={48} color="#666" />
|
||||
</Box>
|
||||
)}
|
||||
{/* Add Series badge in the same position as Movie badge */}
|
||||
<Badge pos="absolute" bottom={8} left={8} color="purple">
|
||||
Series
|
||||
</Badge>
|
||||
</Box>
|
||||
</CardSection>
|
||||
|
||||
<Stack spacing={8} mt="md">
|
||||
<Text weight={500}>{series.name}</Text>
|
||||
|
||||
<Group spacing={16}>
|
||||
{series.year && (
|
||||
<Group spacing={4}>
|
||||
<Calendar size={14} color="#666" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{series.year}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{series.rating && (
|
||||
<Group spacing={4}>
|
||||
<Star size={14} color="#666" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{series.rating}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{series.genre && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{series.genre}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeriesCard;
|
||||
590
frontend/src/components/cards/StreamConnectionCard.jsx
Normal file
590
frontend/src/components/cards/StreamConnectionCard.jsx
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
|
||||
import usePlaylistsStore from '../../store/playlists.jsx';
|
||||
import useSettingsStore from '../../store/settings.jsx';
|
||||
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Select, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Gauge, HardDriveDownload, HardDriveUpload, SquareX, Timer, Users, Video } from 'lucide-react';
|
||||
import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
|
||||
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
|
||||
import { TableHelper } from '../../helpers/index.jsx';
|
||||
import logo from '../../images/logo.png';
|
||||
import { formatBytes, formatSpeed } from '../../utils/networkUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
connectedAccessor,
|
||||
durationAccessor,
|
||||
getBufferingSpeedThreshold,
|
||||
getChannelStreams,
|
||||
getLogoUrl,
|
||||
getM3uAccountsMap,
|
||||
getMatchingStreamByUrl,
|
||||
getSelectedStream,
|
||||
getStartDate,
|
||||
getStreamOptions,
|
||||
getStreamsByIds,
|
||||
switchStream,
|
||||
} from '../../utils/cards/StreamConnectionCardUtils.js';
|
||||
|
||||
// Create a separate component for each channel card to properly handle the hook
|
||||
const StreamConnectionCard = ({
|
||||
channel,
|
||||
clients,
|
||||
stopClient,
|
||||
stopChannel,
|
||||
logos,
|
||||
channelsByUUID,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
// Get M3U account data from the playlists store
|
||||
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
|
||||
// Get settings for speed threshold
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
// Get Date-format from localStorage
|
||||
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
||||
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
|
||||
// Create a map of M3U account IDs to names for quick lookup
|
||||
const m3uAccountsMap = useMemo(() => {
|
||||
return getM3uAccountsMap(m3uAccounts);
|
||||
}, [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 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 = getMatchingStreamByUrl(streamData, 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 (
|
||||
<Group>
|
||||
<Text size="sm" name={header.id}>
|
||||
{header.column.columnDef.header}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBodyCell = ({ cell, row }) => {
|
||||
switch (cell.column.id) {
|
||||
case 'actions':
|
||||
return (
|
||||
<Box sx={{ justifyContent: 'right' }}>
|
||||
<Center>
|
||||
<Tooltip label="Disconnect client">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() =>
|
||||
stopClient(
|
||||
row.original.channel.uuid,
|
||||
row.original.client_id
|
||||
)
|
||||
}
|
||||
>
|
||||
<SquareX size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const checkStreamsAfterChange = (streamId) => {
|
||||
return async () => {
|
||||
try {
|
||||
const channelId = channelsByUUID[channel.channel_id];
|
||||
if (channelId) {
|
||||
const updatedStreamData = await getChannelStreams(channelId);
|
||||
console.log('Channel streams after switch:', updatedStreamData);
|
||||
|
||||
// Update current stream information with fresh data
|
||||
const updatedStream = getSelectedStream(updatedStreamData, streamId);
|
||||
if (updatedStream?.m3u_profile) {
|
||||
setCurrentM3UProfile(updatedStream.m3u_profile);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking streams after switch:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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 = getSelectedStream(availableStreams, streamId);
|
||||
console.log('Selected stream details:', selectedStream);
|
||||
|
||||
// Make sure we're passing the correct ID to the API
|
||||
const response = await switchStream(channel, 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?.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
|
||||
showNotification({
|
||||
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(checkStreamsAfterChange(streamId), 2000);
|
||||
} catch (error) {
|
||||
console.error('Stream switch error:', error);
|
||||
showNotification({
|
||||
title: 'Error switching stream',
|
||||
message: error.toString(),
|
||||
color: 'red.5',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clientsColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'expand',
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
accessorKey: 'ip_address',
|
||||
},
|
||||
// Updated Connected column with tooltip
|
||||
{
|
||||
id: 'connected',
|
||||
header: 'Connected',
|
||||
accessorFn: connectedAccessor(dateFormat),
|
||||
cell: ({ cell }) => (
|
||||
<Tooltip
|
||||
label={
|
||||
cell.getValue() !== 'Unknown'
|
||||
? `Connected at ${cell.getValue()}`
|
||||
: 'Unknown connection time'
|
||||
}
|
||||
>
|
||||
<Text size="xs">{cell.getValue()}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
// Update Duration column with tooltip showing exact seconds
|
||||
{
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
accessorFn: durationAccessor(),
|
||||
cell: ({ cell, row }) => {
|
||||
const exactDuration =
|
||||
row.original.connected_since || row.original.connection_duration;
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
exactDuration
|
||||
? `${exactDuration.toFixed(1)} seconds`
|
||||
: 'Unknown duration'
|
||||
}
|
||||
>
|
||||
<Text size="xs">{cell.getValue()}</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: tableSize == 'compact' ? 75 : 100,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
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 (
|
||||
<Box p="xs">
|
||||
<Group spacing="xs" align="flex-start">
|
||||
<Text size="xs" fw={500} color="dimmed">
|
||||
User Agent:
|
||||
</Text>
|
||||
<Text size="xs">{row.original.user_agent || 'Unknown'}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
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 = getLogoUrl(channel.logo_id , logos, previewedStream);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
// Only fetch if we have a stream_id and NO channel.name
|
||||
if (!channel.name && channel.stream_id) {
|
||||
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 = getStreamOptions(availableStreams, m3uAccountsMap);
|
||||
|
||||
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 (
|
||||
<Card
|
||||
key={channel.channel_id}
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
color='#fff'
|
||||
maw={700}
|
||||
w={'100%'}
|
||||
>
|
||||
<Stack pos='relative' >
|
||||
<Group justify="space-between">
|
||||
<Box
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
w={100}
|
||||
h={50}
|
||||
display='flex'
|
||||
>
|
||||
<img
|
||||
src={logoUrl || logo}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
alt="channel logo"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
<Box>
|
||||
<Tooltip label={getStartDate(uptime)}>
|
||||
<Center>
|
||||
<Timer pr={5} />
|
||||
{toFriendlyDuration(uptime, 'seconds')}
|
||||
</Center>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Center>
|
||||
<Tooltip label="Stop Channel">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() => stopChannel(channel.channel_id)}
|
||||
>
|
||||
<SquareX size="24" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Flex justify="space-between" align="center">
|
||||
<Group>
|
||||
<Text fw={500}>{channelName}</Text>
|
||||
</Group>
|
||||
|
||||
<Tooltip label="Active Stream Profile">
|
||||
<Group gap={5}>
|
||||
<Video size="18" />
|
||||
{streamProfileName}
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* Display M3U profile information */}
|
||||
<Flex justify="flex-end" align="center" mt={-8}>
|
||||
<Group gap={5}>
|
||||
<HardDriveUpload size="18" />
|
||||
<Tooltip label="Current M3U Profile">
|
||||
<Text size="xs">{m3uProfileName}</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{/* Add stream selection dropdown */}
|
||||
{availableStreams.length > 0 && (
|
||||
<Tooltip label="Switch to another stream source">
|
||||
<Select
|
||||
size="xs"
|
||||
label="Active Stream"
|
||||
placeholder={
|
||||
isLoadingStreams ? 'Loading streams...' : 'Select stream'
|
||||
}
|
||||
data={streamOptions}
|
||||
value={activeStreamId || channel.stream_id?.toString() || null}
|
||||
onChange={handleStreamChange}
|
||||
disabled={isLoadingStreams}
|
||||
mt={8}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Add stream information badges */}
|
||||
<Group gap="xs" mt="xs">
|
||||
{channel.resolution && (
|
||||
<Tooltip label="Video resolution">
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
{channel.resolution}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.source_fps && (
|
||||
<Tooltip label="Source frames per second">
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{channel.source_fps} FPS
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.video_codec && (
|
||||
<Tooltip label="Video codec">
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{channel.video_codec.toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.audio_codec && (
|
||||
<Tooltip label="Audio codec">
|
||||
<Badge size="sm" variant="light" color="pink">
|
||||
{channel.audio_codec.toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.audio_channels && (
|
||||
<Tooltip label="Audio channel configuration">
|
||||
<Badge size="sm" variant="light" color="pink">
|
||||
{channel.audio_channels}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.stream_type && (
|
||||
<Tooltip label="Stream type">
|
||||
<Badge size="sm" variant="light" color="cyan">
|
||||
{channel.stream_type.toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.ffmpeg_speed && (
|
||||
<Tooltip
|
||||
label={`Current Speed: ${parseFloat(channel.ffmpeg_speed).toFixed(2)}x`}
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={
|
||||
parseFloat(channel.ffmpeg_speed) >=
|
||||
getBufferingSpeedThreshold(settings['proxy-settings'])
|
||||
? 'green'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{parseFloat(channel.ffmpeg_speed).toFixed(2)}x
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip
|
||||
label={`Current bitrate: ${formatSpeed(bitrates.at(-1) || 0)}`}
|
||||
>
|
||||
<Group gap={4} style={{ cursor: 'help' }}>
|
||||
<Gauge pr={5} size="22" />
|
||||
<Text size="sm">{formatSpeed(bitrates.at(-1) || 0)}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Tooltip label={`Average bitrate: ${avgBitrate}`}>
|
||||
<Text size="sm" style={{ cursor: 'help' }}>
|
||||
Avg: {avgBitrate}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
|
||||
<Group gap={4}>
|
||||
<Tooltip label={`Total transferred: ${formatBytes(totalBytes)}`}>
|
||||
<Group gap={4} style={{ cursor: 'help' }}>
|
||||
<HardDriveDownload size="18" />
|
||||
<Text size="sm">{formatBytes(totalBytes)}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group gap={5}>
|
||||
<Tooltip
|
||||
label={`${clientCount} active client${clientCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Group gap={4} style={{ cursor: 'help' }}>
|
||||
<Users size="18" />
|
||||
<Text size="sm">{clientCount}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<CustomTable table={channelClientsTable} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamConnectionCard;
|
||||
143
frontend/src/components/cards/VODCard.jsx
Normal file
143
frontend/src/components/cards/VODCard.jsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
CardSection,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Calendar, Clock, Play, Star } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
formatDuration,
|
||||
getSeasonLabel,
|
||||
} from '../../utils/cards/VODCardUtils.js';
|
||||
|
||||
const VODCard = ({ vod, onClick }) => {
|
||||
const isEpisode = vod.type === 'episode';
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
if (isEpisode && vod.series) {
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{vod.series.name}
|
||||
</Text>
|
||||
<Text weight={500}>
|
||||
{getSeasonLabel(vod)} - {vod.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return <Text weight={500}>{vod.name}</Text>;
|
||||
};
|
||||
|
||||
const handleCardClick = async () => {
|
||||
// Just pass the basic vod info to the parent handler
|
||||
onClick(vod);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardSection>
|
||||
<Box pos="relative" h={300}>
|
||||
{vod.logo?.url ? (
|
||||
<Image
|
||||
src={vod.logo.url}
|
||||
height={300}
|
||||
alt={vod.name}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: '#404040',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
h={300}
|
||||
display="flex"
|
||||
>
|
||||
<Play size={48} color="#666" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ActionIcon
|
||||
style={{
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
}}
|
||||
pos="absolute"
|
||||
top={8}
|
||||
right={8}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(vod);
|
||||
}}
|
||||
>
|
||||
<Play size={16} color="white" />
|
||||
</ActionIcon>
|
||||
|
||||
<Badge
|
||||
pos="absolute"
|
||||
bottom={8}
|
||||
left={8}
|
||||
color={isEpisode ? 'blue' : 'green'}
|
||||
>
|
||||
{isEpisode ? 'Episode' : 'Movie'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</CardSection>
|
||||
|
||||
<Stack spacing={8} mt="md">
|
||||
{getDisplayTitle()}
|
||||
|
||||
<Group spacing={16}>
|
||||
{vod.year && (
|
||||
<Group spacing={4}>
|
||||
<Calendar size={14} color="#666" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{vod.year}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vod.duration && (
|
||||
<Group spacing={4}>
|
||||
<Clock size={14} color="#666" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(vod.duration_secs)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vod.rating && (
|
||||
<Group spacing={4}>
|
||||
<Star size={14} color="#666" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{vod.rating}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{vod.genre && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{vod.genre}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VODCard;
|
||||
422
frontend/src/components/cards/VodConnectionCard.jsx
Normal file
422
frontend/src/components/cards/VodConnectionCard.jsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
// Format duration for content length
|
||||
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import logo from '../../images/logo.png';
|
||||
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Progress, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { convertToSec, fromNow, toFriendlyDuration } from '../../utils/dateTimeUtils.js';
|
||||
import { ChevronDown, HardDriveUpload, SquareX, Timer, Video } from 'lucide-react';
|
||||
import {
|
||||
calculateConnectionDuration,
|
||||
calculateConnectionStartTime,
|
||||
calculateProgress,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
getEpisodeDisplayTitle,
|
||||
getEpisodeSubtitle,
|
||||
getMovieDisplayTitle,
|
||||
getMovieSubtitle,
|
||||
} from '../../utils/cards/VodConnectionCardUtils.js';
|
||||
|
||||
const ClientDetails = ({ connection, connectionStartTime }) => {
|
||||
return (
|
||||
<Stack
|
||||
gap="xs"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
p={12}
|
||||
bdrs={6}
|
||||
bd={'1px solid rgba(255, 255, 255, 0.08)'}
|
||||
>
|
||||
{connection.user_agent &&
|
||||
connection.user_agent !== 'Unknown' && (
|
||||
<Group gap={8} align="flex-start">
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
User Agent:
|
||||
</Text>
|
||||
<Text size="xs" ff={'monospace'} flex={1}>
|
||||
{connection.user_agent.length > 100
|
||||
? `${connection.user_agent.substring(0, 100)}...`
|
||||
: connection.user_agent}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
Client ID:
|
||||
</Text>
|
||||
<Text size="xs" ff={'monospace'}>
|
||||
{connection.client_id || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{connection.connected_at && (
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
Connected:
|
||||
</Text>
|
||||
<Text size="xs">{connectionStartTime}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{connection.duration && connection.duration > 0 && (
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
Watch Duration:
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{toFriendlyDuration(connection.duration, 'seconds')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Seek/Position Information */}
|
||||
{(connection.last_seek_percentage > 0 ||
|
||||
connection.last_seek_byte > 0) && (
|
||||
<>
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
Last Seek:
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{connection.last_seek_percentage?.toFixed(1)}%
|
||||
{connection.total_content_size > 0 && (
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)' }}>
|
||||
{' '}
|
||||
({Math.round(connection.last_seek_byte / (1024 * 1024))}
|
||||
MB /{' '}
|
||||
{Math.round(
|
||||
connection.total_content_size / (1024 * 1024)
|
||||
)}
|
||||
MB)
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{Number(connection.last_seek_timestamp) > 0 && (
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
Seek Time:
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{fromNow(convertToSec(Number(connection.last_seek_timestamp)))}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{connection.bytes_sent > 0 && (
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
Data Sent:
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Create a VOD Card component similar to ChannelCard
|
||||
const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
||||
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;
|
||||
|
||||
// Get display title
|
||||
const getDisplayTitle = () => {
|
||||
if (isMovie) {
|
||||
return getMovieDisplayTitle(vodContent);
|
||||
} else if (isEpisode) {
|
||||
return getEpisodeDisplayTitle(metadata);
|
||||
}
|
||||
return vodContent.content_name;
|
||||
};
|
||||
|
||||
// Get subtitle info
|
||||
const getSubtitle = () => {
|
||||
if (isMovie) {
|
||||
return getMovieSubtitle(metadata);
|
||||
} else if (isEpisode) {
|
||||
return getEpisodeSubtitle(metadata);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Render subtitle
|
||||
const renderSubtitle = () => {
|
||||
const subtitleParts = getSubtitle();
|
||||
if (subtitleParts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subtitleParts.join(' • ')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
// Calculate progress percentage and time
|
||||
const getProgressInfo = useCallback(() => {
|
||||
return calculateProgress(connection, metadata.duration_secs);
|
||||
}, [connection, metadata.duration_secs]);
|
||||
|
||||
// Calculate duration for connection
|
||||
const getConnectionDuration = useCallback((connection) => {
|
||||
return calculateConnectionDuration(connection);
|
||||
}, []);
|
||||
|
||||
// Get connection start time for tooltip
|
||||
const getConnectionStartTime = useCallback(
|
||||
(connection) => {
|
||||
return calculateConnectionStartTime(connection, dateFormat);
|
||||
},
|
||||
[dateFormat]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
color='#FFF'
|
||||
maw={700}
|
||||
w={'100%'}
|
||||
>
|
||||
<Stack pos='relative' >
|
||||
{/* Header with poster and basic info */}
|
||||
<Group justify="space-between">
|
||||
<Box h={100} display='flex'
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={posterUrl}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
alt="content poster"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
{connection && (
|
||||
<Tooltip
|
||||
label={`Connected at ${getConnectionStartTime(connection)}`}
|
||||
>
|
||||
<Center>
|
||||
<Timer pr={5} />
|
||||
{getConnectionDuration(connection)}
|
||||
</Center>
|
||||
</Tooltip>
|
||||
)}
|
||||
{connection && stopVODClient && (
|
||||
<Center>
|
||||
<Tooltip label="Stop VOD Connection">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() => stopVODClient(connection.client_id)}
|
||||
>
|
||||
<SquareX size="24" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Title and type */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<Group>
|
||||
<Text fw={500}>{getDisplayTitle()}</Text>
|
||||
</Group>
|
||||
|
||||
<Tooltip label="Content Type">
|
||||
<Group gap={5}>
|
||||
<Video size="18" />
|
||||
{isMovie ? 'Movie' : 'TV Episode'}
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* Display M3U profile information - matching channel card style */}
|
||||
{connection &&
|
||||
connection.m3u_profile &&
|
||||
(connection.m3u_profile.profile_name ||
|
||||
connection.m3u_profile.account_name) && (
|
||||
<Flex justify="flex-end" align="flex-start" mt={-8}>
|
||||
<Group gap={5} align="flex-start">
|
||||
<HardDriveUpload size="18" mt={2} />
|
||||
<Stack gap={0}>
|
||||
<Tooltip label="M3U Account">
|
||||
<Text size="xs" fw={500}>
|
||||
{connection.m3u_profile.account_name || 'Unknown Account'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Tooltip label="M3U Profile">
|
||||
<Text size="xs" c="dimmed">
|
||||
{connection.m3u_profile.profile_name || 'Default Profile'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Subtitle/episode info */}
|
||||
{getSubtitle().length > 0 && (
|
||||
<Flex justify="flex-start" align="center" mt={-12}>
|
||||
{renderSubtitle()}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Content information badges - streamlined to avoid duplication */}
|
||||
<Group gap="xs" mt={-4}>
|
||||
{metadata.year && (
|
||||
<Tooltip label="Release Year">
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{metadata.year}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{metadata.duration_secs && (
|
||||
<Tooltip label="Content Duration">
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{formatDuration(metadata.duration_secs)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{metadata.rating && (
|
||||
<Tooltip label="Critic Rating (out of 10)">
|
||||
<Badge size="sm" variant="light" color="yellow">
|
||||
{parseFloat(metadata.rating).toFixed(1)}/10
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Progress bar - show current position in content */}
|
||||
{connection &&
|
||||
metadata.duration_secs &&
|
||||
(() => {
|
||||
const { totalTime, currentTime, percentage} = getProgressInfo();
|
||||
return totalTime > 0 ? (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
Progress
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTime(currentTime)} /{' '}
|
||||
{formatTime(totalTime)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
color="blue"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{percentage.toFixed(1)}% watched
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Client information section - collapsible like channel cards */}
|
||||
{connection && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
{/* Client summary header - always visible */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
p={'8px 12px'}
|
||||
bdrs={6}
|
||||
bd={'1px solid rgba(255, 255, 255, 0.1)'}
|
||||
onClick={() => setIsClientExpanded(!isClientExpanded)}
|
||||
>
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={500} color="dimmed">
|
||||
Client:
|
||||
</Text>
|
||||
<Text size="sm" ff={'monospace'}>
|
||||
{connection.client_ip || 'Unknown IP'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={8}>
|
||||
<Text size="xs" color="dimmed">
|
||||
{isClientExpanded ? 'Hide Details' : 'Show Details'}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: isClientExpanded
|
||||
? 'rotate(0deg)'
|
||||
: 'rotate(180deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Expanded client details */}
|
||||
{isClientExpanded && (
|
||||
<ClientDetails
|
||||
connection={connection}
|
||||
connectionStartTime={getConnectionStartTime(connection)} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VodConnectionCard;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 (
|
||||
<Stack spacing={4}>
|
||||
<Text size="sm" color="dimmed">
|
||||
{vod.series.name}
|
||||
</Text>
|
||||
<Text weight={500}>
|
||||
{seasonEp} - {vod.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return <Text weight={500}>{vod.name}</Text>;
|
||||
};
|
||||
|
||||
const handleCardClick = async () => {
|
||||
// Just pass the basic vod info to the parent handler
|
||||
onClick(vod);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<Card.Section>
|
||||
<Box style={{ position: 'relative', height: 300 }}>
|
||||
{vod.logo?.url ? (
|
||||
<Image
|
||||
src={vod.logo.url}
|
||||
height={300}
|
||||
alt={vod.name}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
height: 300,
|
||||
backgroundColor: '#404040',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Play size={48} color="#666" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ActionIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(vod);
|
||||
}}
|
||||
>
|
||||
<Play size={16} color="white" />
|
||||
</ActionIcon>
|
||||
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
}}
|
||||
color={isEpisode ? 'blue' : 'green'}
|
||||
>
|
||||
{isEpisode ? 'Episode' : 'Movie'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing={8} mt="md">
|
||||
{getDisplayTitle()}
|
||||
|
||||
<Group spacing={16}>
|
||||
{vod.year && (
|
||||
<Group spacing={4}>
|
||||
<Calendar size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">
|
||||
{vod.year}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vod.duration && (
|
||||
<Group spacing={4}>
|
||||
<Clock size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">
|
||||
{formatDuration(vod.duration_secs)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vod.rating && (
|
||||
<Group spacing={4}>
|
||||
<Star size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">
|
||||
{vod.rating}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{vod.genre && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{vod.genre}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SeriesCard = ({ series, onClick }) => {
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
|
||||
onClick={() => onClick(series)}
|
||||
>
|
||||
<Card.Section>
|
||||
<Box style={{ position: 'relative', height: 300 }}>
|
||||
{series.logo?.url ? (
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
height={300}
|
||||
alt={series.name}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
height: 300,
|
||||
backgroundColor: '#404040',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Play size={48} color="#666" />
|
||||
</Box>
|
||||
)}
|
||||
{/* Add Series badge in the same position as Movie badge */}
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
}}
|
||||
color="purple"
|
||||
>
|
||||
Series
|
||||
</Badge>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing={8} mt="md">
|
||||
<Text weight={500}>{series.name}</Text>
|
||||
|
||||
<Group spacing={16}>
|
||||
{series.year && (
|
||||
<Group spacing={4}>
|
||||
<Calendar size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">
|
||||
{series.year}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{series.rating && (
|
||||
<Group spacing={4}>
|
||||
<Star size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">
|
||||
{series.rating}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{series.genre && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{series.genre}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
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={<Search size={16} />}
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ search: e.target.value })}
|
||||
style={{ minWidth: 200 }}
|
||||
miw={200}
|
||||
/>
|
||||
|
||||
<Select
|
||||
|
|
@ -405,7 +168,7 @@ const VODsPage = () => {
|
|||
value={filters.category}
|
||||
onChange={onCategoryChange}
|
||||
clearable
|
||||
style={{ minWidth: 150 }}
|
||||
miw={150}
|
||||
/>
|
||||
|
||||
<Select
|
||||
|
|
@ -416,7 +179,7 @@ const VODsPage = () => {
|
|||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
style={{ width: 110 }}
|
||||
w={110}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
|
|
@ -428,23 +191,25 @@ const VODsPage = () => {
|
|||
) : (
|
||||
<>
|
||||
<Grid gutter="md">
|
||||
{getDisplayData().map((item) => (
|
||||
<Grid.Col
|
||||
span={12 / columns}
|
||||
key={`${item.contentType}_${item.id}`}
|
||||
style={{
|
||||
minWidth: MIN_CARD_WIDTH,
|
||||
maxWidth: MAX_CARD_WIDTH,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{item.contentType === 'series' ? (
|
||||
<SeriesCard series={item} onClick={handleSeriesClick} />
|
||||
) : (
|
||||
<VODCard vod={item} onClick={handleVODCardClick} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
))}
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
{getDisplayData().map((item) => (
|
||||
<GridCol
|
||||
span={12 / columns}
|
||||
key={`${item.contentType}_${item.id}`}
|
||||
miw={MIN_CARD_WIDTH}
|
||||
maw={MAX_CARD_WIDTH}
|
||||
m={'0 auto'}
|
||||
>
|
||||
{item.contentType === 'series' ? (
|
||||
<SeriesCard series={item} onClick={handleSeriesClick} />
|
||||
) : (
|
||||
<VODCard vod={item} onClick={handleVODCardClick} />
|
||||
)}
|
||||
</GridCol>
|
||||
))}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Grid>
|
||||
|
||||
{/* Pagination */}
|
||||
|
|
@ -462,18 +227,26 @@ const VODsPage = () => {
|
|||
</Stack>
|
||||
|
||||
{/* Series Episodes Modal */}
|
||||
<SeriesModal
|
||||
series={selectedSeries}
|
||||
opened={seriesModalOpened}
|
||||
onClose={closeSeriesModal}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
<SeriesModal
|
||||
series={selectedSeries}
|
||||
opened={seriesModalOpened}
|
||||
onClose={closeSeriesModal}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* VOD Details Modal */}
|
||||
<VODModal
|
||||
vod={selectedVOD}
|
||||
opened={vodModalOpened}
|
||||
onClose={closeVODModal}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
<VODModal
|
||||
vod={selectedVOD}
|
||||
opened={vodModalOpened}
|
||||
onClose={closeVODModal}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
132
frontend/src/utils/cards/StreamConnectionCardUtils.js
Normal file
132
frontend/src/utils/cards/StreamConnectionCardUtils.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import API from '../../api.js';
|
||||
import {
|
||||
format,
|
||||
getNow,
|
||||
initializeTime,
|
||||
subtract,
|
||||
toFriendlyDuration,
|
||||
} from '../dateTimeUtils.js';
|
||||
|
||||
// Parse proxy settings to get buffering_speed
|
||||
export const getBufferingSpeedThreshold = (proxySetting) => {
|
||||
try {
|
||||
if (proxySetting?.value) {
|
||||
const proxySettings = JSON.parse(proxySetting.value);
|
||||
return parseFloat(proxySettings.buffering_speed) || 1.0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing proxy settings:', 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}]`,
|
||||
};
|
||||
});
|
||||
};
|
||||
13
frontend/src/utils/cards/VODCardUtils.js
Normal file
13
frontend/src/utils/cards/VODCardUtils.js
Normal file
|
|
@ -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')}`
|
||||
: '';
|
||||
};
|
||||
139
frontend/src/utils/cards/VodConnectionCardUtils.js
Normal file
139
frontend/src/utils/cards/VodConnectionCardUtils.js
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ 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');
|
||||
|
|
@ -27,6 +29,9 @@ export const isSame = (date1, date2, unit = 'day') =>
|
|||
|
||||
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);
|
||||
|
||||
|
|
@ -35,6 +40,10 @@ export const format = (dateTime, 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) => {
|
||||
|
|
|
|||
|
|
@ -2,3 +2,21 @@ export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
|
|||
|
||||
export const IPV6_CIDR_REGEX =
|
||||
/(?:(?:(?:[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])/;
|
||||
|
||||
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];
|
||||
}
|
||||
133
frontend/src/utils/pages/StatsUtils.js
Normal file
133
frontend/src/utils/pages/StatsUtils.js
Normal file
|
|
@ -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;
|
||||
};
|
||||
28
frontend/src/utils/pages/VODsUtils.js
Normal file
28
frontend/src/utils/pages/VODsUtils.js
Normal file
|
|
@ -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;
|
||||
}, {});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue