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
This commit is contained in:
SergeantPanda 2026-01-20 13:37:40 -06:00
parent 3a5a676097
commit 0e1db3e39c
7 changed files with 368 additions and 34 deletions

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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 = ({
</Group>
<Flex justify="space-between" align="center">
<Group>
<Text fw={500}>{channelName}</Text>
</Group>
<Text fw={500}>{channelName}</Text>
<Tooltip label="Active Stream Profile">
<Group gap={5}>
@ -474,8 +504,32 @@ const StreamConnectionCard = ({
</Tooltip>
</Flex>
{/* Display M3U profile information */}
<Flex justify="flex-end" align="center" mt={-8}>
{/* Display M3U profile and current program */}
<Flex justify="space-between" align="center" mt={-8}>
{currentProgram ? (
<Group gap={5}>
<Radio size="14" style={{ color: '#22c55e' }} />
<Text size="xs" fw={500} c="green.5">
Now Playing:
</Text>
<Text size="xs" c="dimmed">
{currentProgram.title}
</Text>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
>
{isProgramDescExpanded ? (
<ChevronDown size="14" />
) : (
<ChevronRight size="14" />
)}
</ActionIcon>
</Group>
) : (
<Box />
)}
<Group gap={5}>
<HardDriveUpload size="18" />
<Tooltip label="Current M3U Profile">
@ -484,22 +538,53 @@ const StreamConnectionCard = ({
</Group>
</Flex>
{/* Add stream selection dropdown */}
{/* Expandable program description */}
{currentProgram &&
isProgramDescExpanded &&
currentProgram.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{currentProgram.description}
</Text>
</Box>
)}
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (
<Tooltip label="Switch to another stream source">
<Select
size="xs"
label="Active Stream"
placeholder={
isLoadingStreams ? 'Loading streams...' : 'Select stream'
}
data={streamOptions}
value={activeStreamId || channel.stream_id?.toString() || null}
onChange={handleStreamChange}
disabled={isLoadingStreams}
mt={8}
/>
</Tooltip>
<Box mt={8}>
<Group align="flex-end" gap="xs">
<Box style={{ flex: 1 }}>
<Tooltip label="Switch to another stream source">
<Select
size="xs"
label="Active Stream"
placeholder={
isLoadingStreams ? 'Loading streams...' : 'Select stream'
}
data={streamOptions}
value={
activeStreamId || channel.stream_id?.toString() || null
}
onChange={handleStreamChange}
disabled={isLoadingStreams}
/>
</Tooltip>
</Box>
{channel.name && (
<Tooltip label="Preview Channel">
<ActionIcon
size="md"
variant="transparent"
color={theme.tailwind.green[5]}
onClick={handlePreviewChannel}
style={{ marginBottom: 1 }}
>
<CirclePlay size="20" />
</ActionIcon>
</Tooltip>
)}
</Group>
</Box>
)}
{/* Add stream information badges */}

View file

@ -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 = () => {
<Title order={3}>Active Connections</Title>
<Group align="center">
<Text size="sm" c="dimmed">
{channelHistoryLength} {
channelHistoryLength !== 1 ? 'streams' : 'stream'
} {vodConnectionsCount} {
vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection'
}
{channelHistoryLength}{' '}
{channelHistoryLength !== 1 ? 'streams' : 'stream'} {' '}
{vodConnectionsCount}{' '}
{vodConnectionsCount !== 1
? 'VOD connections'
: 'VOD connection'}
</Text>
<Group align="center" gap="xs">
<Text size="sm">Refresh Interval (seconds):</Text>
@ -273,7 +374,9 @@ const StatsPage = () => {
combinedConnections={combinedConnections}
clients={clients}
channelsByUUID={channelsByUUID}
channels={channels}
handleStopVODClient={handleStopVODClient}
currentPrograms={currentPrograms}
/>
</Box>
</Box>

View file

@ -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',