diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dadeef..da99fb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942) - Event-driven webhooks and script execution (Connect): Added new Connect feature that enables event-driven execution of custom scripts and webhooks in response to system events. Supports multiple event types including channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect). Event data is available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads. Plugins can now subscribe to events by specifying an `events` array in their action definitions. Includes connection testing endpoint with dummy payloads for validation. (Closes #203) - Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165) - Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups: @@ -16,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing - **Next Available**: Auto-assign starting from 1, skipping all used channel numbers Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433) -- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs.- Thanks [@patrickjmcd](https://github.com/patrickjmcd) +- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd) ### Changed diff --git a/apps/output/views.py b/apps/output/views.py index 21377489..21670e5a 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -511,6 +511,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone program_duration = custom_properties.get('program_duration', 180) # Minutes title_template = custom_properties.get('title_template', '') + subtitle_template = custom_properties.get('subtitle_template', '') description_template = custom_properties.get('description_template', '') # Templates for upcoming/ended programs @@ -916,6 +917,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust title_parts.append(all_groups['title']) main_event_title = ' - '.join(title_parts) if title_parts else channel_name + if subtitle_template: + main_event_subtitle = format_template(subtitle_template, all_groups) + else: + main_event_subtitle = None + if description_template: main_event_description = format_template(description_template, all_groups) else: @@ -966,6 +972,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": upcoming_title, + "sub_title": None, # No subtitle for filler programs "description": upcoming_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1005,6 +1012,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": event_start_utc, "end_time": event_end_utc, "title": main_event_title, + "sub_title": main_event_subtitle, "description": main_event_description, "custom_properties": main_event_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1049,6 +1057,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": ended_title, + "sub_title": None, # No subtitle for filler programs "description": ended_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1109,6 +1118,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": program_title, + "sub_title": None, # No subtitle for filler programs "description": program_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, @@ -1136,6 +1146,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust title_parts.append(all_groups['title']) title = ' - '.join(title_parts) if title_parts else channel_name + if subtitle_template: + subtitle = format_template(subtitle_template, all_groups) + else: + subtitle = None + if description_template: description = format_template(description_template, all_groups) else: @@ -1172,6 +1187,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": title, + "sub_title": subtitle, "description": description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1211,6 +1227,11 @@ def generate_dummy_epg( f' ' ) xml_lines.append(f" {html.escape(program['title'])}") + + # Add subtitle if available + if program.get('sub_title'): + xml_lines.append(f" {html.escape(program['sub_title'])}") + xml_lines.append(f" {html.escape(program['description'])}") # Add custom_properties if present @@ -1530,6 +1551,11 @@ def generate_epg(request, profile_name=None, user=None): # Create program entry with escaped channel name yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1579,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None): yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present diff --git a/frontend/src/components/forms/DummyEPG.jsx b/frontend/src/components/forms/DummyEPG.jsx index 9f9346da..06731ac9 100644 --- a/frontend/src/components/forms/DummyEPG.jsx +++ b/frontend/src/components/forms/DummyEPG.jsx @@ -43,6 +43,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { const [datePattern, setDatePattern] = useState(''); const [sampleTitle, setSampleTitle] = useState(''); const [titleTemplate, setTitleTemplate] = useState(''); + const [subtitleTemplate, setSubtitleTemplate] = useState(''); const [descriptionTemplate, setDescriptionTemplate] = useState(''); const [upcomingTitleTemplate, setUpcomingTitleTemplate] = useState(''); const [upcomingDescriptionTemplate, setUpcomingDescriptionTemplate] = @@ -71,6 +72,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { program_duration: 180, sample_title: '', title_template: '', + subtitle_template: '', description_template: '', upcoming_title_template: '', upcoming_description_template: '', @@ -125,6 +127,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { dateGroups: {}, calculatedPlaceholders: {}, formattedTitle: '', + formattedSubtitle: '', formattedDescription: '', formattedUpcomingTitle: '', formattedUpcomingDescription: '', @@ -459,6 +462,14 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { ); } + // Format subtitle template + if (subtitleTemplate && (result.titleMatch || result.timeMatch)) { + result.formattedSubtitle = subtitleTemplate.replace( + /\{(\w+)\}/g, + (match, key) => allGroups[key] || match + ); + } + // Format description template if (descriptionTemplate && (result.titleMatch || result.timeMatch)) { result.formattedDescription = descriptionTemplate.replace( @@ -533,6 +544,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { datePattern, sampleTitle, titleTemplate, + subtitleTemplate, descriptionTemplate, upcomingTitleTemplate, upcomingDescriptionTemplate, @@ -565,6 +577,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { program_duration: custom.program_duration || 180, sample_title: custom.sample_title || '', title_template: custom.title_template || '', + subtitle_template: custom.subtitle_template || '', description_template: custom.description_template || '', upcoming_title_template: custom.upcoming_title_template || '', upcoming_description_template: @@ -591,6 +604,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { setDatePattern(custom.date_pattern || ''); setSampleTitle(custom.sample_title || ''); setTitleTemplate(custom.title_template || ''); + setSubtitleTemplate(custom.subtitle_template || ''); setDescriptionTemplate(custom.description_template || ''); setUpcomingTitleTemplate(custom.upcoming_title_template || ''); setUpcomingDescriptionTemplate( @@ -611,6 +625,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { setDatePattern(''); setSampleTitle(''); setTitleTemplate(''); + setSubtitleTemplate(''); setDescriptionTemplate(''); setUpcomingTitleTemplate(''); setUpcomingDescriptionTemplate(''); @@ -682,6 +697,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { program_duration: custom.program_duration || 180, sample_title: custom.sample_title || '', title_template: custom.title_template || '', + subtitle_template: custom.subtitle_template || '', description_template: custom.description_template || '', upcoming_title_template: custom.upcoming_title_template || '', upcoming_description_template: @@ -708,6 +724,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { setDatePattern(custom.date_pattern || ''); setSampleTitle(custom.sample_title || ''); setTitleTemplate(custom.title_template || ''); + setSubtitleTemplate(custom.subtitle_template || ''); setDescriptionTemplate(custom.description_template || ''); setUpcomingTitleTemplate(custom.upcoming_title_template || ''); setUpcomingDescriptionTemplate(custom.upcoming_description_template || ''); @@ -904,6 +921,20 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => { }} /> + { + const value = e.target.value; + setSubtitleTemplate(value); + form.setFieldValue('custom_properties.subtitle_template', value); + }} + /> +