diff --git a/apps/output/views.py b/apps/output/views.py index a695d05f..0f7c009b 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -346,6 +346,10 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust ended_title_template = custom_properties.get('ended_title_template', '') ended_description_template = custom_properties.get('ended_description_template', '') + # Image URL templates + channel_logo_url_template = custom_properties.get('channel_logo_url', '') + program_poster_url_template = custom_properties.get('program_poster_url', '') + # EPG metadata options category_string = custom_properties.get('category', '') # Split comma-separated categories and strip whitespace, filter out empty strings @@ -428,13 +432,25 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust logger.debug(f"Title pattern matched. Groups: {groups}") # Helper function to format template with matched groups - def format_template(template, groups): - """Replace {groupname} placeholders with matched group values""" + def format_template(template, groups, url_encode=False): + """Replace {groupname} placeholders with matched group values + + Args: + template: Template string with {groupname} placeholders + groups: Dict of group names to values + url_encode: If True, URL encode the group values for safe use in URLs + """ if not template: return '' result = template for key, value in groups.items(): - result = result.replace(f'{{{key}}}', str(value) if value else '') + if url_encode and value: + # URL encode the value to handle spaces and special characters + from urllib.parse import quote + encoded_value = quote(str(value), safe='') + result = result.replace(f'{{{key}}}', encoded_value) + else: + result = result.replace(f'{{{key}}}', str(value) if value else '') return result # Extract time from title if time pattern exists @@ -516,6 +532,28 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust # Merge title groups, time groups, and date groups for template formatting all_groups = {**groups, **time_groups, **date_groups} + # Add normalized versions of all groups for cleaner URLs + # These remove all non-alphanumeric characters and convert to lowercase + for key, value in list(all_groups.items()): + if value: + # Remove all non-alphanumeric characters (except spaces temporarily) + # then replace spaces with nothing, and convert to lowercase + normalized = regex.sub(r'[^a-zA-Z0-9\s]', '', str(value)) + normalized = regex.sub(r'\s+', '', normalized).lower() + all_groups[f'{key}_normalize'] = normalized + + # Format channel logo URL if template provided (with URL encoding) + channel_logo_url = None + if channel_logo_url_template: + channel_logo_url = format_template(channel_logo_url_template, all_groups, url_encode=True) + logger.debug(f"Formatted channel logo URL: {channel_logo_url}") + + # Format program poster URL if template provided (with URL encoding) + program_poster_url = None + if program_poster_url_template: + program_poster_url = format_template(program_poster_url_template, all_groups, url_encode=True) + logger.debug(f"Formatted program poster URL: {program_poster_url}") + # Add formatted time strings for better display (handles minutes intelligently) if time_info: hour_24 = time_info['hour'] @@ -676,6 +714,10 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + programs.append({ "channel_id": channel_id, "start_time": program_start_utc, @@ -683,6 +725,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "title": upcoming_title, "description": upcoming_description, "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) current_time += timedelta(minutes=program_duration) @@ -706,6 +749,10 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust if include_live: main_event_custom_properties['live'] = True + # Add program poster URL if provided + if program_poster_url: + main_event_custom_properties['icon'] = program_poster_url + programs.append({ "channel_id": channel_id, "start_time": event_start_utc, @@ -713,6 +760,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "title": main_event_title, "description": main_event_description, "custom_properties": main_event_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) event_happened = True @@ -745,6 +793,10 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + programs.append({ "channel_id": channel_id, "start_time": program_start_utc, @@ -752,6 +804,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "title": ended_title, "description": ended_description, "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) current_time += timedelta(minutes=program_duration) @@ -800,6 +853,10 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + programs.append({ "channel_id": channel_id, "start_time": program_start_utc, @@ -807,6 +864,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "title": program_title, "description": program_description, "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, }) current_time += timedelta(minutes=program_duration) @@ -854,6 +912,10 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust if include_live: program_custom_properties['live'] = True + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + programs.append({ "channel_id": channel_id, "start_time": program_start_utc, @@ -861,6 +923,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "title": title, "description": description, "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) logger.info(f"Generated {len(programs)} custom dummy programs for {channel_name}") @@ -1013,7 +1076,62 @@ def generate_epg(request, profile_name=None, user=None): # Add channel logo if available tvg_logo = "" - if channel.logo: + + # Check if this is a custom dummy EPG with channel logo URL template + if channel.epg_data and channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': + epg_source = channel.epg_data.epg_source + if epg_source.custom_properties: + custom_props = epg_source.custom_properties + channel_logo_url_template = custom_props.get('channel_logo_url', '') + + if channel_logo_url_template: + # Determine which name to use for pattern matching (same logic as program generation) + pattern_match_name = channel.name + name_source = custom_props.get('name_source') + + if name_source == 'stream': + stream_index = custom_props.get('stream_index', 1) - 1 + channel_streams = channel.streams.all().order_by('channelstream__order') + + if channel_streams.exists() and 0 <= stream_index < channel_streams.count(): + stream = list(channel_streams)[stream_index] + pattern_match_name = stream.name + + # Try to extract groups from the channel/stream name and build the logo URL + title_pattern = custom_props.get('title_pattern', '') + if title_pattern: + try: + # Convert PCRE/JavaScript named groups to Python format + title_pattern = regex.sub(r'\(\?<(?![=!])([^>]+)>', r'(?P<\1>', title_pattern) + title_regex = regex.compile(title_pattern) + title_match = title_regex.search(pattern_match_name) + + if title_match: + groups = title_match.groupdict() + + # Add normalized versions of all groups for cleaner URLs + for key, value in list(groups.items()): + if value: + # Remove all non-alphanumeric characters and convert to lowercase + normalized = regex.sub(r'[^a-zA-Z0-9\s]', '', str(value)) + normalized = regex.sub(r'\s+', '', normalized).lower() + groups[f'{key}_normalize'] = normalized + + # Format the logo URL template with the matched groups (with URL encoding) + from urllib.parse import quote + for key, value in groups.items(): + if value: + encoded_value = quote(str(value), safe='') + channel_logo_url_template = channel_logo_url_template.replace(f'{{{key}}}', encoded_value) + else: + channel_logo_url_template = channel_logo_url_template.replace(f'{{{key}}}', '') + tvg_logo = channel_logo_url_template + logger.debug(f"Built channel logo URL from template: {tvg_logo}") + except Exception as e: + logger.warning(f"Failed to build channel logo URL for {channel.name}: {e}") + + # If no custom dummy logo, use regular logo logic + if not tvg_logo and channel.logo: if use_cached_logos: # Use cached logo as before tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) @@ -1114,6 +1232,10 @@ def generate_epg(request, profile_name=None, user=None): if custom_data.get('live', False): yield f" \n" + # Icon/poster URL + if 'icon' in custom_data: + yield f" \n" + yield f" \n" else: @@ -1155,6 +1277,10 @@ def generate_epg(request, profile_name=None, user=None): if custom_data.get('live', False): yield f" \n" + # Icon/poster URL + if 'icon' in custom_data: + yield f" \n" + yield f" \n" continue # Skip to next channel diff --git a/frontend/src/components/forms/DummyEPG.jsx b/frontend/src/components/forms/DummyEPG.jsx index a449d0c6..89c3c1a7 100644 --- a/frontend/src/components/forms/DummyEPG.jsx +++ b/frontend/src/components/forms/DummyEPG.jsx @@ -37,6 +37,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { useState(''); const [endedTitleTemplate, setEndedTitleTemplate] = useState(''); const [endedDescriptionTemplate, setEndedDescriptionTemplate] = useState(''); + const [channelLogoUrl, setChannelLogoUrl] = useState(''); + const [programPosterUrl, setProgramPosterUrl] = useState(''); const [timezoneOptions, setTimezoneOptions] = useState([]); const [loadingTimezones, setLoadingTimezones] = useState(true); @@ -59,6 +61,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { upcoming_description_template: '', ended_title_template: '', ended_description_template: '', + channel_logo_url: '', + program_poster_url: '', name_source: 'channel', stream_index: 1, category: '', @@ -107,6 +111,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { formattedUpcomingDescription: '', formattedEndedTitle: '', formattedEndedDescription: '', + formattedChannelLogoUrl: '', + formattedProgramPosterUrl: '', error: null, }; @@ -166,6 +172,21 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { ...result.dateGroups, }; + // Add normalized versions of all groups for cleaner URLs + // These remove all non-alphanumeric characters and convert to lowercase + Object.keys(allGroups).forEach((key) => { + const value = allGroups[key]; + if (value) { + // Remove all non-alphanumeric characters (except spaces temporarily) + // then replace spaces with nothing, and convert to lowercase + const normalized = String(value) + .replace(/[^a-zA-Z0-9\s]/g, '') + .replace(/\s+/g, '') + .toLowerCase(); + allGroups[`${key}_normalize`] = normalized; + } + }); + // Calculate formatted time strings if time was extracted if (result.timeGroups.hour) { try { @@ -305,6 +326,30 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { ); } + // Format channel logo URL + if (channelLogoUrl && (result.titleMatch || result.timeMatch)) { + result.formattedChannelLogoUrl = channelLogoUrl.replace( + /\{(\w+)\}/g, + (match, key) => { + const value = allGroups[key]; + // URL encode the value to handle spaces and special characters + return value ? encodeURIComponent(String(value)) : match; + } + ); + } + + // Format program poster URL + if (programPosterUrl && (result.titleMatch || result.timeMatch)) { + result.formattedProgramPosterUrl = programPosterUrl.replace( + /\{(\w+)\}/g, + (match, key) => { + const value = allGroups[key]; + // URL encode the value to handle spaces and special characters + return value ? encodeURIComponent(String(value)) : match; + } + ); + } + return result; }, [ titlePattern, @@ -317,6 +362,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { upcomingDescriptionTemplate, endedTitleTemplate, endedDescriptionTemplate, + channelLogoUrl, + programPosterUrl, form.values.custom_properties?.timezone, form.values.custom_properties?.output_timezone, ]); @@ -347,6 +394,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { custom.upcoming_description_template || '', ended_title_template: custom.ended_title_template || '', ended_description_template: custom.ended_description_template || '', + channel_logo_url: custom.channel_logo_url || '', + program_poster_url: custom.program_poster_url || '', name_source: custom.name_source || 'channel', stream_index: custom.stream_index || 1, category: custom.category || '', @@ -368,6 +417,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { ); setEndedTitleTemplate(custom.ended_title_template || ''); setEndedDescriptionTemplate(custom.ended_description_template || ''); + setChannelLogoUrl(custom.channel_logo_url || ''); + setProgramPosterUrl(custom.program_poster_url || ''); } else { form.reset(); setTitlePattern(''); @@ -380,6 +431,8 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { setUpcomingDescriptionTemplate(''); setEndedTitleTemplate(''); setEndedDescriptionTemplate(''); + setChannelLogoUrl(''); + setProgramPosterUrl(''); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [epg]); @@ -544,7 +597,9 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { Use extracted groups from your patterns to format EPG titles and - descriptions. Reference groups using {'{groupname}'} syntax. + descriptions. Reference groups using {'{groupname}'} syntax. For + cleaner URLs, use {'{groupname_normalize}'} to get alphanumeric-only + lowercase versions. { {...form.getInputProps('custom_properties.category')} /> + { + const value = e.target.value; + setChannelLogoUrl(value); + form.setFieldValue('custom_properties.channel_logo_url', value); + }} + /> + + { + const value = e.target.value; + setProgramPosterUrl(value); + form.setFieldValue('custom_properties.program_poster_url', value); + }} + /> + { )} + {channelLogoUrl && ( + <> + + Channel Logo URL: + + + {patternValidation.formattedChannelLogoUrl || + '(no matching groups)'} + + + )} + + {programPosterUrl && ( + <> + + Program Poster URL: + + + {patternValidation.formattedProgramPosterUrl || + '(no matching groups)'} + + + )} + {!titleTemplate && !descriptionTemplate && !upcomingTitleTemplate && !upcomingDescriptionTemplate && !endedTitleTemplate && - !endedDescriptionTemplate && ( + !endedDescriptionTemplate && + !channelLogoUrl && + !programPosterUrl && ( Add title or description templates above to see formatted output preview