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 ? ( + {series.name} + ) : ( + + + + )} + {/* 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 ( + + + + + channel logo + + + + + +
+ + {toFriendlyDuration(uptime, 'seconds')} +
+
+
+
+ + stopChannel(channel.channel_id)} + > + + + +
+
+
+ + + + {channelName} + + + + + + + + + {/* 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 ? ( - {vod.name} - ) : ( - - - - )} - - { - 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 ? ( - {series.name} - ) : ( - - - - )} - {/* 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} /> { value: v, label: v, }))} - style={{ width: 110 }} + w={110} /> @@ -428,23 +191,25 @@ const VODsPage = () => { ) : ( <> - {getDisplayData().map((item) => ( - - {item.contentType === 'series' ? ( - - ) : ( - - )} - - ))} + + }> + {getDisplayData().map((item) => ( + + {item.contentType === 'series' ? ( + + ) : ( + + )} + + ))} + + {/* Pagination */} @@ -462,18 +227,26 @@ const VODsPage = () => { {/* Series Episodes Modal */} - + + }> + + + {/* VOD Details Modal */} - + + }> + + + ); }; 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; + }, {}); +};