Merge branch 'dev' into optimize-channels-store

This commit is contained in:
SergeantPanda 2026-02-20 09:42:36 -06:00 committed by GitHub
commit a7b9c0ffe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 934 additions and 392 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations.
- 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:
@ -17,17 +18,25 @@ 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
- Channel store optimization: Refactored frontend channel loading to only fetch channel IDs on initial login (matching the streams store pattern), instead of loading full channel objects upfront. Full channel data is fetched lazily as needed. This dramatically reduces login time and initial page load when large channel libraries are present.
- DVR scheduling: Channel selector now displays the channel number alongside the channel name when scheduling a recording.
- TV Guide performance improvements: Optimized the TV Guide with horizontal culling for off-screen program rows (only rendering visible programs), throttled now-line position updates, and improved scroll performance. Reduces unnecessary DOM work and improves responsiveness with large EPG datasets.
- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity.
- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible.
- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839)
### Fixed
- DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time.
- XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978)
- Stream Profile form User-Agent not populating when editing: The User-Agent field was not correctly loaded from the existing profile when opening the edit modal. (Fixes #650)
- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- 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)

View file

@ -2354,10 +2354,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}"

View file

@ -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' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(program["channel_id"])}">'
)
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
# Add subtitle if available
if program.get('sub_title'):
xml_lines.append(f" <sub-title>{html.escape(program['sub_title'])}</sub-title>")
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")
# 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' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
yield f" <title>{html.escape(program['title'])}</title>\n"
# Add subtitle if available
if program.get('sub_title'):
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
yield f" <desc>{html.escape(program['description'])}</desc>\n"
# Add custom_properties if present
@ -1579,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None):
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
yield f" <title>{html.escape(program['title'])}</title>\n"
# Add subtitle if available
if program.get('sub_title'):
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
yield f" <desc>{html.escape(program['description'])}</desc>\n"
# Add custom_properties if present
@ -2213,7 +2244,7 @@ def xc_get_live_streams(request, user, category_id=None):
)
),
"epg_channel_id": str(channel_num_int),
"added": int(channel.created_at.timestamp()),
"added": str(int(channel.created_at.timestamp())),
"is_adult": int(channel.is_adult),
"category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id),
"category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id],
@ -2875,7 +2906,7 @@ def xc_get_vod_info(request, user, vod_id):
"movie_data": {
"stream_id": movie.id,
"name": movie.name,
"added": int(movie_relation.created_at.timestamp()),
"added": str(int(movie_relation.created_at.timestamp())),
"category_id": str(movie_relation.category.id) if movie_relation.category else "0",
"category_ids": [int(movie_relation.category.id)] if movie_relation.category else [],
"container_extension": movie_relation.container_extension or "mp4",

View file

@ -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:

View file

@ -416,8 +416,22 @@ class RedisBackedVODConnection:
logger.info(f"[{self.session_id}] Updated connection state: length={state.content_length}, type={state.content_type}")
# Save updated state
self._save_connection_state(state)
# Save updated state under lock to avoid overwriting concurrent
# active_streams changes (e.g., another stream's GeneratorExit decrement)
if self._acquire_lock():
try:
current = self._get_connection_state()
if current:
# Preserve the current active_streams value — it may have been
# modified by concurrent increment/decrement operations while
# waiting for the upstream HTTP response.
state.active_streams = current.active_streams
self._save_connection_state(state)
finally:
self._release_lock()
else:
# Fallback: save without lock but skip active_streams to avoid overwrite
logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save")
self.local_response = response
return response
@ -466,35 +480,44 @@ class RedisBackedVODConnection:
return range_header
def increment_active_streams(self):
"""Increment active streams count in Redis"""
"""Increment active streams count in Redis. Returns new active_streams count, or 0 on failure."""
if not self._acquire_lock():
return False
logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock")
return 0
try:
state = self._get_connection_state()
if state:
old = state.active_streams
state.active_streams += 1
state.last_activity = time.time()
self._save_connection_state(state)
logger.debug(f"[{self.session_id}] Active streams incremented to {state.active_streams}")
return True
return False
logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}")
return state.active_streams
logger.warning(f"[{self.session_id}] INCR-AS failed: no state")
return 0
finally:
self._release_lock()
def decrement_active_streams(self):
"""Decrement active streams count in Redis"""
if not self._acquire_lock():
logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock")
return False
try:
state = self._get_connection_state()
if state and state.active_streams > 0:
old = state.active_streams
state.active_streams -= 1
state.last_activity = time.time()
self._save_connection_state(state)
logger.debug(f"[{self.session_id}] Active streams decremented to {state.active_streams}")
logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}")
return True
if not state:
logger.warning(f"[{self.session_id}] DECR-AS failed: no state")
else:
logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}")
return False
finally:
self._release_lock()
@ -674,6 +697,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 +814,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,16 +861,43 @@ 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")
# Immediately increment active_streams to prevent cleanup race condition.
# Without this, stream's GeneratorExit can see active_streams=0
# and DECR the profile counter before the new generator starts.
if matching_session_id:
# Idle session reuse: active_streams already incremented at line 776
# Always need to re-reserve profile slot (GeneratorExit DECRed it)
if not self._check_and_reserve_profile_slot(m3u_profile):
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse")
redis_connection.decrement_active_streams()
return HttpResponse("Connection limit exceeded for profile", status=429)
profile_connections_incremented = True
else:
# Concurrent/reconnect: increment active_streams now (not in generator)
new_count = redis_connection.increment_active_streams()
if new_count == 1:
# 0→1 transition: previous stream's GeneratorExit already DECRed
# the profile counter, need to re-reserve the slot
if not self._check_and_reserve_profile_slot(m3u_profile):
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect")
redis_connection.decrement_active_streams()
return HttpResponse("Connection limit exceeded for profile", status=429)
profile_connections_incremented = True
elif new_count == 0:
logger.error(f"[{client_id}] Failed to increment active streams")
return HttpResponse("Failed to reserve stream", status=500)
# else: new_count > 1, another stream is already active and profile
# counter already reflects it — no INCR needed
# Transfer ownership to current worker and update session activity
if redis_connection._acquire_lock():
try:
@ -834,6 +920,12 @@ class MultiWorkerVODConnectionManager:
if upstream_response is None:
logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable")
if existing_state:
# Roll back the active_streams increment from the else branch
redis_connection.decrement_active_streams()
if profile_connections_incremented:
self._decrement_profile_connections(m3u_profile.id)
profile_connections_incremented = False
return HttpResponse("Requested Range Not Satisfiable", status=416)
# Get connection headers
@ -846,13 +938,14 @@ class MultiWorkerVODConnectionManager:
try:
logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream")
# Increment active streams (unless we already did it for session reuse)
if not matching_session_id:
# New session - increment active streams
# Increment active streams only for brand-new connections.
# For existing connections (session reuse or concurrent requests),
# active_streams was already incremented in the else branch above
# to prevent cleanup race conditions with GeneratorExit.
if not existing_state:
redis_connection.increment_active_streams()
else:
# Reused session - we already incremented when reserving the session
logger.debug(f"[{client_id}] Using pre-reserved session - active streams already incremented")
logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path")
bytes_sent = 0
chunk_count = 0
@ -898,11 +991,19 @@ 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()
profile_id = state.m3u_profile_id if state else m3u_profile.id
if profile_id:
self._decrement_profile_connections(profile_id)
logger.info(f"[{client_id}] Profile counter decremented for profile {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 +1018,19 @@ 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()
profile_id = state.m3u_profile_id if state else m3u_profile.id
if profile_id:
self._decrement_profile_connections(profile_id)
logger.info(f"[{client_id}] Profile counter decremented for profile {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 +1042,17 @@ 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()
profile_id = state.m3u_profile_id if state else m3u_profile.id
if profile_id:
self._decrement_profile_connections(profile_id)
logger.info(f"[{client_id}] Profile counter decremented for profile {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:

View file

@ -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

View file

@ -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

View file

@ -201,8 +201,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
],
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
label: 'System',
icon: <MonitorCog size={20} />,
paths: [
{
label: 'Users',
@ -215,8 +215,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
path: '/logos',
},
{
label: 'System',
icon: <MonitorCog size={20} />,
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
],

View file

@ -1,5 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Accordion,
ActionIcon,
Box,
Button,
Checkbox,
@ -8,12 +10,14 @@ import {
Modal,
NumberInput,
Paper,
Popover,
Select,
Stack,
Text,
TextInput,
Textarea,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import API from '../../api';
@ -26,6 +30,30 @@ import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
// Helper component for labels with info popover
const LabelWithInfo = ({ label, info, required }) => (
<Group spacing={4} align="center">
<Text size="sm" fw={500}>
{label}
{required && (
<Text component="span" c="red" ml={4}>
*
</Text>
)}
</Text>
<Popover width={300} position="top" withArrow shadow="md">
<Popover.Target>
<ActionIcon size="xs" variant="subtle" color="gray">
<Info size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Text size="xs">{info}</Text>
</Popover.Dropdown>
</Popover>
</Group>
);
const DummyEPGForm = ({ epg, isOpen, onClose }) => {
// Get all EPGs from the store
const epgs = useEPGsStore((state) => state.epgs);
@ -43,6 +71,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 +100,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 +155,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
dateGroups: {},
calculatedPlaceholders: {},
formattedTitle: '',
formattedSubtitle: '',
formattedDescription: '',
formattedUpcomingTitle: '',
formattedUpcomingDescription: '',
@ -459,6 +490,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 +572,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
datePattern,
sampleTitle,
titleTemplate,
subtitleTemplate,
descriptionTemplate,
upcomingTitleTemplate,
upcomingDescriptionTemplate,
@ -565,6 +605,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 +632,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 +653,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
setDatePattern('');
setSampleTitle('');
setTitleTemplate('');
setSubtitleTemplate('');
setDescriptionTemplate('');
setUpcomingTitleTemplate('');
setUpcomingDescriptionTemplate('');
@ -682,6 +725,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 +752,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 || '');
@ -805,341 +850,494 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
{...form.getInputProps('name')}
/>
{/* Pattern Configuration */}
<Divider label="Pattern Configuration" labelPosition="center" />
{/* Accordion for organized sections */}
<Accordion defaultValue="patterns" variant="separated">
<Accordion.Item value="patterns">
<Accordion.Control>Pattern Configuration</Accordion.Control>
<Accordion.Panel>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Define regex patterns to extract information from channel
titles or stream names. Use named capture groups like
(?&lt;groupname&gt;pattern).
</Text>
<Text size="sm" c="dimmed">
Define regex patterns to extract information from channel titles or
stream names. Use named capture groups like
(?&lt;groupname&gt;pattern).
</Text>
<Select
label={
<LabelWithInfo
label="Name Source"
info="Choose whether to parse the channel name or a stream name assigned to the channel"
required
/>
}
required
withAsterisk={false}
data={[
{ value: 'channel', label: 'Channel Name' },
{ value: 'stream', label: 'Stream Name' },
]}
{...form.getInputProps('custom_properties.name_source')}
/>
<Select
label="Name Source"
description="Choose whether to parse the channel name or a stream name assigned to the channel"
required
data={[
{ value: 'channel', label: 'Channel Name' },
{ value: 'stream', label: 'Stream Name' },
]}
{...form.getInputProps('custom_properties.name_source')}
/>
{form.values.custom_properties?.name_source === 'stream' && (
<NumberInput
label={
<LabelWithInfo
label="Stream Index"
info="Which stream to use (1 = first stream, 2 = second stream, etc.)"
/>
}
placeholder="1"
min={1}
max={100}
{...form.getInputProps('custom_properties.stream_index')}
/>
)}
{form.values.custom_properties?.name_source === 'stream' && (
<NumberInput
label="Stream Index"
description="Which stream to use (1 = first stream, 2 = second stream, etc.)"
placeholder="1"
min={1}
max={100}
{...form.getInputProps('custom_properties.stream_index')}
/>
)}
<TextInput
id="title_pattern"
name="title_pattern"
label={
<LabelWithInfo
label="Title Pattern"
info="Regex pattern to extract title information (e.g., team names, league). Example: (?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
required
/>
}
placeholder="(?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
required
withAsterisk={false}
value={titlePattern}
onChange={(e) => {
const value = e.target.value;
setTitlePattern(value);
form.setFieldValue(
'custom_properties.title_pattern',
value
);
}}
error={form.errors['custom_properties.title_pattern']}
/>
<TextInput
id="title_pattern"
name="title_pattern"
label="Title Pattern"
description="Regex pattern to extract title information (e.g., team names, league). Example: (?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
placeholder="(?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
required
value={titlePattern}
onChange={(e) => {
const value = e.target.value;
setTitlePattern(value);
form.setFieldValue('custom_properties.title_pattern', value);
}}
error={form.errors['custom_properties.title_pattern']}
/>
<TextInput
id="time_pattern"
name="time_pattern"
label={
<LabelWithInfo
label="Time Pattern (Optional)"
info="Extract time from channel titles. Required groups: 'hour' (1-12 or 0-23), 'minute' (0-59), 'ampm' (AM/PM - optional for 24-hour). Examples: @ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM) for '8:30PM' OR @ (?<hour>\d{1,2}):(?<minute>\d{2}) for '20:30'"
/>
}
placeholder="@ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM)"
value={timePattern}
onChange={(e) => {
const value = e.target.value;
setTimePattern(value);
form.setFieldValue(
'custom_properties.time_pattern',
value
);
}}
/>
<TextInput
id="time_pattern"
name="time_pattern"
label="Time Pattern (Optional)"
description="Extract time from channel titles. Required groups: 'hour' (1-12 or 0-23), 'minute' (0-59), 'ampm' (AM/PM - optional for 24-hour). Examples: @ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM) for '8:30PM' OR @ (?<hour>\d{1,2}):(?<minute>\d{2}) for '20:30'"
placeholder="@ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM)"
value={timePattern}
onChange={(e) => {
const value = e.target.value;
setTimePattern(value);
form.setFieldValue('custom_properties.time_pattern', value);
}}
/>
<TextInput
id="date_pattern"
name="date_pattern"
label={
<LabelWithInfo
label="Date Pattern (Optional)"
info="Extract date from channel titles. Groups: 'month' (name or number), 'day', 'year' (optional, defaults to current year). Examples: @ (?<month>\w+) (?<day>\d+) for 'Oct 17' OR (?<month>\d+)/(?<day>\d+)/(?<year>\d+) for '10/17/2025'"
/>
}
placeholder="@ (?<month>\w+) (?<day>\d+)"
value={datePattern}
onChange={(e) => {
const value = e.target.value;
setDatePattern(value);
form.setFieldValue(
'custom_properties.date_pattern',
value
);
}}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<TextInput
id="date_pattern"
name="date_pattern"
label="Date Pattern (Optional)"
description="Extract date from channel titles. Groups: 'month' (name or number), 'day', 'year' (optional, defaults to current year). Examples: @ (?<month>\w+) (?<day>\d+) for 'Oct 17' OR (?<month>\d+)/(?<day>\d+)/(?<year>\d+) for '10/17/2025'"
placeholder="@ (?<month>\w+) (?<day>\d+)"
value={datePattern}
onChange={(e) => {
const value = e.target.value;
setDatePattern(value);
form.setFieldValue('custom_properties.date_pattern', value);
}}
/>
<Accordion.Item value="templates">
<Accordion.Control>Output Templates</Accordion.Control>
<Accordion.Panel>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Use extracted groups from your patterns to format EPG titles
and descriptions. Reference groups using {'{groupname}'}{' '}
syntax. For cleaner URLs, use {'{groupname_normalize}'} to
get alphanumeric-only lowercase versions.
</Text>
{/* Output Templates */}
<Divider label="Output Templates (Optional)" labelPosition="center" />
<TextInput
id="title_template"
name="title_template"
label={
<LabelWithInfo
label="Title Template"
info="Format the EPG title using extracted groups. Use {starttime} (12-hour: '10 PM'), {starttime24} (24-hour: '22:00'), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {league} - {team1} vs {team2} ({starttime}-{endtime})"
/>
}
placeholder="{league} - {team1} vs {team2}"
value={titleTemplate}
onChange={(e) => {
const value = e.target.value;
setTitleTemplate(value);
form.setFieldValue(
'custom_properties.title_template',
value
);
}}
/>
<Text size="sm" c="dimmed">
Use extracted groups from your patterns to format EPG titles and
descriptions. Reference groups using {'{groupname}'} syntax. For
cleaner URLs, use {'{groupname_normalize}'} to get alphanumeric-only
lowercase versions.
</Text>
<TextInput
id="subtitle_template"
name="subtitle_template"
label={
<LabelWithInfo
label="Subtitle Template (Optional)"
info="Format the EPG subtitle using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Example: {starttime} - {endtime}"
/>
}
placeholder="{starttime} - {endtime}"
value={subtitleTemplate}
onChange={(e) => {
const value = e.target.value;
setSubtitleTemplate(value);
form.setFieldValue(
'custom_properties.subtitle_template',
value
);
}}
/>
<TextInput
id="title_template"
name="title_template"
label="Title Template"
description="Format the EPG title using extracted groups. Use {starttime} (12-hour: '10 PM'), {starttime24} (24-hour: '22:00'), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {league} - {team1} vs {team2} ({starttime}-{endtime})"
placeholder="{league} - {team1} vs {team2}"
value={titleTemplate}
onChange={(e) => {
const value = e.target.value;
setTitleTemplate(value);
form.setFieldValue('custom_properties.title_template', value);
}}
/>
<Textarea
id="description_template"
name="description_template"
label={
<LabelWithInfo
label="Description Template"
info="Format the EPG description using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Watch {team1} take on {team2} on {date} from {starttime} to {endtime}!"
/>
}
placeholder="Watch {team1} take on {team2} in this exciting {league} matchup from {starttime} to {endtime}!"
minRows={2}
value={descriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.description_template',
value
);
}}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Textarea
id="description_template"
name="description_template"
label="Description Template"
description="Format the EPG description using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Watch {team1} take on {team2} on {date} from {starttime} to {endtime}!"
placeholder="Watch {team1} take on {team2} in this exciting {league} matchup from {starttime} to {endtime}!"
minRows={2}
value={descriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.description_template',
value
);
}}
/>
<Accordion.Item value="upcoming-ended">
<Accordion.Control>Upcoming/Ended Templates</Accordion.Control>
<Accordion.Panel>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Customize how programs appear before and after the event. If
left empty, will use the main title/description with
"Upcoming:" or "Ended:" prefix.
</Text>
{/* Upcoming/Ended Templates */}
<Divider
label="Upcoming/Ended Templates (Optional)"
labelPosition="center"
/>
<TextInput
id="upcoming_title_template"
name="upcoming_title_template"
label={
<LabelWithInfo
label="Upcoming Title Template"
info="Title for programs before the event starts. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} starting at {starttime}."
/>
}
placeholder="{team1} vs {team2} starting at {starttime}."
value={upcomingTitleTemplate}
onChange={(e) => {
const value = e.target.value;
setUpcomingTitleTemplate(value);
form.setFieldValue(
'custom_properties.upcoming_title_template',
value
);
}}
/>
<Text size="sm" c="dimmed">
Customize how programs appear before and after the event. If left
empty, will use the main title/description with "Upcoming:" or
"Ended:" prefix.
</Text>
<Textarea
id="upcoming_description_template"
name="upcoming_description_template"
label={
<LabelWithInfo
label="Upcoming Description Template"
info="Description for programs before the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Upcoming: Watch the {league} match up where the {team1} take on the {team2} on {date} from {starttime} to {endtime}!"
/>
}
placeholder="Upcoming: Watch the {league} match up where the {team1} take on the {team2} from {starttime} to {endtime}!"
minRows={2}
value={upcomingDescriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setUpcomingDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.upcoming_description_template',
value
);
}}
/>
<TextInput
id="upcoming_title_template"
name="upcoming_title_template"
label="Upcoming Title Template"
description="Title for programs before the event starts. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} starting at {starttime}."
placeholder="{team1} vs {team2} starting at {starttime}."
value={upcomingTitleTemplate}
onChange={(e) => {
const value = e.target.value;
setUpcomingTitleTemplate(value);
form.setFieldValue(
'custom_properties.upcoming_title_template',
value
);
}}
/>
<TextInput
id="ended_title_template"
name="ended_title_template"
label={
<LabelWithInfo
label="Ended Title Template"
info="Title for programs after the event has ended. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} started at {starttime}."
/>
}
placeholder="{team1} vs {team2} started at {starttime}."
value={endedTitleTemplate}
onChange={(e) => {
const value = e.target.value;
setEndedTitleTemplate(value);
form.setFieldValue(
'custom_properties.ended_title_template',
value
);
}}
/>
<Textarea
id="upcoming_description_template"
name="upcoming_description_template"
label="Upcoming Description Template"
description="Description for programs before the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Upcoming: Watch the {league} match up where the {team1} take on the {team2} on {date} from {starttime} to {endtime}!"
placeholder="Upcoming: Watch the {league} match up where the {team1} take on the {team2} from {starttime} to {endtime}!"
minRows={2}
value={upcomingDescriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setUpcomingDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.upcoming_description_template',
value
);
}}
/>
<Textarea
id="ended_description_template"
name="ended_description_template"
label={
<LabelWithInfo
label="Ended Description Template"
info="Description for programs after the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: The {league} match between {team1} and {team2} on {date} ran from {starttime} to {endtime}."
/>
}
placeholder="The {league} match between {team1} and {team2} ran from {starttime} to {endtime}."
minRows={2}
value={endedDescriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setEndedDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.ended_description_template',
value
);
}}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<TextInput
id="ended_title_template"
name="ended_title_template"
label="Ended Title Template"
description="Title for programs after the event has ended. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} started at {starttime}."
placeholder="{team1} vs {team2} started at {starttime}."
value={endedTitleTemplate}
onChange={(e) => {
const value = e.target.value;
setEndedTitleTemplate(value);
form.setFieldValue(
'custom_properties.ended_title_template',
value
);
}}
/>
<Accordion.Item value="fallback">
<Accordion.Control>Fallback Templates</Accordion.Control>
<Accordion.Panel>
<Stack spacing="md">
<Text size="sm" c="dimmed">
When patterns don't match the channel/stream name, use these
custom fallback templates instead of the default placeholder
messages. Leave empty to use the built-in humorous fallback
descriptions.
</Text>
<Textarea
id="ended_description_template"
name="ended_description_template"
label="Ended Description Template"
description="Description for programs after the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: The {league} match between {team1} and {team2} on {date} ran from {starttime} to {endtime}."
placeholder="The {league} match between {team1} and {team2} ran from {starttime} to {endtime}."
minRows={2}
value={endedDescriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setEndedDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.ended_description_template',
value
);
}}
/>
<TextInput
id="fallback_title_template"
name="fallback_title_template"
label={
<LabelWithInfo
label="Fallback Title Template"
info="Custom title when patterns don't match. If empty, uses the channel/stream name."
/>
}
placeholder="No EPG data available"
value={fallbackTitleTemplate}
onChange={(e) => {
const value = e.target.value;
setFallbackTitleTemplate(value);
form.setFieldValue(
'custom_properties.fallback_title_template',
value
);
}}
/>
{/* Fallback Templates */}
<Divider
label="Fallback Templates (Optional)"
labelPosition="center"
/>
<Textarea
id="fallback_description_template"
name="fallback_description_template"
label={
<LabelWithInfo
label="Fallback Description Template"
info="Custom description when patterns don't match. If empty, uses built-in placeholder messages."
/>
}
placeholder="EPG information is currently unavailable for this channel."
minRows={2}
value={fallbackDescriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setFallbackDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.fallback_description_template',
value
);
}}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Text size="sm" c="dimmed">
When patterns don't match the channel/stream name, use these custom
fallback templates instead of the default placeholder messages.
Leave empty to use the built-in humorous fallback descriptions.
</Text>
<Accordion.Item value="settings">
<Accordion.Control>EPG Settings</Accordion.Control>
<Accordion.Panel>
<Stack spacing="md">
<Select
label={
<LabelWithInfo
label="Event Timezone"
info="The timezone of the event times in your channel titles. DST (Daylight Saving Time) is handled automatically! All timezones supported by pytz are available."
/>
}
placeholder={
loadingTimezones
? 'Loading timezones...'
: 'Select timezone'
}
data={timezoneOptions}
searchable
disabled={loadingTimezones}
{...form.getInputProps('custom_properties.timezone')}
/>
<TextInput
id="fallback_title_template"
name="fallback_title_template"
label="Fallback Title Template"
description="Custom title when patterns don't match. If empty, uses the channel/stream name."
placeholder="No EPG data available"
value={fallbackTitleTemplate}
onChange={(e) => {
const value = e.target.value;
setFallbackTitleTemplate(value);
form.setFieldValue(
'custom_properties.fallback_title_template',
value
);
}}
/>
<Select
label={
<LabelWithInfo
label="Output Timezone (Optional)"
info="Display times in a different timezone than the event timezone. Leave empty to use the event timezone. Example: Event at 10 PM ET displayed as 9 PM CT."
/>
}
placeholder="Same as event timezone"
data={timezoneOptions}
searchable
clearable
disabled={loadingTimezones}
{...form.getInputProps('custom_properties.output_timezone')}
/>
<Textarea
id="fallback_description_template"
name="fallback_description_template"
label="Fallback Description Template"
description="Custom description when patterns don't match. If empty, uses built-in placeholder messages."
placeholder="EPG information is currently unavailable for this channel."
minRows={2}
value={fallbackDescriptionTemplate}
onChange={(e) => {
const value = e.target.value;
setFallbackDescriptionTemplate(value);
form.setFieldValue(
'custom_properties.fallback_description_template',
value
);
}}
/>
<NumberInput
label={
<LabelWithInfo
label="Program Duration (minutes)"
info="Default duration for each program"
/>
}
placeholder="180"
min={1}
max={1440}
{...form.getInputProps(
'custom_properties.program_duration'
)}
/>
{/* EPG Settings */}
<Divider label="EPG Settings" labelPosition="center" />
<TextInput
label={
<LabelWithInfo
label="Categories (Optional)"
info="EPG categories for these programs. Use commas to separate multiple (e.g., Sports, Live, HD). Note: Only added to the main event, not upcoming/ended filler programs."
/>
}
placeholder="Sports, Live"
{...form.getInputProps('custom_properties.category')}
/>
<Select
label="Event Timezone"
description="The timezone of the event times in your channel titles. DST (Daylight Saving Time) is handled automatically! All timezones supported by pytz are available."
placeholder={
loadingTimezones ? 'Loading timezones...' : 'Select timezone'
}
data={timezoneOptions}
searchable
disabled={loadingTimezones}
{...form.getInputProps('custom_properties.timezone')}
/>
<TextInput
label={
<LabelWithInfo
label="Channel Logo URL (Optional)"
info="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
);
}}
/>
<Select
label="Output Timezone (Optional)"
description="Display times in a different timezone than the event timezone. Leave empty to use the event timezone. Example: Event at 10 PM ET displayed as 9 PM CT."
placeholder="Same as event timezone"
data={timezoneOptions}
searchable
clearable
disabled={loadingTimezones}
{...form.getInputProps('custom_properties.output_timezone')}
/>
<TextInput
label={
<LabelWithInfo
label="Program Poster URL (Optional)"
info="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
);
}}
/>
<NumberInput
label="Program Duration (minutes)"
description="Default duration for each program"
placeholder="180"
min={1}
max={1440}
{...form.getInputProps('custom_properties.program_duration')}
/>
<Checkbox
label={
<LabelWithInfo
label="Include Date Tag"
info="Include the <date> tag in EPG output with the program's start date (YYYY-MM-DD format). Added to all programs."
/>
}
{...form.getInputProps('custom_properties.include_date', {
type: 'checkbox',
})}
/>
<TextInput
label="Categories (Optional)"
description="EPG categories for these programs. Use commas to separate multiple (e.g., Sports, Live, HD). Note: Only added to the main event, not upcoming/ended filler programs."
placeholder="Sports, Live"
{...form.getInputProps('custom_properties.category')}
/>
<Checkbox
label={
<LabelWithInfo
label="Include Live Tag"
info="Mark programs as live content with the <live /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
/>
}
{...form.getInputProps('custom_properties.include_live', {
type: 'checkbox',
})}
/>
<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."
{...form.getInputProps('custom_properties.include_date', {
type: 'checkbox',
})}
/>
<Checkbox
label="Include Live Tag"
description="Mark programs as live content with the <live /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
{...form.getInputProps('custom_properties.include_live', {
type: 'checkbox',
})}
/>
<Checkbox
label="Include New Tag"
description="Mark programs as new content with the <new /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
{...form.getInputProps('custom_properties.include_new', {
type: 'checkbox',
})}
/>
<Checkbox
label={
<LabelWithInfo
label="Include New Tag"
info="Mark programs as new content with the <new /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
/>
}
{...form.getInputProps('custom_properties.include_new', {
type: 'checkbox',
})}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{/* Testing & Preview */}
<Divider label="Test Your Configuration" labelPosition="center" />
@ -1155,8 +1353,12 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
<TextInput
id="sample_title"
name="sample_title"
label={`Sample ${form.values.custom_properties?.name_source === 'stream' ? 'Stream' : 'Channel'} Name`}
description={`Enter a sample ${form.values.custom_properties?.name_source === 'stream' ? 'stream name' : 'channel name'} to test pattern matching and see the formatted output`}
label={
<LabelWithInfo
label={`Sample ${form.values.custom_properties?.name_source === 'stream' ? 'Stream' : 'Channel'} Name`}
info={`Enter a sample ${form.values.custom_properties?.name_source === 'stream' ? 'stream name' : 'channel name'} to test pattern matching and see the formatted output`}
/>
}
placeholder="League 01: Team 1 VS Team 2 @ Oct 17 8:00PM ET"
value={sampleTitle}
onChange={(e) => {
@ -1371,6 +1573,18 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
</>
)}
{subtitleTemplate && (
<>
<Text size="xs" c="dimmed" mt="xs">
EPG Subtitle:
</Text>
<Text size="sm" fw={500}>
{patternValidation.formattedSubtitle ||
'(no matching groups)'}
</Text>
</>
)}
{descriptionTemplate && (
<>
<Text size="xs" c="dimmed" mt="xs">
@ -1464,6 +1678,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
)}
{!titleTemplate &&
!subtitleTemplate &&
!descriptionTemplate &&
!upcomingTitleTemplate &&
!upcomingDescriptionTemplate &&

View file

@ -169,7 +169,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,

View file

@ -1,28 +1,65 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
// StreamProfile form
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
import {
Modal,
TextInput,
Textarea,
Select,
Button,
Flex,
Stack,
Checkbox,
} from '@mantine/core';
// Built-in commands supported by Dispatcharr out of the box.
const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: 'streamlink', label: 'Streamlink' },
{ value: 'cvlc', label: 'VLC' },
{ value: 'yt-dlp', label: 'yt-dlp' },
{ value: '__custom__', label: 'Custom…' },
];
// Default parameter examples for each built-in command.
const COMMAND_EXAMPLES = {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
};
// Returns '__custom__' when the command isn't one of the built-ins,
// otherwise returns the command value itself.
const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
parameters: Yup.string(),
});
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
// Separate state for the dropdown selection so 'Custom' can be chosen
// independently of the actual command string stored in the form.
const [commandSelection, setCommandSelection] = useState('ffmpeg');
const defaultValues = useMemo(
() => ({
name: profile?.name || '',
command: profile?.command || '',
parameters: profile?.parameters || '',
is_active: profile?.is_active ?? true,
user_agent: profile?.user_agent || '',
user_agent: profile?.user_agent ? `${profile.user_agent}` : '',
}),
[profile]
);
@ -32,12 +69,19 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
});
// Sync form + dropdown selection whenever the target profile or modal state changes
useEffect(() => {
reset(defaultValues);
setCommandSelection(toCommandSelection(profile?.command || ''));
}, [defaultValues, reset, profile]);
const onSubmit = async (values) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
@ -49,58 +93,120 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
onClose();
};
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
if (!isOpen) {
return <></>;
}
const isLocked = profile ? profile.locked : false;
const isCustom = commandSelection === '__custom__';
const userAgentValue = watch('user_agent');
const isActiveValue = watch('is_active');
return (
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
label="Name"
{...register('name')}
error={errors.name?.message}
disabled={profile ? profile.locked : false}
/>
<TextInput
label="Command"
{...register('command')}
error={errors.command?.message}
disabled={profile ? profile.locked : false}
/>
<TextInput
label="Parameters"
{...register('parameters')}
error={errors.parameters?.message}
disabled={profile ? profile.locked : false}
/>
<Stack gap="sm">
<TextInput
label="Name"
description="A unique, descriptive label for this stream profile"
disabled={isLocked}
{...register('name')}
error={errors.name?.message}
/>
<Select
label="User-Agent"
{...register('user_agent')}
value={userAgentValue}
error={errors.user_agent?.message}
data={userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
}))}
/>
<Select
label="Command"
description={
<>
The executable used to process the stream.
<br />
Choose a built-in tool or select <em>Custom</em> to enter any
executable name or path.
</>
}
data={BUILT_IN_COMMANDS}
disabled={isLocked}
value={commandSelection}
onChange={(val) => {
setCommandSelection(val);
// For built-in selections, write the real command value immediately
if (val !== '__custom__') {
setValue('command', val, { shouldValidate: true });
} else {
// Clear so the user enters their own value
setValue('command', '', { shouldValidate: false });
}
}}
error={isCustom ? undefined : errors.command?.message}
/>
{isCustom && (
<TextInput
label="Custom Command"
description="Enter the executable name (e.g. ffmpeg) or full path (e.g. /usr/local/bin/mycmd)"
disabled={isLocked}
{...register('command')}
error={errors.command?.message}
/>
)}
<Textarea
label="Parameters"
description={
<>
Command-line arguments passed to the command.
<br />
Use <strong>{'{streamUrl}'}</strong> and{' '}
<strong>{'{userAgent}'}</strong> as placeholders they are
substituted at stream time.
{COMMAND_EXAMPLES[commandSelection] && (
<>
<br />
Example: <em>{COMMAND_EXAMPLES[commandSelection]}</em>
</>
)}
</>
}
autosize
minRows={2}
placeholder={
COMMAND_EXAMPLES[commandSelection] ||
'Enter command-line arguments…'
}
disabled={isLocked}
{...register('parameters')}
error={errors.parameters?.message}
/>
<Select
label="User-Agent"
description="Optional user-agent override. Falls back to the system default if not set."
clearable
data={userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
}))}
value={userAgentValue}
onChange={(val) => setValue('user_agent', val ?? '')}
error={errors.user_agent?.message}
/>
<Checkbox
label="Is Active"
description="Enable or disable this stream profile"
checked={isActiveValue}
onChange={(e) => setValue('is_active', e.currentTarget.checked)}
/>
</Stack>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
variant="filled"
disabled={isSubmitting}
size="small"
size="sm"
>
Submit
Save
</Button>
</Flex>
</form>