mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Enhancement: Adds ability to set a custom poster and channel logo with regex for custom epg dummy's.
This commit is contained in:
parent
a1834d9885
commit
0fd464cb96
2 changed files with 245 additions and 6 deletions
|
|
@ -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" <live />\n"
|
||||
|
||||
# Icon/poster URL
|
||||
if 'icon' in custom_data:
|
||||
yield f" <icon src=\"{html.escape(custom_data['icon'])}\" />\n"
|
||||
|
||||
yield f" </programme>\n"
|
||||
|
||||
else:
|
||||
|
|
@ -1155,6 +1277,10 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
if custom_data.get('live', False):
|
||||
yield f" <live />\n"
|
||||
|
||||
# Icon/poster URL
|
||||
if 'icon' in custom_data:
|
||||
yield f" <icon src=\"{html.escape(custom_data['icon'])}\" />\n"
|
||||
|
||||
yield f" </programme>\n"
|
||||
|
||||
continue # Skip to next channel
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
|
||||
<Text size="sm" c="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
|
|
@ -703,6 +758,30 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
{...form.getInputProps('custom_properties.category')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Channel Logo URL (Optional)"
|
||||
description="Build a URL for the channel logo using regex groups. Example: https://example.com/logos/{league_normalize}/{team1_normalize}.png. Use {groupname_normalize} for cleaner URLs (alphanumeric-only, lowercase). This will be used as the channel <icon> in the EPG output."
|
||||
placeholder="https://example.com/logos/{league_normalize}/{team1_normalize}.png"
|
||||
value={channelLogoUrl}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setChannelLogoUrl(value);
|
||||
form.setFieldValue('custom_properties.channel_logo_url', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Program Poster URL (Optional)"
|
||||
description="Build a URL for the program poster/icon using regex groups. Example: https://example.com/posters/{team1_normalize}-vs-{team2_normalize}.jpg. Use {groupname_normalize} for cleaner URLs (alphanumeric-only, lowercase). This will be used as the program <icon> in the EPG output."
|
||||
placeholder="https://example.com/posters/{team1_normalize}-vs-{team2_normalize}.jpg"
|
||||
value={programPosterUrl}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setProgramPosterUrl(value);
|
||||
form.setFieldValue('custom_properties.program_poster_url', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Include Date Tag"
|
||||
description="Include the <date> tag in EPG output with the program's start date (YYYY-MM-DD format). Added to all programs."
|
||||
|
|
@ -973,12 +1052,46 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{channelLogoUrl && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mt="md">
|
||||
Channel Logo URL:
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{patternValidation.formattedChannelLogoUrl ||
|
||||
'(no matching groups)'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{programPosterUrl && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Program Poster URL:
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{patternValidation.formattedProgramPosterUrl ||
|
||||
'(no matching groups)'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!titleTemplate &&
|
||||
!descriptionTemplate &&
|
||||
!upcomingTitleTemplate &&
|
||||
!upcomingDescriptionTemplate &&
|
||||
!endedTitleTemplate &&
|
||||
!endedDescriptionTemplate && (
|
||||
!endedDescriptionTemplate &&
|
||||
!channelLogoUrl &&
|
||||
!programPosterUrl && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Add title or description templates above to see
|
||||
formatted output preview
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue