From 8de23eec35057e4c0620b7fa3cb6149126df47dc Mon Sep 17 00:00:00 2001 From: None Date: Thu, 5 Feb 2026 20:55:52 -0600 Subject: [PATCH 01/13] Fix XC URL sub-path handling in profile refresh and EPG creation - Fix credential extraction in get_transformed_credentials() using negative indices anchored to the known tail structure instead of hardcoded indices that break when server URLs contain sub-paths - Fix EPG URL construction in M3U form to normalize server URL to origin before appending xmltv.php endpoint XC accounts with sub-paths in their server URL (e.g., http://server.com/portal/a/) caused two failures: profile refresh extracted wrong credentials from the URL path due to hardcoded array indices, and EPG auto-creation appended xmltv.php under the sub-path instead of at the domain root. Both fixes normalize away the sub-path using the same approach the backend's XCClient already uses for API calls. --- apps/m3u/tasks.py | 10 ++++++---- frontend/src/components/forms/M3U.jsx | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index f929a208..0b035702 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -2305,10 +2305,12 @@ def get_transformed_credentials(account, profile=None): parsed_url = urllib.parse.urlparse(transformed_complete_url) path_parts = [part for part in parsed_url.path.split('/') if part] - if len(path_parts) >= 2: - # Extract username and password from path - transformed_username = path_parts[1] - transformed_password = path_parts[2] + if len(path_parts) >= 4 and path_parts[-1] == '1234.ts': + # Extract username and password from the known structure: + # .../{live}/{username}/{password}/1234.ts + # Using negative indices so sub-paths in the server URL don't shift extraction + transformed_username = path_parts[-3] + transformed_password = path_parts[-2] # Rebuild server URL without the username/password path transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}" diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index a13f4fef..c92ce823 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -148,7 +148,7 @@ const M3U = ({ API.addEPG({ name: values.name, source_type: 'xmltv', - url: `${values.server_url}/xmltv.php?username=${values.username}&password=${values.password}`, + url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`, api_key: '', is_active: true, refresh_interval: 24, From a52b3dfbe598d5ef25e29bb02d0c91da977e584f Mon Sep 17 00:00:00 2001 From: Patrick McDonagh Date: Thu, 12 Feb 2026 13:08:25 -0600 Subject: [PATCH 02/13] fix: add support for legacy numpy in celery entrypoint --- docker/entrypoint.celery.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index 81cece86..e5c2f340 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -4,15 +4,32 @@ set -e cd /app source /dispatcharrpy/bin/activate +# Function to echo with timestamp +echo_with_timestamp() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" +} + # Wait for Django secret key echo 'Waiting for Django secret key...' while [ ! -f /data/jwt ]; do sleep 1; done export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)" +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl + echo_with_timestamp "✅ Legacy NumPy installed" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + # Wait for migrations to complete (check that NO unapplied migrations remain) echo 'Waiting for migrations to complete...' until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do - echo 'Migrations not ready yet, waiting...' + echo_with_timestamp 'Migrations not ready yet, waiting...' sleep 2 done From 4842bb87fa793578109c2f0ad59a8c7810b65cc6 Mon Sep 17 00:00:00 2001 From: None Date: Fri, 13 Feb 2026 19:37:06 -0600 Subject: [PATCH 03/13] Fix: VOD proxy connection counter leak on client disconnect Three fixes: - TOCTOU: Replace GET-check-INCR with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams - Immediate DECR: Decrement profile counter directly in stream generator exit paths (normal completion, client disconnect, error) instead of deferring to daemon thread cleanup which may never execute - Rollback: Decrement profile counter on create_connection() failure so the reserved slot is released Fixes #962 --- apps/proxy/vod_proxy/connection_manager.py | 49 ++++++++++-- .../multi_worker_connection_manager.py | 77 ++++++++++++++++--- 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index aafc75cd..c4c2130f 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -459,13 +459,12 @@ class VODConnectionManager: return False try: - # Check profile connection limits using standardized key - if not self._check_profile_limits(m3u_profile): + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): logger.warning(f"Profile {m3u_profile.name} connection limit exceeded") return False connection_key = self._get_connection_key(content_type, content_uuid, client_id) - profile_connections_key = self._get_profile_connections_key(m3u_profile.id) content_connections_key = self._get_content_connections_key(content_type, content_uuid) # Check if connection already exists to prevent duplicate counting @@ -473,6 +472,9 @@ class VODConnectionManager: logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}") # Update activity but don't increment profile counter self.redis_client.hset(connection_key, "last_activity", str(time.time())) + # Roll back the reservation — connection already counted + if m3u_profile.max_streams > 0: + self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id)) return True # Connection data @@ -499,8 +501,7 @@ class VODConnectionManager: pipe.hset(connection_key, mapping=connection_data) pipe.expire(connection_key, self.connection_ttl) - # Increment profile connections using standardized method - pipe.incr(profile_connections_key) + # Profile counter already incremented atomically above — no pipe.incr needed # Add to content connections set pipe.sadd(content_connections_key, client_id) @@ -513,6 +514,9 @@ class VODConnectionManager: return True except Exception as e: + # Roll back the profile reservation on failure + if m3u_profile.max_streams > 0: + self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id)) logger.error(f"Error creating VOD connection: {e}") return False @@ -531,6 +535,41 @@ class VODConnectionManager: logger.error(f"Error checking profile limits: {e}") return False + def _check_and_reserve_profile_slot(self, m3u_profile: M3UAccountProfile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + def update_connection_activity(self, content_type: str, content_uuid: str, client_id: str, bytes_sent: int = 0, position_seconds: int = 0) -> bool: diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 1534f761..c8da7cc1 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -674,6 +674,41 @@ class MultiWorkerVODConnectionManager: logger.error(f"Error checking profile limits: {e}") return False + def _check_and_reserve_profile_slot(self, m3u_profile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + def _increment_profile_connections(self, m3u_profile): """Increment profile connection count""" try: @@ -756,10 +791,11 @@ class MultiWorkerVODConnectionManager: if not existing_state: logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection") - # Check profile limits before creating new connection - if not self._check_profile_limits(m3u_profile): + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded") return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True # Apply timeshift parameters modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset) @@ -802,12 +838,11 @@ class MultiWorkerVODConnectionManager: worker_id=self.worker_id ): logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection") + # Roll back the profile slot reservation since connection failed + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False return HttpResponse("Failed to create connection", status=500) - # Increment profile connections after successful connection creation - self._increment_profile_connections(m3u_profile) - profile_connections_incremented = True - logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata") else: logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection") @@ -898,11 +933,18 @@ class MultiWorkerVODConnectionManager: # Schedule smart cleanup if no active streams after normal completion if not redis_connection.has_active_streams(): + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + if state and state.m3u_profile_id: + self._decrement_profile_connections(state.m3u_profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {state.m3u_profile_id} on normal completion") + def delayed_cleanup(): time.sleep(1) # Wait 1 second # Smart cleanup: check active streams and ownership logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion") - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) import threading cleanup_thread = threading.Thread(target=delayed_cleanup) @@ -917,11 +959,18 @@ class MultiWorkerVODConnectionManager: # Schedule smart cleanup if no active streams if not redis_connection.has_active_streams(): + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + if state and state.m3u_profile_id: + self._decrement_profile_connections(state.m3u_profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {state.m3u_profile_id} on client disconnect") + def delayed_cleanup(): time.sleep(1) # Wait 1 second # Smart cleanup: check active streams and ownership logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect") - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) import threading cleanup_thread = threading.Thread(target=delayed_cleanup) @@ -933,8 +982,16 @@ class MultiWorkerVODConnectionManager: if not decremented: redis_connection.decrement_active_streams() decremented = True - # Smart cleanup on error - immediate cleanup since we're in error state - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + + # Decrement profile counter immediately if no other active streams + if not redis_connection.has_active_streams(): + state = redis_connection._get_connection_state() + if state and state.m3u_profile_id: + self._decrement_profile_connections(state.m3u_profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {state.m3u_profile_id} on stream error") + # Smart cleanup on error - immediate cleanup since we're in error state + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) yield b"Error: Stream interrupted" finally: From 74cc9bd376d1ed1ffea570ce3c1971cd7fde8471 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 16 Feb 2026 16:10:51 -0600 Subject: [PATCH 04/13] changelog: Update changelog for pr 946 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 320cf90b..26665f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique. - Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen) From b1376386d5cd8862668aea4c61b27c55e954884f Mon Sep 17 00:00:00 2001 From: Patrick McDonagh Date: Tue, 17 Feb 2026 09:50:50 -0600 Subject: [PATCH 05/13] docs: add legacy NumPy support instructions in docker-compose --- docker/docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 519b288a..bb4397d4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -117,6 +117,11 @@ services: # Process Priority Configuration (Optional) #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features: + #- USE_LEGACY_NUMPY=true + # Django Configuration - DJANGO_SETTINGS_MODULE=dispatcharr.settings - PYTHONUNBUFFERED=1 From 967704d7a00379e04c6326b87909e9d9dc3351eb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 17 Feb 2026 10:59:19 -0600 Subject: [PATCH 06/13] changelog: Update changelog for modular celery container numpy detection pr. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26665f72..f7dadeef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,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) ### Changed From 47853bdb5bcf0132642c967dc4f7ae576f614a33 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 17 Feb 2026 12:00:24 -0600 Subject: [PATCH 07/13] Enhancement: 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) --- CHANGELOG.md | 3 +- apps/output/views.py | 31 +++++++++++++++ frontend/src/components/forms/DummyEPG.jsx | 44 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) 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); + }} + /> +