From 0e1db3e39cfd24e98343c17717cc74263b08c85d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 20 Jan 2026 13:37:40 -0600 Subject: [PATCH] Enhancement: - Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: - Currently playing program title displayed with live broadcast indicator (green Radio icon) - Expandable program descriptions via chevron button - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels - Smart scheduling that fetches new program data 5 seconds after current program ends - Only polls when active channel list changes, not on stats refresh - Channel preview button: Added preview functionality to active stream cards on stats page --- CHANGELOG.md | 7 + apps/epg/api_urls.py | 3 +- apps/epg/api_views.py | 86 ++++++++++++ frontend/src/api.js | 17 +++ .../components/cards/StreamConnectionCard.jsx | 127 +++++++++++++++--- frontend/src/pages/Stats.jsx | 127 ++++++++++++++++-- frontend/src/utils/pages/StatsUtils.js | 35 +++++ 7 files changed, 368 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fc4bb2..c371dd93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: + - Currently playing program title displayed with live broadcast indicator (green Radio icon) + - Expandable program descriptions via chevron button + - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels + - Smart scheduling that fetches new program data 5 seconds after current program ends + - Only polls when active channel list changes, not on stats refresh +- Channel preview button: Added preview functionality to active stream cards on stats page - Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) - Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom) - DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery. diff --git a/apps/epg/api_urls.py b/apps/epg/api_urls.py index 2818e66b..ed4b3105 100644 --- a/apps/epg/api_urls.py +++ b/apps/epg/api_urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet +from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView app_name = 'epg' @@ -12,6 +12,7 @@ router.register(r'epgdata', EPGDataViewSet, basename='epgdata') urlpatterns = [ path('grid/', EPGGridAPIView.as_view(), name='epg_grid'), path('import/', EPGImportAPIView.as_view(), name='epg_import'), + path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'), ] urlpatterns += router.urls diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 2fc5a743..319368d0 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -417,3 +417,89 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet): except KeyError: return [Authenticated()] + +# ───────────────────────────── +# 6) Current Programs API +# ───────────────────────────── +class CurrentProgramsAPIView(APIView): + """ + Lightweight endpoint that returns currently playing programs for specified channel IDs. + Accepts POST with JSON body containing channel_ids array, or null/empty to fetch all channels. + """ + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @swagger_auto_schema( + operation_description="Get currently playing programs for specified channels or all channels", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'channel_ids': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_INTEGER), + description="Array of channel IDs. If null or omitted, returns all channels with current programs.", + nullable=True, + ) + }, + ), + responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))}, + ) + def post(self, request, format=None): + # Get channel IDs from request body + channel_ids = request.data.get('channel_ids', None) + + # Import Channel model + from apps.channels.models import Channel + + # Build query for channels with EPG data + query = Channel.objects.filter(epg_data__isnull=False) + + # Filter by specific channel IDs if provided + if channel_ids is not None: + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be an array of integers or null"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + channel_ids = [int(id) for id in channel_ids] + except (ValueError, TypeError): + return Response( + {"error": "channel_ids must contain valid integers"}, + status=status.HTTP_400_BAD_REQUEST + ) + + query = query.filter(id__in=channel_ids) + + # Get channels with EPG data + channels = query.select_related('epg_data') + + # Get current time + now = timezone.now() + + # Build list of current programs + current_programs = [] + + for channel in channels: + # Query for current program + program = ProgramData.objects.filter( + epg=channel.epg_data, + start_time__lte=now, + end_time__gt=now + ).first() + + if program: + # Serialize program and add channel_id for easy mapping + program_data = ProgramDataSerializer(program).data + program_data['channel_id'] = channel.id + current_programs.append(program_data) + + return Response(current_programs, status=status.HTTP_200_OK) + diff --git a/frontend/src/api.js b/frontend/src/api.js index 2c74f3f7..4b728622 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1101,6 +1101,23 @@ export default class API { } } + static async getCurrentPrograms(channelIds = null) { + try { + const response = await request( + `${host}/api/epg/current-programs/`, + { + method: 'POST', + body: { channel_ids: channelIds }, + } + ); + + return response; + } catch (e) { + console.error('Failed to retrieve current programs', e); + return []; + } + } + // Notice there's a duplicated "refreshPlaylist" method above; // you might want to rename or remove one if it's not needed. diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 0e441cbe..5488aef5 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -15,11 +15,16 @@ import { Stack, Text, Tooltip, + useMantineTheme, } from '@mantine/core'; import { + ChevronDown, + ChevronRight, + CirclePlay, Gauge, HardDriveDownload, HardDriveUpload, + Radio, SquareX, Timer, Users, @@ -48,6 +53,7 @@ import { getStreamsByIds, switchStream, } from '../../utils/cards/StreamConnectionCardUtils.js'; +import useVideoStore from '../../store/useVideoStore'; // Create a separate component for each channel card to properly handle the hook const StreamConnectionCard = ({ @@ -57,6 +63,8 @@ const StreamConnectionCard = ({ stopChannel, logos, channelsByUUID, + channels, + currentProgram, }) => { const location = useLocation(); const [availableStreams, setAvailableStreams] = useState([]); @@ -65,11 +73,18 @@ const StreamConnectionCard = ({ const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile const [data, setData] = useState([]); const [previewedStream, setPreviewedStream] = useState(null); + const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false); + + const theme = useMantineTheme(); // Get M3U account data from the playlists store const m3uAccounts = usePlaylistsStore((s) => s.playlists); - // Get settings for speed threshold + // Get settings for speed threshold and environment mode const settings = useSettingsStore((s) => s.settings); + const env_mode = + useSettingsStore((s) => s.environment?.env_mode) || 'production'; + // Get video preview function + const showVideo = useVideoStore((s) => s.showVideo); // Get user's date/time format preferences const { fullDateTimeFormat } = useDateTimeFormat(); @@ -393,6 +408,23 @@ const StreamConnectionCard = ({ // Create select options for available streams const streamOptions = getStreamOptions(availableStreams, m3uAccountsMap); + // Handle preview channel button click + const handlePreviewChannel = () => { + const channelDbId = channelsByUUID[channel.channel_id]; + if (!channelDbId) return; + + const actualChannel = channels[channelDbId]; + if (!actualChannel?.uuid) return; + + const uri = `/proxy/ts/stream/${actualChannel.uuid}`; + let url = `${window.location.protocol}//${window.location.host}${uri}`; + if (env_mode === 'dev') { + url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`; + } + + showVideo(url); + }; + if (location.pathname !== '/stats') { return <>; } @@ -462,9 +494,7 @@ const StreamConnectionCard = ({ - - {channelName} - + {channelName} @@ -474,8 +504,32 @@ const StreamConnectionCard = ({ - {/* Display M3U profile information */} - + {/* Display M3U profile and current program */} + + {currentProgram ? ( + + + + Now Playing: + + + {currentProgram.title} + + setIsProgramDescExpanded(!isProgramDescExpanded)} + > + {isProgramDescExpanded ? ( + + ) : ( + + )} + + + ) : ( + + )} @@ -484,22 +538,53 @@ const StreamConnectionCard = ({ - {/* Add stream selection dropdown */} + {/* Expandable program description */} + {currentProgram && + isProgramDescExpanded && + currentProgram.description && ( + + + {currentProgram.description} + + + )} + + {/* Add stream selection dropdown and preview button */} {availableStreams.length > 0 && ( - - + + + {channel.name && ( + + + + + + )} + + )} {/* Add stream information badges */} diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 19702ae6..fe88b399 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -1,5 +1,20 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; -import { Box, Button, Group, LoadingOverlay, NumberInput, Text, Title, } from '@mantine/core'; +import React, { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + 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 useStreamProfilesStore from '../store/streamProfiles'; @@ -10,22 +25,27 @@ import { fetchActiveChannelStats, getClientStats, getCombinedConnections, + getCurrentPrograms, getStatsByChannelId, getVODStats, stopChannel, 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 VodConnectionCard = React.lazy( + () => import('../components/cards/VodConnectionCard.jsx') +); +const StreamConnectionCard = React.lazy( + () => import('../components/cards/StreamConnectionCard.jsx') +); const Connections = ({ combinedConnections, clients, channelsByUUID, + channels, handleStopVODClient, + currentPrograms, }) => { const logos = useLogosStore((s) => s.logos); @@ -55,6 +75,8 @@ const Connections = ({ stopChannel={stopChannel} logos={logos} channelsByUUID={channelsByUUID} + channels={channels} + currentProgram={currentPrograms[connection.data.channel_id]} /> ); } else if (connection.type === 'vod') { @@ -84,6 +106,20 @@ const StatsPage = () => { const [vodConnections, setVodConnections] = useState([]); const [channelHistory, setChannelHistory] = useState({}); const [isPollingActive, setIsPollingActive] = useState(false); + const [currentPrograms, setCurrentPrograms] = useState({}); + + // Use refs to hold latest values without triggering effects + const channelHistoryRef = useRef(channelHistory); + const channelsByUUIDRef = useRef(channelsByUUID); + + // Update refs when values change + useEffect(() => { + channelHistoryRef.current = channelHistory; + }, [channelHistory]); + + useEffect(() => { + channelsByUUIDRef.current = channelsByUUID; + }, [channelsByUUID]); // Use localStorage for stats refresh interval (in seconds) const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage( @@ -191,7 +227,13 @@ const StatsPage = () => { // Use functional update to access previous state without dependency setChannelHistory((prevChannelHistory) => { // Create a completely new object based only on current channel stats - const stats = getStatsByChannelId(channelStats, prevChannelHistory, channelsByUUID, channels, streamProfiles); + const stats = getStatsByChannelId( + channelStats, + prevChannelHistory, + channelsByUUID, + channels, + streamProfiles + ); console.log('Processed active channels:', stats); @@ -202,6 +244,64 @@ const StatsPage = () => { }); }, [channelStats, channels, channelsByUUID, streamProfiles]); + // Track which channel IDs are active (only changes when channels start/stop, not on stats updates) + const activeChannelIds = useMemo(() => { + return Object.keys(channelHistory).sort().join(','); + }, [channelHistory]); + + // Smart polling for current programs - only fetch when active channels change + useEffect(() => { + // Skip if no active channels + if (!activeChannelIds) { + setCurrentPrograms({}); + return; + } + + let timer = null; + + const fetchPrograms = async () => { + // Use refs to get latest values without adding dependencies + const programs = await getCurrentPrograms( + channelHistoryRef.current, + channelsByUUIDRef.current + ); + setCurrentPrograms(programs); + + // Schedule next fetch based on nearest program end time + if (programs && Object.keys(programs).length > 0) { + const now = new Date(); + let nearestEndTime = null; + + Object.values(programs).forEach((program) => { + if (program && program.end_time) { + const endTime = new Date(program.end_time); + if ( + endTime > now && + (!nearestEndTime || endTime < nearestEndTime) + ) { + nearestEndTime = endTime; + } + } + }); + + if (nearestEndTime) { + const timeUntilChange = nearestEndTime.getTime() - now.getTime(); + const fetchDelay = Math.max(timeUntilChange + 5000, 0); + + timer = setTimeout(fetchPrograms, fetchDelay); + } + } + }; + + // Initial fetch + fetchPrograms(); + + // Cleanup timer on unmount or when active channels change + return () => { + if (timer) clearTimeout(timer); + }; + }, [activeChannelIds]); // Only depend on activeChannelIds + // Combine active streams and VOD connections into a single mixed list const combinedConnections = useMemo(() => { return getCombinedConnections(channelHistory, vodConnections); @@ -216,11 +316,12 @@ const StatsPage = () => { Active Connections - {channelHistoryLength} { - channelHistoryLength !== 1 ? 'streams' : 'stream' - } • {vodConnectionsCount} { - vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection' - } + {channelHistoryLength}{' '} + {channelHistoryLength !== 1 ? 'streams' : 'stream'} •{' '} + {vodConnectionsCount}{' '} + {vodConnectionsCount !== 1 + ? 'VOD connections' + : 'VOD connection'} Refresh Interval (seconds): @@ -273,7 +374,9 @@ const StatsPage = () => { combinedConnections={combinedConnections} clients={clients} channelsByUUID={channelsByUUID} + channels={channels} handleStopVODClient={handleStopVODClient} + currentPrograms={currentPrograms} /> diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js index a25e33f0..72198122 100644 --- a/frontend/src/utils/pages/StatsUtils.js +++ b/frontend/src/utils/pages/StatsUtils.js @@ -20,6 +20,41 @@ export const getVODStats = async () => { return await API.getVODStats(); }; +export const getCurrentPrograms = async (channelHistory, channelsByUUID) => { + try { + // Get all active channel IDs that have actual channels (not just streams) + const activeChannelIds = Object.values(channelHistory) + .filter(ch => ch.name && channelsByUUID && channelsByUUID[ch.channel_id]) + .map(ch => channelsByUUID[ch.channel_id]) + .filter(id => id !== undefined); + + if (activeChannelIds.length === 0) { + return {}; + } + + const programs = await API.getCurrentPrograms(activeChannelIds); + + // Convert array to map keyed by channel UUID for easy lookup + const programsMap = {}; + if (programs && Array.isArray(programs)) { + programs.forEach(program => { + // Find the channel UUID from the channel ID + const channelEntry = Object.entries(channelsByUUID).find( + ([uuid, id]) => id === program.channel_id + ); + if (channelEntry) { + programsMap[channelEntry[0]] = program; + } + }); + } + + return programsMap; + } catch (error) { + console.error('Error fetching current programs:', error); + return {}; + } +}; + export const getCombinedConnections = (channelHistory, vodConnections) => { const activeStreams = Object.values(channelHistory).map((channel) => ({ type: 'stream',