From 7e5be6094f6fb9bc00ff3eee9e4e4b799c98ce3c Mon Sep 17 00:00:00 2001 From: Marlon Alkan Date: Sun, 8 Jun 2025 16:45:34 +0200 Subject: [PATCH 001/307] docker: init: 02-postgres.sh: allow DB user to create new DB (for tests) --- docker/init/02-postgres.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 69a81dd4..7bb90671 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -57,13 +57,14 @@ if [ -z "$(ls -A $POSTGRES_DIR)" ]; then echo "Creating PostgreSQL database..." su - postgres -c "createdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" - # Create user, set ownership, and grant privileges + # Create user, set ownership, and grant privileges, including privileges to create new databases echo "Creating PostgreSQL user..." su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" < Date: Sun, 8 Jun 2025 16:47:00 +0200 Subject: [PATCH 002/307] apps: output: change body detection logic and add tests --- apps/output/tests.py | 23 +++++++++++++++++++++++ apps/output/views.py | 5 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/output/tests.py b/apps/output/tests.py index e1e857ee..f87c8340 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -14,3 +14,26 @@ class OutputM3UTest(TestCase): self.assertEqual(response.status_code, 200) content = response.content.decode() self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_empty_body(self): + """ + Test that a POST request with an empty body returns 200 OK. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded') + content = response.content.decode() + + self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK") + self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_with_body(self): + """ + Test that a POST request with a non-empty body returns 403 Forbidden. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data={'evilstring': 'muhahaha'}) + + self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") + self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) diff --git a/apps/output/views.py b/apps/output/views.py index 2b18d185..ff02560c 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -18,9 +18,10 @@ def generate_m3u(request, profile_name=None): The stream URL now points to the new stream_view that uses StreamProfile. Supports both GET and POST methods for compatibility with IPTVSmarters. """ - # Check if this is a POST request with data (which we don't want to allow) + # Check if this is a POST request and the body is not empty (which we don't want to allow) if request.method == "POST" and request.body: - return HttpResponseForbidden("POST requests with content are not allowed") + if request.body.decode() != '{}': + return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) if profile_name is not None: channel_profile = ChannelProfile.objects.get(name=profile_name) From 0dbc5221b2d602323de9fa938f07f1b1a4363126 Mon Sep 17 00:00:00 2001 From: BigPanda Date: Thu, 18 Sep 2025 21:20:47 +0100 Subject: [PATCH 003/307] Add 'UK' region I'm not sure if this was intentional, but the UK seems to be missing from the region list. --- frontend/src/constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 78f374d4..528c5f04 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -303,6 +303,7 @@ export const REGION_CHOICES = [ { value: 'tz', label: 'TZ' }, { value: 'ua', label: 'UA' }, { value: 'ug', label: 'UG' }, + { value: 'uk', label: 'UK' }, { value: 'um', label: 'UM' }, { value: 'us', label: 'US' }, { value: 'uy', label: 'UY' }, From ae8b85a3e2d019234d4b183fd1963a35d0a7c85f Mon Sep 17 00:00:00 2001 From: Ragchuck Date: Wed, 15 Oct 2025 22:06:01 +0200 Subject: [PATCH 004/307] feat: added support for rtsp --- apps/m3u/tasks.py | 6 +++++- core/utils.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 0ba595c5..52847e77 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -219,6 +219,10 @@ def fetch_m3u_lines(account, use_cache=False): # Has HTTP URLs, might be a simple M3U without headers is_valid_m3u = True logger.info("Content validated as M3U: contains HTTP URLs") + elif any(line.strip().startswith('rtsp') for line in content_lines): + # Has HTTP URLs, might be a simple M3U without headers + is_valid_m3u = True + logger.info("Content validated as M3U: contains RTSP URLs") if not is_valid_m3u: # Log what we actually received for debugging @@ -1381,7 +1385,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): ) problematic_lines.append((line_index + 1, line[:200])) - elif extinf_data and line.startswith("http"): + elif extinf_data and (line.startswith("http") or line.startswith("rtsp")): url_count += 1 # Associate URL with the last EXTINF line extinf_data[-1]["url"] = line diff --git a/core/utils.py b/core/utils.py index 36ac5fef..da40d19c 100644 --- a/core/utils.py +++ b/core/utils.py @@ -377,8 +377,8 @@ def validate_flexible_url(value): import re # More flexible pattern for non-FQDN hostnames with paths - # Matches: http://hostname, http://hostname/, http://hostname:port/path/to/file.xml - non_fqdn_pattern = r'^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\:[0-9]+)?(/[^\s]*)?$' + # Matches: http://hostname, http://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1 + non_fqdn_pattern = r'^(rts?p|https?)://([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' non_fqdn_match = re.match(non_fqdn_pattern, value) if non_fqdn_match: From 57b99e39004115eacca28d536053ad897b470bae Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 22 Oct 2025 17:02:40 -0500 Subject: [PATCH 005/307] Enhancement: Change sub_title field in ProgramData model from CharField to TextField. This will allow for longer than 255 character sub titles. (Closes #579) --- .../0019_alter_programdata_sub_title.py | 18 ++++++++++++++++++ apps/epg/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 apps/epg/migrations/0019_alter_programdata_sub_title.py diff --git a/apps/epg/migrations/0019_alter_programdata_sub_title.py b/apps/epg/migrations/0019_alter_programdata_sub_title.py new file mode 100644 index 00000000..5a53627c --- /dev/null +++ b/apps/epg/migrations/0019_alter_programdata_sub_title.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-22 21:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0018_epgsource_custom_properties_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='programdata', + name='sub_title', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 6c70add2..e5f3847b 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -155,7 +155,7 @@ class ProgramData(models.Model): start_time = models.DateTimeField() end_time = models.DateTimeField() title = models.CharField(max_length=255) - sub_title = models.CharField(max_length=255, blank=True, null=True) + sub_title = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True) tvg_id = models.CharField(max_length=255, null=True, blank=True) custom_properties = models.JSONField(default=dict, blank=True, null=True) From 0fd464cb96bd5b55cac0e207bf2e68a8a535d4ba Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 24 Oct 2025 12:55:25 -0500 Subject: [PATCH 006/307] Enhancement: Adds ability to set a custom poster and channel logo with regex for custom epg dummy's. --- apps/output/views.py | 134 ++++++++++++++++++++- frontend/src/components/forms/DummyEPG.jsx | 117 +++++++++++++++++- 2 files changed, 245 insertions(+), 6 deletions(-) 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 From 2042274f10126344f00fc4a7d3e14fd30c53c05d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 25 Oct 2025 09:27:31 -0500 Subject: [PATCH 007/307] Enhancement: Bulk assign custom dummy epgs. Enhancement: Display a dynamic warning dialog when saving batch channel edits that will display what will be changed. --- .../src/components/forms/ChannelBatch.jsx | 250 +++++++++++++----- 1 file changed, 181 insertions(+), 69 deletions(-) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 0527a3b6..42184f4d 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import useChannelsStore from '../../store/channels'; import API from '../../api'; import useStreamProfilesStore from '../../store/streamProfiles'; +import useEPGsStore from '../../store/epgs'; import ChannelGroupForm from './ChannelGroup'; import { Box, @@ -53,6 +54,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }, [ensureLogosLoaded]); const streamProfiles = useStreamProfilesStore((s) => s.profiles); + const epgs = useEPGsStore((s) => s.epgs); + const fetchEPGs = useEPGsStore((s) => s.fetchEPGs); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); @@ -60,6 +63,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const [isSubmitting, setIsSubmitting] = useState(false); const [regexFind, setRegexFind] = useState(''); const [regexReplace, setRegexReplace] = useState(''); + const [selectedDummyEpgId, setSelectedDummyEpgId] = useState(null); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); @@ -71,10 +75,22 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const [confirmSetNamesOpen, setConfirmSetNamesOpen] = useState(false); const [confirmSetLogosOpen, setConfirmSetLogosOpen] = useState(false); const [confirmSetTvgIdsOpen, setConfirmSetTvgIdsOpen] = useState(false); - const [confirmClearEpgsOpen, setConfirmClearEpgsOpen] = useState(false); + const [confirmBatchUpdateOpen, setConfirmBatchUpdateOpen] = useState(false); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); + // Fetch EPG sources when modal opens + useEffect(() => { + if (isOpen) { + fetchEPGs(); + } + }, [isOpen, fetchEPGs]); + + // Get dummy EPG sources + const dummyEpgSources = useMemo(() => { + return Object.values(epgs).filter((epg) => epg.source_type === 'dummy'); + }, [epgs]); + const form = useForm({ mode: 'uncontrolled', initialValues: { @@ -85,7 +101,88 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }, }); + // Build confirmation message based on selected changes + const getConfirmationMessage = () => { + const changes = []; + const values = form.getValues(); + + // Check for regex name changes + if (regexFind.trim().length > 0) { + changes.push( + `• Name Change: Apply regex find "${regexFind}" replace with "${regexReplace || ''}"` + ); + } + + // Check channel group + if (selectedChannelGroup && selectedChannelGroup !== '-1') { + const groupName = channelGroups[selectedChannelGroup]?.name || 'Unknown'; + changes.push(`• Channel Group: ${groupName}`); + } + + // Check logo + if (selectedLogoId && selectedLogoId !== '-1') { + if (selectedLogoId === '0') { + changes.push(`• Logo: Use Default`); + } else { + const logoName = channelLogos[selectedLogoId]?.name || 'Selected Logo'; + changes.push(`• Logo: ${logoName}`); + } + } + + // Check stream profile + if (values.stream_profile_id && values.stream_profile_id !== '-1') { + if (values.stream_profile_id === '0') { + changes.push(`• Stream Profile: Use Default`); + } else { + const profileName = + streamProfiles[values.stream_profile_id]?.name || 'Selected Profile'; + changes.push(`• Stream Profile: ${profileName}`); + } + } + + // Check user level + if (values.user_level && values.user_level !== '-1') { + const userLevelLabel = + USER_LEVEL_LABELS[values.user_level] || values.user_level; + changes.push(`• User Level: ${userLevelLabel}`); + } + + // Check dummy EPG + if (selectedDummyEpgId) { + if (selectedDummyEpgId === 'clear') { + changes.push(`• EPG: Clear Assignment (use default dummy)`); + } else { + const epgName = epgs[selectedDummyEpgId]?.name || 'Selected EPG'; + changes.push(`• Dummy EPG: ${epgName}`); + } + } + + return changes; + }; + + const handleSubmit = () => { + const changes = getConfirmationMessage(); + + // If no changes detected, show notification + if (changes.length === 0) { + notifications.show({ + title: 'No Changes', + message: 'Please select at least one field to update.', + color: 'orange', + }); + return; + } + + // Skip warning if suppressed + if (isWarningSuppressed('batch-update-channels')) { + return onSubmit(); + } + + setConfirmBatchUpdateOpen(true); + }; + const onSubmit = async () => { + setConfirmBatchUpdateOpen(false); setIsSubmitting(true); const values = { @@ -126,6 +223,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { try { const applyRegex = regexFind.trim().length > 0; + // First, handle standard field updates (name, group, logo, etc.) if (applyRegex) { // Build per-channel updates to apply unique names via regex let flags = 'g'; @@ -153,10 +251,37 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); await API.bulkUpdateChannels(updates); - } else { + } else if (Object.keys(values).length > 0) { await API.updateChannels(channelIds, values); } + // Then, handle EPG assignment if a dummy EPG was selected + if (selectedDummyEpgId) { + if (selectedDummyEpgId === 'clear') { + // Clear EPG assignments + const associations = channelIds.map((id) => ({ + channel_id: id, + epg_data_id: null, + })); + await API.batchSetEPG(associations); + } else { + // Assign the selected dummy EPG + const selectedEpg = epgs[selectedDummyEpgId]; + if ( + selectedEpg && + selectedEpg.epg_data_ids && + selectedEpg.epg_data_ids.length > 0 + ) { + const epgDataId = selectedEpg.epg_data_ids[0]; + const associations = channelIds.map((id) => ({ + channel_id: id, + epg_data_id: epgDataId, + })); + await API.batchSetEPG(associations); + } + } + } + // Refresh both the channels table data and the main channels store await Promise.all([ API.requeryChannels(), @@ -305,49 +430,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } }; - const handleClearEpgs = async () => { - if (!channelIds || channelIds.length === 0) { - notifications.show({ - title: 'No Channels Selected', - message: 'No channels to update.', - color: 'orange', - }); - return; - } - - // Skip warning if suppressed - if (isWarningSuppressed('batch-clear-epgs')) { - return executeClearEpgs(); - } - - setConfirmClearEpgsOpen(true); - }; - - const executeClearEpgs = async () => { - try { - // Clear EPG assignments (set to null/dummy) using existing batchSetEPG API - const associations = channelIds.map((id) => ({ - channel_id: id, - epg_data_id: null, - })); - - await API.batchSetEPG(associations); - - // batchSetEPG already shows a notification and refreshes channels - // Close the modal - setConfirmClearEpgsOpen(false); - onClose(); - } catch (error) { - console.error('Failed to clear EPG assignments:', error); - notifications.show({ - title: 'Error', - message: 'Failed to clear EPG assignments.', - color: 'red', - }); - setConfirmClearEpgsOpen(false); - } - }; - // useEffect(() => { // // const sameStreamProfile = channels.every( // // (channel) => channel.stream_profile_id == channels[0].stream_profile_id @@ -418,7 +500,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } styles={{ hannontent: { '--mantine-color-body': '#27272A' } }} > -
+ @@ -484,20 +566,30 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { Set TVG-IDs from EPG - -