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;
+ }, {});
+};