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