diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44663dc9..4a830604 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
diff --git a/frontend/src/components/cards/SeriesCard.jsx b/frontend/src/components/cards/SeriesCard.jsx
new file mode 100644
index 00000000..f010cb44
--- /dev/null
+++ b/frontend/src/components/cards/SeriesCard.jsx
@@ -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 (
+ onClick(series)}
+ >
+
+
+ {series.logo?.url ? (
+
+ ) : (
+
+
+
+ )}
+ {/* Add Series badge in the same position as Movie badge */}
+
+ Series
+
+
+
+
+
+ {series.name}
+
+
+ {series.year && (
+
+
+
+ {series.year}
+
+
+ )}
+ {series.rating && (
+
+
+
+ {series.rating}
+
+
+ )}
+
+
+ {series.genre && (
+
+ {series.genre}
+
+ )}
+
+
+ );
+};
+
+export default SeriesCard;
\ No newline at end of file
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
new file mode 100644
index 00000000..f15e2801
--- /dev/null
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -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 (
+
+
+ {header.column.columnDef.header}
+
+
+ );
+ }
+ };
+
+ const renderBodyCell = ({ cell, row }) => {
+ switch (cell.column.id) {
+ case 'actions':
+ return (
+
+
+
+
+ stopClient(
+ row.original.channel.uuid,
+ row.original.client_id
+ )
+ }
+ >
+
+
+
+
+
+ );
+ }
+ };
+
+ 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 }) => (
+
+ {cell.getValue()}
+
+ ),
+ },
+ // 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 (
+
+ {cell.getValue()}
+
+ );
+ },
+ },
+ {
+ 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 (
+
+
+
+ User Agent:
+
+ {row.original.user_agent || 'Unknown'}
+
+
+ );
+ },
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ {toFriendlyDuration(uptime, 'seconds')}
+
+
+
+
+
+ stopChannel(channel.channel_id)}
+ >
+
+
+
+
+
+
+
+
+
+ {channelName}
+
+
+
+
+
+ {streamProfileName}
+
+
+
+
+ {/* Display M3U profile information */}
+
+
+
+
+ {m3uProfileName}
+
+
+
+
+ {/* Add stream selection dropdown */}
+ {availableStreams.length > 0 && (
+
+
+
+ )}
+
+ {/* Add stream information badges */}
+
+ {channel.resolution && (
+
+
+ {channel.resolution}
+
+
+ )}
+ {channel.source_fps && (
+
+
+ {channel.source_fps} FPS
+
+
+ )}
+ {channel.video_codec && (
+
+
+ {channel.video_codec.toUpperCase()}
+
+
+ )}
+ {channel.audio_codec && (
+
+
+ {channel.audio_codec.toUpperCase()}
+
+
+ )}
+ {channel.audio_channels && (
+
+
+ {channel.audio_channels}
+
+
+ )}
+ {channel.stream_type && (
+
+
+ {channel.stream_type.toUpperCase()}
+
+
+ )}
+ {channel.ffmpeg_speed && (
+
+ =
+ getBufferingSpeedThreshold(settings['proxy-settings'])
+ ? 'green'
+ : 'red'
+ }
+ >
+ {parseFloat(channel.ffmpeg_speed).toFixed(2)}x
+
+
+ )}
+
+
+
+
+
+
+
+ {formatSpeed(bitrates.at(-1) || 0)}
+
+
+
+
+
+
+ Avg: {avgBitrate}
+
+
+
+
+
+
+
+ {formatBytes(totalBytes)}
+
+
+
+
+
+
+
+
+ {clientCount}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default StreamConnectionCard;
\ No newline at end of file
diff --git a/frontend/src/components/cards/VODCard.jsx b/frontend/src/components/cards/VODCard.jsx
new file mode 100644
index 00000000..42468dae
--- /dev/null
+++ b/frontend/src/components/cards/VODCard.jsx
@@ -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 (
+
+
+ {vod.series.name}
+
+
+ {getSeasonLabel(vod)} - {vod.name}
+
+
+ );
+ }
+ return {vod.name};
+ };
+
+ const handleCardClick = async () => {
+ // Just pass the basic vod info to the parent handler
+ onClick(vod);
+ };
+
+ return (
+
+
+
+ {vod.logo?.url ? (
+
+ ) : (
+
+
+
+ )}
+
+ {
+ e.stopPropagation();
+ onClick(vod);
+ }}
+ >
+
+
+
+
+ {isEpisode ? 'Episode' : 'Movie'}
+
+
+
+
+
+ {getDisplayTitle()}
+
+
+ {vod.year && (
+
+
+
+ {vod.year}
+
+
+ )}
+
+ {vod.duration && (
+
+
+
+ {formatDuration(vod.duration_secs)}
+
+
+ )}
+
+ {vod.rating && (
+
+
+
+ {vod.rating}
+
+
+ )}
+
+
+ {vod.genre && (
+
+ {vod.genre}
+
+ )}
+
+
+ );
+};
+
+export default VODCard;
\ No newline at end of file
diff --git a/frontend/src/components/cards/VodConnectionCard.jsx b/frontend/src/components/cards/VodConnectionCard.jsx
new file mode 100644
index 00000000..57564dce
--- /dev/null
+++ b/frontend/src/components/cards/VodConnectionCard.jsx
@@ -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 (
+
+ {connection.user_agent &&
+ connection.user_agent !== 'Unknown' && (
+
+
+ User Agent:
+
+
+ {connection.user_agent.length > 100
+ ? `${connection.user_agent.substring(0, 100)}...`
+ : connection.user_agent}
+
+
+ )}
+
+
+
+ Client ID:
+
+
+ {connection.client_id || 'Unknown'}
+
+
+
+ {connection.connected_at && (
+
+
+ Connected:
+
+ {connectionStartTime}
+
+ )}
+
+ {connection.duration && connection.duration > 0 && (
+
+
+ Watch Duration:
+
+
+ {toFriendlyDuration(connection.duration, 'seconds')}
+
+
+ )}
+
+ {/* Seek/Position Information */}
+ {(connection.last_seek_percentage > 0 ||
+ connection.last_seek_byte > 0) && (
+ <>
+
+
+ Last Seek:
+
+
+ {connection.last_seek_percentage?.toFixed(1)}%
+ {connection.total_content_size > 0 && (
+
+ {' '}
+ ({Math.round(connection.last_seek_byte / (1024 * 1024))}
+ MB /{' '}
+ {Math.round(
+ connection.total_content_size / (1024 * 1024)
+ )}
+ MB)
+
+ )}
+
+
+
+ {Number(connection.last_seek_timestamp) > 0 && (
+
+
+ Seek Time:
+
+
+ {fromNow(convertToSec(Number(connection.last_seek_timestamp)))}
+
+
+ )}
+ >
+ )}
+
+ {connection.bytes_sent > 0 && (
+
+
+ Data Sent:
+
+
+ {(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
+
+
+ )}
+
+ );
+}
+
+// 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 (
+
+ {subtitleParts.join(' • ')}
+
+ );
+ };
+
+ // 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 (
+
+
+ {/* Header with poster and basic info */}
+
+
+
+
+
+
+ {connection && (
+
+
+
+ {getConnectionDuration(connection)}
+
+
+ )}
+ {connection && stopVODClient && (
+
+
+ stopVODClient(connection.client_id)}
+ >
+
+
+
+
+ )}
+
+
+
+ {/* Title and type */}
+
+
+ {getDisplayTitle()}
+
+
+
+
+
+ {isMovie ? 'Movie' : 'TV Episode'}
+
+
+
+
+ {/* Display M3U profile information - matching channel card style */}
+ {connection &&
+ connection.m3u_profile &&
+ (connection.m3u_profile.profile_name ||
+ connection.m3u_profile.account_name) && (
+
+
+
+
+
+
+ {connection.m3u_profile.account_name || 'Unknown Account'}
+
+
+
+
+ {connection.m3u_profile.profile_name || 'Default Profile'}
+
+
+
+
+
+ )}
+
+ {/* Subtitle/episode info */}
+ {getSubtitle().length > 0 && (
+
+ {renderSubtitle()}
+
+ )}
+
+ {/* Content information badges - streamlined to avoid duplication */}
+
+ {metadata.year && (
+
+
+ {metadata.year}
+
+
+ )}
+
+ {metadata.duration_secs && (
+
+
+ {formatDuration(metadata.duration_secs)}
+
+
+ )}
+
+ {metadata.rating && (
+
+
+ {parseFloat(metadata.rating).toFixed(1)}/10
+
+
+ )}
+
+
+ {/* Progress bar - show current position in content */}
+ {connection &&
+ metadata.duration_secs &&
+ (() => {
+ const { totalTime, currentTime, percentage} = getProgressInfo();
+ return totalTime > 0 ? (
+
+
+
+ Progress
+
+
+ {formatTime(currentTime)} /{' '}
+ {formatTime(totalTime)}
+
+
+
+
+ {percentage.toFixed(1)}% watched
+
+
+ ) : null;
+ })()}
+
+ {/* Client information section - collapsible like channel cards */}
+ {connection && (
+
+ {/* Client summary header - always visible */}
+ setIsClientExpanded(!isClientExpanded)}
+ >
+
+
+ Client:
+
+
+ {connection.client_ip || 'Unknown IP'}
+
+
+
+
+
+ {isClientExpanded ? 'Hide Details' : 'Show Details'}
+
+
+
+
+
+ {/* Expanded client details */}
+ {isClientExpanded && (
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default VodConnectionCard;
\ No newline at end of file
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index 8ec576a8..19702ae6 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -1,1293 +1,83 @@
-import React, { useMemo, useState, useEffect, useCallback } from 'react';
-import {
- ActionIcon,
- Box,
- Button,
- Card,
- Center,
- Container,
- Flex,
- Group,
- Pagination,
- Progress,
- SimpleGrid,
- Stack,
- Text,
- Title,
- Tooltip,
- Select,
- Badge,
- NumberInput,
-} from '@mantine/core';
-import { TableHelper } from '../helpers';
-import API from '../api';
+import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { Box, Button, Group, LoadingOverlay, NumberInput, Text, Title, } from '@mantine/core';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
-import logo from '../images/logo.png';
-import {
- ChevronDown,
- CirclePlay,
- Gauge,
- HardDriveDownload,
- HardDriveUpload,
- RefreshCw,
- SquareX,
- Timer,
- Users,
- Video,
-} from 'lucide-react';
-import dayjs from 'dayjs';
-import duration from 'dayjs/plugin/duration';
-import relativeTime from 'dayjs/plugin/relativeTime';
-import { Sparkline } from '@mantine/charts';
import useStreamProfilesStore from '../store/streamProfiles';
-import usePlaylistsStore from '../store/playlists'; // Add this import
-import useSettingsStore from '../store/settings';
-import { useLocation } from 'react-router-dom';
-import { notifications } from '@mantine/notifications';
-import { CustomTable, useTable } from '../components/tables/CustomTable';
import useLocalStorage from '../hooks/useLocalStorage';
import SystemEvents from '../components/SystemEvents';
-
-dayjs.extend(duration);
-dayjs.extend(relativeTime);
-
-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];
-}
-
-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];
-}
-
-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
- });
-};
-
-// Create a VOD Card component similar to ChannelCard
-const VODCard = ({ 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;
-
- // Format duration for content length
- 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`;
- };
-
- // Get display title
- const getDisplayTitle = () => {
- if (isMovie) {
- return vodContent.content_name;
- } else if (isEpisode) {
- 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}`;
- }
- return vodContent.content_name;
- };
-
- // Get subtitle info
- const getSubtitle = () => {
- if (isMovie) {
- const parts = [];
- if (metadata.genre) parts.push(metadata.genre);
- // We'll handle rating separately as a badge now
- return parts;
- } else if (isEpisode) {
- return [metadata.episode_name || 'Episode'];
- }
- return [];
- };
-
- // Render subtitle
- const renderSubtitle = () => {
- const subtitleParts = getSubtitle();
- if (subtitleParts.length === 0) return null;
-
- return (
-
- {subtitleParts.join(' • ')}
-
- );
- };
-
- // Calculate progress percentage and time
- const calculateProgress = useCallback(() => {
- if (!connection || !metadata.duration_secs) {
- return {
- percentage: 0,
- currentTime: 0,
- totalTime: metadata.duration_secs || 0,
- };
- }
-
- const totalSeconds = metadata.duration_secs;
- let percentage = 0;
- let currentTime = 0;
- const now = Date.now() / 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,
- };
- }, [connection, metadata.duration_secs]);
-
- // Format time for display (e.g., "1:23:45" or "23:45")
- 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')}`;
- }
- };
-
- // Calculate duration for connection
- const calculateConnectionDuration = useCallback((connection) => {
- // If duration is provided by API, use it
- if (connection.duration && connection.duration > 0) {
- return dayjs.duration(connection.duration, 'seconds').humanize();
- }
-
- // 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 = Date.now() / 1000;
- const duration = currentTime - clientStartTime;
- return dayjs.duration(duration, 'seconds').humanize();
- }
- } catch {
- // Ignore parsing errors
- }
- }
-
- return 'Unknown duration';
- }, []);
-
- // Get connection start time for tooltip
- const getConnectionStartTime = useCallback(
- (connection) => {
- if (connection.connected_at) {
- return dayjs(connection.connected_at * 1000).format(
- `${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 dayjs(clientStartTime).format(`${dateFormat} HH:mm:ss`);
- }
- } catch {
- // Ignore parsing errors
- }
- }
-
- return 'Unknown';
- },
- [dateFormat]
- );
-
- return (
-
-
- {/* Header with poster and basic info */}
-
-
-
-
-
-
- {connection && (
-
-
-
- {calculateConnectionDuration(connection)}
-
-
- )}
- {connection && stopVODClient && (
-
-
- stopVODClient(connection.client_id)}
- >
-
-
-
-
- )}
-
-
-
- {/* Title and type */}
-
-
- {getDisplayTitle()}
-
-
-
-
-
- {isMovie ? 'Movie' : 'TV Episode'}
-
-
-
-
- {/* Display M3U profile information - matching channel card style */}
- {connection &&
- connection.m3u_profile &&
- (connection.m3u_profile.profile_name ||
- connection.m3u_profile.account_name) && (
-
-
-
-
-
-
- {connection.m3u_profile.account_name || 'Unknown Account'}
-
-
-
-
- {connection.m3u_profile.profile_name || 'Default Profile'}
-
-
-
-
-
- )}
-
- {/* Subtitle/episode info */}
- {getSubtitle().length > 0 && (
-
- {renderSubtitle()}
-
- )}
-
- {/* Content information badges - streamlined to avoid duplication */}
-
- {metadata.year && (
-
-
- {metadata.year}
-
-
- )}
-
- {metadata.duration_secs && (
-
-
- {formatDuration(metadata.duration_secs)}
-
-
- )}
-
- {metadata.rating && (
-
-
- {parseFloat(metadata.rating).toFixed(1)}/10
-
-
- )}
-
-
- {/* Progress bar - show current position in content */}
- {connection &&
- metadata.duration_secs &&
- (() => {
- const progress = calculateProgress();
- return progress.totalTime > 0 ? (
-
-
-
- Progress
-
-
- {formatTime(progress.currentTime)} /{' '}
- {formatTime(progress.totalTime)}
-
-
-
-
- {progress.percentage.toFixed(1)}% watched
-
-
- ) : null;
- })()}
-
- {/* Client information section - collapsible like channel cards */}
- {connection && (
-
- {/* Client summary header - always visible */}
- setIsClientExpanded(!isClientExpanded)}
- >
-
-
- Client:
-
-
- {connection.client_ip || 'Unknown IP'}
-
-
-
-
-
- {isClientExpanded ? 'Hide Details' : 'Show Details'}
-
-
-
-
-
- {/* Expanded client details */}
- {isClientExpanded && (
-
- {connection.user_agent &&
- connection.user_agent !== 'Unknown' && (
-
-
- User Agent:
-
-
- {connection.user_agent.length > 100
- ? `${connection.user_agent.substring(0, 100)}...`
- : connection.user_agent}
-
-
- )}
-
-
-
- Client ID:
-
-
- {connection.client_id || 'Unknown'}
-
-
-
- {connection.connected_at && (
-
-
- Connected:
-
- {getConnectionStartTime(connection)}
-
- )}
-
- {connection.duration && connection.duration > 0 && (
-
-
- Watch Duration:
-
-
- {dayjs
- .duration(connection.duration, 'seconds')
- .humanize()}
-
-
- )}
-
- {/* Seek/Position Information */}
- {(connection.last_seek_percentage > 0 ||
- connection.last_seek_byte > 0) && (
- <>
-
-
- Last Seek:
-
-
- {connection.last_seek_percentage?.toFixed(1)}%
- {connection.total_content_size > 0 && (
-
- {' '}
- (
- {Math.round(
- connection.last_seek_byte / (1024 * 1024)
- )}
- MB /{' '}
- {Math.round(
- connection.total_content_size / (1024 * 1024)
- )}
- MB)
-
- )}
-
-
-
- {Number(connection.last_seek_timestamp) > 0 && (
-
-
- Seek Time:
-
-
- {dayjs
- .unix(Number(connection.last_seek_timestamp))
- .fromNow()}
-
-
- )}
- >
- )}
-
- {connection.bytes_sent > 0 && (
-
-
- Data Sent:
-
-
- {(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
-
-
- )}
-
- )}
-
- )}
-
-
- );
-};
-
-// Create a separate component for each channel card to properly handle the hook
-const ChannelCard = ({
- channel,
- clients,
- stopClient,
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ getStatsByChannelId,
+ getVODStats,
stopChannel,
- logos,
+ stopClient,
+ stopVODClient,
+} from '../utils/pages/StatsUtils.js';
+const VodConnectionCard = React.lazy(() =>
+ import('../components/cards/VodConnectionCard.jsx'));
+const StreamConnectionCard = React.lazy(() =>
+ import('../components/cards/StreamConnectionCard.jsx'));
+
+const Connections = ({
+ combinedConnections,
+ clients,
channelsByUUID,
+ handleStopVODClient,
}) => {
- 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);
+ const logos = useLogosStore((s) => s.logos);
- // Get Date-format from localStorage
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
- // Get M3U account data from the playlists store
- const m3uAccounts = usePlaylistsStore((s) => s.playlists);
- const [tableSize] = useLocalStorage('table-size', 'default');
- // Get settings for speed threshold
- const settings = useSettingsStore((s) => s.settings);
-
- // Parse proxy settings to get buffering_speed
- const getBufferingSpeedThreshold = () => {
- try {
- if (settings['proxy-settings']?.value) {
- const proxySettings = JSON.parse(settings['proxy-settings'].value);
- return parseFloat(proxySettings.buffering_speed) || 1.0;
- }
- } catch (error) {
- console.error('Error parsing proxy settings:', error);
- }
- return 1.0; // Default fallback
- };
-
- // Create a map of M3U account IDs to names for quick lookup
- const m3uAccountsMap = useMemo(() => {
- const map = {};
- if (m3uAccounts && Array.isArray(m3uAccounts)) {
- m3uAccounts.forEach((account) => {
- if (account.id) {
- map[account.id] = account.name;
- }
- });
- }
- return map;
- }, [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 API.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 = streamData.find(
- (stream) =>
- channel.url.includes(stream.url) ||
- stream.url.includes(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 (
-
-
- {header.column.columnDef.header}
-
-
- );
- }
- };
-
- const renderBodyCell = ({ cell, row }) => {
- switch (cell.column.id) {
- case 'actions':
- return (
-
-
-
-
- stopClient(
- row.original.channel.uuid,
- row.original.client_id
- )
- }
- >
-
-
-
-
-
- );
- }
- };
-
- // 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 = availableStreams.find(
- (s) => s.id.toString() === streamId
- );
- console.log('Selected stream details:', selectedStream);
-
- // Make sure we're passing the correct ID to the API
- const response = await API.switchStream(channel.channel_id, 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 && 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
- notifications.show({
- 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(async () => {
- try {
- const channelId = channelsByUUID[channel.channel_id];
- if (channelId) {
- const updatedStreamData = await API.getChannelStreams(channelId);
- console.log('Channel streams after switch:', updatedStreamData);
-
- // Update current stream information with fresh data
- const updatedStream = updatedStreamData.find(
- (s) => s.id.toString() === streamId
- );
- if (updatedStream && updatedStream.m3u_profile) {
- setCurrentM3UProfile(updatedStream.m3u_profile);
- }
- }
- } catch (error) {
- console.error('Error checking streams after switch:', error);
- }
- }, 2000);
- } catch (error) {
- console.error('Stream switch error:', error);
- notifications.show({
- title: 'Error switching stream',
- message: error.toString(),
- color: 'red.5',
- });
- }
- };
- console.log(data);
-
- const clientsColumns = useMemo(
- () => [
- {
- id: 'expand',
- size: 20,
- },
- {
- header: 'IP Address',
- accessorKey: 'ip_address',
- },
- // Updated Connected column with tooltip
- {
- id: 'connected',
- header: 'Connected',
- accessorFn: (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 currentTime = dayjs();
- const connectedTime = currentTime.subtract(
- row.connected_since,
- 'second'
- );
- return connectedTime.format(`${dateFormat} HH:mm:ss`);
- }
-
- // Fallback to connected_at if it exists
- if (row.connected_at) {
- const connectedTime = dayjs(row.connected_at * 1000);
- return connectedTime.format(`${dateFormat} HH:mm:ss`);
- }
-
- return 'Unknown';
- },
- cell: ({ cell }) => (
-
- {cell.getValue()}
-
- ),
- },
- // Update Duration column with tooltip showing exact seconds
- {
- id: 'duration',
- header: 'Duration',
- accessorFn: (row) => {
- if (row.connected_since) {
- return dayjs.duration(row.connected_since, 'seconds').humanize();
- }
-
- if (row.connection_duration) {
- return dayjs
- .duration(row.connection_duration, 'seconds')
- .humanize();
- }
-
- return '-';
- },
- cell: ({ cell, row }) => {
- const exactDuration =
- row.original.connected_since || row.original.connection_duration;
- return (
-
- {cell.getValue()}
-
- );
- },
- },
- {
- id: 'actions',
- header: 'Actions',
- size: tableSize == 'compact' ? 75 : 100,
- },
- ],
- []
- );
-
- // This hook is now at the top level of this component
- 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 (
-
-
-
- User Agent:
-
- {row.original.user_agent || 'Unknown'}
-
-
- );
- },
- 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 =
- (channel.logo_id && logos && logos[channel.logo_id]
- ? logos[channel.logo_id].cache_url
- : null) ||
- (previewedStream && previewedStream.logo_url) ||
- null;
-
- useEffect(() => {
- let isMounted = true;
- // Only fetch if we have a stream_id and NO channel.name
- if (!channel.name && channel.stream_id) {
- API.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 = 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}]`,
- };
- });
-
- 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 (
-
-
-
-
-
-
-
-
-
-
-
-
- {dayjs.duration(uptime, 'seconds').humanize()}
-
-
-
-
-
- stopChannel(channel.channel_id)}
- >
-
-
-
-
-
-
-
-
-
- {channelName}
-
-
-
-
-
- {streamProfileName}
-
-
-
-
- {/* Display M3U profile information */}
-
-
-
-
- {m3uProfileName}
-
-
-
-
- {/* Add stream selection dropdown */}
- {availableStreams.length > 0 && (
-
-
-
- )}
-
- {/* Add stream information badges */}
-
- {channel.resolution && (
-
-
- {channel.resolution}
-
-
- )}
- {channel.source_fps && (
-
-
- {channel.source_fps} FPS
-
-
- )}
- {channel.video_codec && (
-
-
- {channel.video_codec.toUpperCase()}
-
-
- )}
- {channel.audio_codec && (
-
-
- {channel.audio_codec.toUpperCase()}
-
-
- )}
- {channel.audio_channels && (
-
-
- {channel.audio_channels}
-
-
- )}
- {channel.stream_type && (
-
-
- {channel.stream_type.toUpperCase()}
-
-
- )}
- {channel.ffmpeg_speed && (
-
- =
- getBufferingSpeedThreshold()
- ? 'green'
- : 'red'
- }
- >
- {parseFloat(channel.ffmpeg_speed).toFixed(2)}x
-
-
- )}
-
-
-
-
-
-
-
- {formatSpeed(bitrates.at(-1) || 0)}
-
-
-
-
-
-
- Avg: {avgBitrate}
-
-
-
-
-
-
-
- {formatBytes(totalBytes)}
-
-
-
-
-
-
-
-
- {clientCount}
-
-
-
-
-
-
-
-
+
+ No active connections
+
+
+ ) : (
+
+ }>
+ {combinedConnections.map((connection) => {
+ if (connection.type === 'stream') {
+ return (
+
+ );
+ } else if (connection.type === 'vod') {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
);
};
-const ChannelsPage = () => {
+const StatsPage = () => {
const channels = useChannelsStore((s) => s.channels);
const channelsByUUID = useChannelsStore((s) => s.channelsByUUID);
const channelStats = useChannelsStore((s) => s.stats);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
- const logos = useLogosStore((s) => s.logos);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const [clients, setClients] = useState([]);
@@ -1301,17 +91,14 @@ const ChannelsPage = () => {
5
);
const refreshInterval = refreshIntervalSeconds * 1000; // Convert to milliseconds
+ const channelHistoryLength = Object.keys(channelHistory).length;
+ const vodConnectionsCount = vodConnections.reduce(
+ (total, vodContent) => total + (vodContent.connections?.length || 0),
+ 0
+ );
- const stopChannel = async (id) => {
- await API.stopChannel(id);
- };
-
- const stopClient = async (channelId, clientId) => {
- await API.stopClient(channelId, clientId);
- };
-
- const stopVODClient = async (clientId) => {
- await API.stopVODClient(clientId);
+ const handleStopVODClient = async (clientId) => {
+ await stopVODClient(clientId);
// Refresh VOD stats after stopping to update the UI
fetchVODStats();
};
@@ -1319,7 +106,7 @@ const ChannelsPage = () => {
// Function to fetch channel stats from API
const fetchChannelStats = useCallback(async () => {
try {
- const response = await API.fetchActiveChannelStats();
+ const response = await fetchActiveChannelStats();
if (response) {
setChannelStats(response);
} else {
@@ -1337,7 +124,7 @@ const ChannelsPage = () => {
const fetchVODStats = useCallback(async () => {
try {
- const response = await API.getVODStats();
+ const response = await getVODStats();
if (response) {
setVodConnections(response.vod_connections || []);
} else {
@@ -1404,130 +191,36 @@ const ChannelsPage = () => {
// Use functional update to access previous state without dependency
setChannelHistory((prevChannelHistory) => {
// Create a completely new object based only on current channel stats
- const stats = {};
- const newChannelHistory = {}; // Start fresh instead of preserving old channels
-
- 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;
- }
-
- 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)
- );
-
- const channelWithMetadata = {
- ...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,
- };
-
- stats[ch.channel_id] = channelWithMetadata;
- newChannelHistory[ch.channel_id] = channelWithMetadata; // Only add currently active channels
- });
+ const stats = getStatsByChannelId(channelStats, prevChannelHistory, channelsByUUID, channels, streamProfiles);
console.log('Processed active channels:', stats);
// Update clients based on new stats
- const clientStats = 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;
- }, []);
- setClients(clientStats);
+ setClients(getClientStats(stats));
- return newChannelHistory; // Return only currently active channels
+ return stats; // Return only currently active channels
});
}, [channelStats, channels, channelsByUUID, streamProfiles]);
// Combine active streams and VOD connections into a single mixed list
const combinedConnections = useMemo(() => {
- 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
- );
+ return getCombinedConnections(channelHistory, vodConnections);
}, [channelHistory, vodConnections]);
return (
<>
-
-
+
+
Active Connections
- {Object.keys(channelHistory).length} stream
- {Object.keys(channelHistory).length !== 1 ? 's' : ''} •{' '}
- {vodConnections.reduce(
- (total, vodContent) =>
- total + (vodContent.connections?.length || 0),
- 0
- )}{' '}
- VOD connection
- {vodConnections.reduce(
- (total, vodContent) =>
- total + (vodContent.connections?.length || 0),
- 0
- ) !== 1
- ? 's'
- : ''}
+ {channelHistoryLength} {
+ channelHistoryLength !== 1 ? 'streams' : 'stream'
+ } • {vodConnectionsCount} {
+ vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection'
+ }
Refresh Interval (seconds):
@@ -1538,7 +231,7 @@ const ChannelsPage = () => {
max={300}
step={1}
size="xs"
- style={{ width: 120 }}
+ w={120}
/>
{refreshIntervalSeconds === 0 && (
@@ -1567,53 +260,21 @@ const ChannelsPage = () => {
- {combinedConnections.length === 0 ? (
-
-
- No active connections
-
-
- ) : (
- combinedConnections.map((connection) => {
- if (connection.type === 'stream') {
- return (
-
- );
- } else if (connection.type === 'vod') {
- return (
-
- );
- }
- return null;
- })
- )}
+
@@ -1621,14 +282,14 @@ const ChannelsPage = () => {
{/* System Events Section - Fixed at bottom */}
@@ -1638,4 +299,4 @@ const ChannelsPage = () => {
);
};
-export default ChannelsPage;
+export default StatsPage;
diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx
index 3c9e2b0f..460b7211 100644
--- a/frontend/src/pages/VODs.jsx
+++ b/frontend/src/pages/VODs.jsx
@@ -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 (
-
-
- {vod.series.name}
-
-
- {seasonEp} - {vod.name}
-
-
- );
- }
- return {vod.name};
- };
-
- const handleCardClick = async () => {
- // Just pass the basic vod info to the parent handler
- onClick(vod);
- };
-
- return (
-
-
-
- {vod.logo?.url ? (
-
- ) : (
-
-
-
- )}
-
- {
- e.stopPropagation();
- onClick(vod);
- }}
- >
-
-
-
-
- {isEpisode ? 'Episode' : 'Movie'}
-
-
-
-
-
- {getDisplayTitle()}
-
-
- {vod.year && (
-
-
-
- {vod.year}
-
-
- )}
-
- {vod.duration && (
-
-
-
- {formatDuration(vod.duration_secs)}
-
-
- )}
-
- {vod.rating && (
-
-
-
- {vod.rating}
-
-
- )}
-
-
- {vod.genre && (
-
- {vod.genre}
-
- )}
-
-
- );
-};
-
-const SeriesCard = ({ series, onClick }) => {
- return (
- onClick(series)}
- >
-
-
- {series.logo?.url ? (
-
- ) : (
-
-
-
- )}
- {/* Add Series badge in the same position as Movie badge */}
-
- Series
-
-
-
-
-
- {series.name}
-
-
- {series.year && (
-
-
-
- {series.year}
-
-
- )}
- {series.rating && (
-
-
-
- {series.rating}
-
-
- )}
-
-
- {series.genre && (
-
- {series.genre}
-
- )}
-
-
- );
-};
+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={}
value={filters.search}
onChange={(e) => setFilters({ search: e.target.value })}
- style={{ minWidth: 200 }}
+ miw={200}
/>
);
};
diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js
new file mode 100644
index 00000000..c27b7c06
--- /dev/null
+++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js
@@ -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}]`,
+ };
+ });
+};
diff --git a/frontend/src/utils/cards/VODCardUtils.js b/frontend/src/utils/cards/VODCardUtils.js
new file mode 100644
index 00000000..3ec456e7
--- /dev/null
+++ b/frontend/src/utils/cards/VODCardUtils.js
@@ -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')}`
+ : '';
+};
diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js
new file mode 100644
index 00000000..3bf635b6
--- /dev/null
+++ b/frontend/src/utils/cards/VodConnectionCardUtils.js
@@ -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';
+}
\ No newline at end of file
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
index 64a50947..3b48f92f 100644
--- a/frontend/src/utils/dateTimeUtils.js
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -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) => {
diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js
index 8562face..d8131229 100644
--- a/frontend/src/utils/networkUtils.js
+++ b/frontend/src/utils/networkUtils.js
@@ -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];
+}
\ No newline at end of file
diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js
new file mode 100644
index 00000000..a25e33f0
--- /dev/null
+++ b/frontend/src/utils/pages/StatsUtils.js
@@ -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;
+};
diff --git a/frontend/src/utils/pages/VODsUtils.js b/frontend/src/utils/pages/VODsUtils.js
new file mode 100644
index 00000000..2e9455ea
--- /dev/null
+++ b/frontend/src/utils/pages/VODsUtils.js
@@ -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;
+ }, {});
+};