Add EPG program preview to channel edit/create modal

- Extended CurrentProgramsAPIView to accept epg_data_ids parameter
- Added getCurrentProgramForEpg() API method in frontend
- Added UI in Channel form showing current program with tooltip
- Shows loading state, no program state, and program details
- Includes expandable description, progress bar with elapsed/remaining time
- Matches stats page 'Now Playing' feature behavior
This commit is contained in:
FiveBoroughs 2026-02-04 09:47:51 +01:00 committed by Five Boroughs
parent 450b384419
commit 9367d76dac
No known key found for this signature in database
3 changed files with 253 additions and 32 deletions

View file

@ -413,7 +413,9 @@ class EPGGridAPIView(APIView):
)
if custom_count > 0:
channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_with_custom_dummy]
channel_names = [
f"{ch.name} (ID: {ch.id})" for ch in channels_with_custom_dummy
]
logger.debug(
f"EPGGridAPIView: Channels needing custom dummy EPG: {', '.join(channel_names)}"
)
@ -501,28 +503,39 @@ class EPGGridAPIView(APIView):
# Get the custom dummy EPG source
epg_source = channel.epg_data.epg_source if channel.epg_data else None
logger.debug(f"Generating custom dummy programs for channel: {channel.name} (ID: {channel.id})")
logger.debug(
f"Generating custom dummy programs for channel: {channel.name} (ID: {channel.id})"
)
# Determine which name to parse based on custom properties
name_to_parse = channel.name
if epg_source and epg_source.custom_properties:
custom_props = epg_source.custom_properties
name_source = custom_props.get('name_source')
name_source = custom_props.get("name_source")
if name_source == 'stream':
if name_source == "stream":
# Get the stream index (1-based from user, convert to 0-based)
stream_index = custom_props.get('stream_index', 1) - 1
stream_index = custom_props.get("stream_index", 1) - 1
# Get streams ordered by channelstream order
channel_streams = channel.streams.all().order_by('channelstream__order')
channel_streams = channel.streams.all().order_by(
"channelstream__order"
)
if channel_streams.exists() and 0 <= stream_index < channel_streams.count():
if (
channel_streams.exists()
and 0 <= stream_index < channel_streams.count()
):
stream = list(channel_streams)[stream_index]
name_to_parse = stream.name
logger.debug(f"Using stream name for parsing: {name_to_parse} (stream index: {stream_index})")
logger.debug(
f"Using stream name for parsing: {name_to_parse} (stream index: {stream_index})"
)
else:
logger.warning(f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name")
elif name_source == 'channel':
logger.warning(
f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name"
)
elif name_source == "channel":
logger.debug(f"Using channel name for parsing: {name_to_parse}")
# Generate programs using custom patterns from the dummy EPG source
@ -532,22 +545,24 @@ class EPGGridAPIView(APIView):
channel_name=name_to_parse,
num_days=1,
program_length_hours=4,
epg_source=epg_source
epg_source=epg_source,
)
# Custom dummy should always return data (either from patterns or fallback)
if generated:
logger.debug(f"Generated {len(generated)} custom dummy programs for {channel.name}")
logger.debug(
f"Generated {len(generated)} custom dummy programs for {channel.name}"
)
# Convert generated programs to API format
for program in generated:
prog_custom = program.get('custom_properties') or {}
dummy_program = {
"id": f"dummy-custom-{channel.id}-{program['start_time'].hour}",
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
"start_time": program['start_time'].isoformat(),
"end_time": program['end_time'].isoformat(),
"title": program['title'],
"description": program['description'],
"start_time": program["start_time"].isoformat(),
"end_time": program["end_time"].isoformat(),
"title": program["title"],
"description": program["description"],
"tvg_id": dummy_tvg_id,
"sub_title": program.get('sub_title'),
"custom_properties": prog_custom if prog_custom else None,
@ -560,7 +575,9 @@ class EPGGridAPIView(APIView):
}
dummy_programs.append(dummy_program)
else:
logger.warning(f"No programs generated for custom dummy EPG channel: {channel.name}")
logger.warning(
f"No programs generated for custom dummy EPG channel: {channel.name}"
)
except Exception as e:
logger.error(
@ -575,7 +592,9 @@ class EPGGridAPIView(APIView):
dummy_tvg_id = str(channel.uuid)
try:
logger.debug(f"Generating standard dummy programs for channel: {channel.name} (ID: {channel.id})")
logger.debug(
f"Generating standard dummy programs for channel: {channel.name} (ID: {channel.id})"
)
# Create programs every 4 hours for the next 24 hours with humorous descriptions
for hour_offset in range(0, 24, 4):
@ -667,11 +686,17 @@ class EPGImportAPIView(APIView):
# Check if this is a dummy EPG source
try:
from .models import EPGSource
epg_source = EPGSource.objects.get(id=epg_id)
if epg_source.source_type == 'dummy':
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
if epg_source.source_type == "dummy":
logger.info(
f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}"
)
return Response(
{"success": False, "message": "Dummy EPG sources do not require refreshing."},
{
"success": False,
"message": "Dummy EPG sources do not require refreshing.",
},
status=status.HTTP_400_BAD_REQUEST,
)
except EPGSource.DoesNotExist:
@ -731,19 +756,71 @@ class CurrentProgramsAPIView(APIView):
allow_null=True,
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
),
"epg_data_ids": serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
help_text="Array of EPG data IDs. Can be used instead of channel_ids.",
),
},
),
responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Get IDs from request body
channel_uuids = request.data.get('channel_uuids', None)
epg_data_ids = request.data.get("epg_data_ids", None)
# Validate that at most one type of ID is provided
if channel_uuids is not None and epg_data_ids is not None:
return Response(
{"error": "Provide either channel_uuids or epg_data_ids, not both"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get current time
now = timezone.now()
# If epg_data_ids are provided, query directly by EPG data
if epg_data_ids is not None:
if not isinstance(epg_data_ids, list):
return Response(
{"error": "epg_data_ids must be an array of integers or null"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
epg_data_ids = [int(id) for id in epg_data_ids]
except (ValueError, TypeError):
return Response(
{"error": "epg_data_ids must contain valid integers"},
status=status.HTTP_400_BAD_REQUEST,
)
from apps.epg.models import EPGData
epg_data_entries = EPGData.objects.filter(id__in=epg_data_ids)
current_programs = []
for epg_data in epg_data_entries:
program = ProgramData.objects.filter(
epg=epg_data, start_time__lte=now, end_time__gt=now
).first()
if program:
program_data = ProgramDataSerializer(program).data
program_data["epg_data_id"] = epg_data.id
current_programs.append(program_data)
return Response(current_programs, status=status.HTTP_200_OK)
# Otherwise, use channel-based query (existing behavior)
# Import Channel model
from apps.channels.models import Channel
# Build query for channels with EPG data
query = Channel.objects.filter(epg_data__isnull=False)
channel_uuids = request.data.get('channel_uuids', None)
if channel_uuids is not None:
if not isinstance(channel_uuids, list):
return Response(
@ -753,10 +830,7 @@ class CurrentProgramsAPIView(APIView):
query = query.filter(uuid__in=channel_uuids)
# Get channels with EPG data
channels = query.select_related('epg_data')
# Get current time
now = timezone.now()
channels = query.select_related("epg_data")
# Build list of current programs
current_programs = []
@ -776,4 +850,3 @@ class CurrentProgramsAPIView(APIView):
return Response(current_programs, status=status.HTTP_200_OK)

View file

@ -1529,6 +1529,24 @@ export default class API {
}
}
static async getCurrentProgramForEpg(epgId) {
try {
const response = await request(
`${host}/api/epg/current-programs/`,
{
method: 'POST',
body: { epg_data_ids: [epgId] },
}
);
// The endpoint returns an array, get the first item
return response && response.length > 0 ? response[0] : null;
} catch (e) {
console.error('Failed to retrieve current program for EPG', e);
return null;
}
}
// Notice there's a duplicated "refreshPlaylist" method above;
// you might want to rename or remove one if it's not needed.

View file

@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import ChannelGroupForm from './ChannelGroup';
import logo from '../../images/logo.png';
@ -32,8 +33,9 @@ import {
Tooltip,
UnstyledButton,
useMantineTheme,
Progress,
} from '@mantine/core';
import { ListOrdered, SquarePlus, Undo2, X, Zap } from 'lucide-react';
import { ListOrdered, Radio, SquarePlus, Undo2, X, Zap } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { FixedSizeList as List } from 'react-window';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
@ -134,6 +136,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
const [selectedEPG, setSelectedEPG] = useState('');
const [tvgFilter, setTvgFilter] = useState('');
const [logoFilter, setLogoFilter] = useState('');
const [currentProgram, setCurrentProgram] = useState(null);
const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false);
const [isLoadingProgram, setIsLoadingProgram] = useState(false);
const [hasFetchedProgram, setHasFetchedProgram] = useState(false);
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
const [groupFilter, setGroupFilter] = useState('');
@ -455,6 +461,32 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
}
}, [defaultValues, channel, reset, epgs, tvgsById]);
useEffect(() => {
const fetchCurrentProgram = async () => {
const epgDataId = watch('epg_data_id');
if (epgDataId && epgDataId !== '0' && epgDataId !== '') {
setIsLoadingProgram(true);
setHasFetchedProgram(false);
try {
const program = await API.getCurrentProgramForEpg(epgDataId);
setCurrentProgram(program);
} catch (error) {
console.error('Failed to fetch current program:', error);
setCurrentProgram(null);
} finally {
setIsLoadingProgram(false);
setHasFetchedProgram(true);
}
} else {
setCurrentProgram(null);
setIsLoadingProgram(false);
setHasFetchedProgram(false);
}
};
fetchCurrentProgram();
}, [watch('epg_data_id')]);
// Memoize logo options to prevent infinite re-renders during background loading
const logoOptions = useMemo(() => {
const options = [{ id: '0', name: 'Default' }].concat(
@ -1218,10 +1250,108 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
</ScrollArea>
</PopoverDropdown>
</Popover>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{isLoadingProgram && (
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
<Group gap={5}>
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" c="dimmed">Loading EPG data...</Text>
</Group>
</Box>
)}
{hasFetchedProgram && !currentProgram && !isLoadingProgram && (
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
<Group gap={5}>
<Radio size="14" style={{ color: '#6b7280', flexShrink: 0 }} />
<Text size="xs" c="dimmed">No current program (EPG may need refresh)</Text>
</Group>
</Box>
)}
{currentProgram && (
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
<Group gap={5} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
Current Program:
</Text>
<Tooltip label={currentProgram.title}>
<Text size="xs" c="dimmed" truncate>
{currentProgram.title}
</Text>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
style={{ flexShrink: 0 }}
>
{isProgramDescExpanded ? '▼' : '▶'}
</ActionIcon>
</Group>
{isProgramDescExpanded && currentProgram.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{currentProgram.description}
</Text>
</Box>
)}
{isProgramDescExpanded &&
currentProgram.start_time &&
currentProgram.end_time &&
(() => {
const now = new Date();
const startTime = new Date(currentProgram.start_time);
const endTime = new Date(currentProgram.end_time);
const totalDuration = (endTime - startTime) / 1000;
const elapsed = (now - startTime) / 1000;
const remaining = (endTime - now) / 1000;
const percentage = Math.min(
100,
Math.max(0, (elapsed / totalDuration) * 100)
);
const formatProgramTime = (seconds) => {
const absSeconds = Math.abs(seconds);
const hours = Math.floor(absSeconds / 3600);
const minutes = Math.floor((absSeconds % 3600) / 60);
const secs = Math.floor(absSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<Stack gap="xs" mt={4} ml={24}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
);
})()}
</Box>
)}
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="default"