mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
450b384419
commit
9367d76dac
3 changed files with 253 additions and 32 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue