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