diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 296f91b8..b3d0bb56 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -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) - diff --git a/frontend/src/api.js b/frontend/src/api.js index def75df5..2967ba18 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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. diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index ee8c4e91..38393d1d 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -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 }) => { - - - + {isLoadingProgram && ( + + + + Loading EPG data... + + + )} + + {hasFetchedProgram && !currentProgram && !isLoadingProgram && ( + + + + No current program (EPG may need refresh) + + + )} + + {currentProgram && ( + + + + + Current Program: + + + + {currentProgram.title} + + + setIsProgramDescExpanded(!isProgramDescExpanded)} + style={{ flexShrink: 0 }} + > + {isProgramDescExpanded ? '▼' : '▶'} + + + + {isProgramDescExpanded && currentProgram.description && ( + + + {currentProgram.description} + + + )} + + {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 ( + + + + {formatProgramTime(elapsed)} elapsed + + + {formatProgramTime(remaining)} remaining + + + + + ); + })()} + + )} + + + +