mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/patchy8736/823
This commit is contained in:
commit
d6c1a2369b
23 changed files with 1962 additions and 1742 deletions
|
|
@ -9,17 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution.
|
||||
- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
|
||||
- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability
|
||||
- Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Channels table EPG column showing "Not Assigned" on initial load for users with large EPG datasets. Added `tvgsLoaded` flag to EPG store to track when EPG data has finished loading, ensuring the table waits for EPG data before displaying. EPG cells now show animated skeleton placeholders while loading instead of incorrectly showing "Not Assigned". (Fixes #810)
|
||||
- Fixed VOD profile connection count not being decremented when stream connection fails (timeout, 404, etc.), preventing profiles from reaching capacity limits and rejecting valid stream requests
|
||||
- Fixed React warning in Channel form by removing invalid `removeTrailingZeros` prop from NumberInput component
|
||||
- Release workflow Docker tagging: Fixed issue where `latest` and version tags (e.g., `0.16.0`) were creating separate manifests instead of pointing to the same image digest, which caused old `latest` tags to become orphaned/untagged after new releases. Now creates a single multi-arch manifest with both tags, maintaining proper tag relationships and download statistics visibility on GitHub.
|
||||
- Fixed onboarding message appearing in the Channels Table when filtered results are empty. The onboarding message now only displays when there are no channels created at all, not when channels exist but are filtered out by current filters.
|
||||
- Fixed `M3UMovieRelation.get_stream_url()` and `M3UEpisodeRelation.get_stream_url()` to use XC client's `_normalize_url()` method instead of simple `rstrip('/')`. This properly handles malformed M3U account URLs (e.g., containing `/player_api.php` or query parameters) before constructing VOD stream endpoints, matching behavior of live channel URL building. (Closes #722)
|
||||
- Fixed bulk_create and bulk_update errors during VOD content refresh by pre-checking object existence with optimized bulk queries (3 queries total instead of N per batch) before creating new objects. This ensures all movie/series objects have primary keys before relation operations, preventing "prohibited to prevent data loss due to unsaved related object" errors. (Fixes #813)
|
||||
- Fixed bulk_create and bulk_update errors during VOD content refresh by pre-checking object existence with optimized bulk queries (3 queries total instead of N per batch) before creating new objects. This ensures all movie/series objects have primary keys before relation operations, preventing "prohibited to prevent data loss due to unsaved related object" errors. Additionally fixed duplicate key constraint violations by treating TMDB/IMDB ID values of `0` or `'0'` as invalid (some providers use this to indicate "no ID"), converting them to NULL to prevent multiple items from incorrectly sharing the same ID. (Fixes #813)
|
||||
|
||||
## [0.16.0] - 2026-01-04
|
||||
|
||||
|
|
|
|||
|
|
@ -410,10 +410,10 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb')
|
||||
imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb')
|
||||
|
||||
# Clean empty string IDs
|
||||
if tmdb_id == '':
|
||||
# Clean empty string IDs and zero values (some providers use 0 to indicate no ID)
|
||||
if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0':
|
||||
tmdb_id = None
|
||||
if imdb_id == '':
|
||||
if imdb_id == '' or imdb_id == 0 or imdb_id == '0':
|
||||
imdb_id = None
|
||||
|
||||
# Create a unique key for this movie (priority: TMDB > IMDB > name+year)
|
||||
|
|
@ -743,10 +743,10 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
|
|||
tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id')
|
||||
imdb_id = series_data.get('imdb') or series_data.get('imdb_id')
|
||||
|
||||
# Clean empty string IDs
|
||||
if tmdb_id == '':
|
||||
# Clean empty string IDs and zero values (some providers use 0 to indicate no ID)
|
||||
if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0':
|
||||
tmdb_id = None
|
||||
if imdb_id == '':
|
||||
if imdb_id == '' or imdb_id == 0 or imdb_id == '0':
|
||||
imdb_id = None
|
||||
|
||||
# Create a unique key for this series (priority: TMDB > IMDB > name+year)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||
ENV VIRTUAL_ENV=/dispatcharrpy
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# --- Install Python 3.13 and system dependencies ---
|
||||
# --- Install Python 3.13 and build dependencies ---
|
||||
# Note: Hardware acceleration (VA-API, VDPAU, NVENC) already included in base ffmpeg image
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
ca-certificates software-properties-common gnupg2 curl wget \
|
||||
|
|
@ -13,18 +13,34 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
|
|||
&& apt-get install --no-install-recommends -y \
|
||||
python3.13 python3.13-dev python3.13-venv \
|
||||
python-is-python3 python3-pip \
|
||||
libpcre3 libpcre3-dev libpq-dev procps \
|
||||
build-essential gcc pciutils \
|
||||
libpcre3 libpcre3-dev libpq-dev procps pciutils \
|
||||
nginx streamlink comskip \
|
||||
vlc-bin vlc-plugin-base \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build
|
||||
|
||||
# --- Create Python virtual environment ---
|
||||
RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pip
|
||||
|
||||
# --- Install Python dependencies ---
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
||||
RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && \
|
||||
rm /tmp/requirements.txt
|
||||
|
||||
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
|
||||
RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir build && \
|
||||
cd /tmp && \
|
||||
$VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \
|
||||
tar -xzf numpy-*.tar.gz && \
|
||||
cd numpy-*/ && \
|
||||
$VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
|
||||
mv dist/*.whl /opt/ && \
|
||||
cd / && rm -rf /tmp/numpy-*
|
||||
|
||||
# --- Clean up build dependencies to reduce image size ---
|
||||
RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev ninja-build && \
|
||||
apt-get autoremove -y --purge && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Set up Redis 7.x ---
|
||||
RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ services:
|
|||
- REDIS_HOST=localhost
|
||||
- CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
- DISPATCHARR_LOG_LEVEL=info
|
||||
# Legacy CPU Support (Optional)
|
||||
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
|
||||
# that lack support for newer baseline CPU features
|
||||
#- USE_LEGACY_NUMPY=true
|
||||
# Process Priority Configuration (Optional)
|
||||
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
|
||||
# Negative values require cap_add: SYS_NICE (uncomment below)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ services:
|
|||
- REDIS_HOST=localhost
|
||||
- CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
- DISPATCHARR_LOG_LEVEL=trace
|
||||
# Legacy CPU Support (Optional)
|
||||
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
|
||||
# that lack support for newer baseline CPU features
|
||||
#- USE_LEGACY_NUMPY=true
|
||||
# Process Priority Configuration (Optional)
|
||||
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
|
||||
# Negative values require cap_add: SYS_NICE (uncomment below)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ services:
|
|||
- REDIS_HOST=localhost
|
||||
- CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
- DISPATCHARR_LOG_LEVEL=debug
|
||||
# Legacy CPU Support (Optional)
|
||||
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
|
||||
# that lack support for newer baseline CPU features
|
||||
#- USE_LEGACY_NUMPY=true
|
||||
# Process Priority Configuration (Optional)
|
||||
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
|
||||
# Negative values require cap_add: SYS_NICE (uncomment below)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ services:
|
|||
- REDIS_HOST=redis
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- DISPATCHARR_LOG_LEVEL=info
|
||||
# Legacy CPU Support (Optional)
|
||||
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
|
||||
# that lack support for newer baseline CPU features
|
||||
#- USE_LEGACY_NUMPY=true
|
||||
# Process Priority Configuration (Optional)
|
||||
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
|
||||
# Negative values require cap_add: SYS_NICE (uncomment below)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ echo_with_timestamp() {
|
|||
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# --- NumPy version switching for legacy hardware ---
|
||||
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
|
||||
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
|
||||
/dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl
|
||||
echo_with_timestamp "✅ Legacy NumPy installed"
|
||||
fi
|
||||
|
||||
# Set PostgreSQL environment variables
|
||||
export POSTGRES_DB=${POSTGRES_DB:-dispatcharr}
|
||||
export POSTGRES_USER=${POSTGRES_USER:-dispatch}
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -52,6 +52,7 @@ import {
|
|||
Select,
|
||||
NumberInput,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
} from '@mantine/core';
|
||||
import { getCoreRowModel, flexRender } from '@tanstack/react-table';
|
||||
import './table.css';
|
||||
|
|
@ -228,6 +229,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
// EPG data lookup
|
||||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
|
||||
const theme = useMantineTheme();
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
|
@ -431,9 +433,9 @@ const ChannelsTable = ({ onReady }) => {
|
|||
});
|
||||
setAllRowIds(ids);
|
||||
|
||||
// Signal ready after first successful data fetch
|
||||
// EPG data is already loaded in initData before this component mounts
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
// Signal ready after first successful data fetch AND EPG data is loaded
|
||||
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
|
||||
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
|
|
@ -445,6 +447,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
showDisabled,
|
||||
selectedProfileId,
|
||||
showOnlyStreamlessChannels,
|
||||
tvgsLoaded,
|
||||
]);
|
||||
|
||||
const stopPropagation = useCallback((e) => {
|
||||
|
|
@ -750,6 +753,19 @@ const ChannelsTable = ({ onReady }) => {
|
|||
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
|
||||
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
|
||||
|
||||
// Signal ready when EPG data finishes loading (if channels were already fetched)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasFetchedData.current &&
|
||||
!hasSignaledReady.current &&
|
||||
onReady &&
|
||||
tvgsLoaded
|
||||
) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
}, [tvgsLoaded, onReady]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -834,6 +850,10 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const tooltip = epgObj
|
||||
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
|
||||
: '';
|
||||
|
||||
// If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading
|
||||
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
|
|
@ -856,6 +876,12 @@ const ChannelsTable = ({ onReady }) => {
|
|||
</Tooltip>
|
||||
) : epgObj ? (
|
||||
<span>{epgObj.name}</span>
|
||||
) : isEpgDataPending ? (
|
||||
<Skeleton
|
||||
height={14}
|
||||
width={(columnSizing.epg || 200) * 0.7}
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: '#888' }}>Not Assigned</span>
|
||||
)}
|
||||
|
|
@ -935,7 +961,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
|
||||
// from the store, so we don't need to recreate columns when logos load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedProfileId, channelGroups, theme]
|
||||
[selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const useEPGsStore = create((set) => ({
|
|||
epgs: {},
|
||||
tvgs: [],
|
||||
tvgsById: {},
|
||||
tvgsLoaded: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refreshProgress: {},
|
||||
|
|
@ -36,11 +37,16 @@ const useEPGsStore = create((set) => ({
|
|||
acc[tvg.id] = tvg;
|
||||
return acc;
|
||||
}, {}),
|
||||
tvgsLoaded: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tvgs:', error);
|
||||
set({ error: 'Failed to load tvgs.', isLoading: false });
|
||||
set({
|
||||
error: 'Failed to load tvgs.',
|
||||
tvgsLoaded: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
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