mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-28 12:36:42 +00:00
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:
parent
3a5a676097
commit
0e1db3e39c
7 changed files with 368 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue