From 8de23eec35057e4c0620b7fa3cb6149126df47dc Mon Sep 17 00:00:00 2001 From: None Date: Thu, 5 Feb 2026 20:55:52 -0600 Subject: [PATCH 01/89] 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 24f812dc4d39b7881c72a77da32967f6a5ffb662 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 8 Feb 2026 09:29:22 -0500 Subject: [PATCH 02/89] initial connect feature --- apps/accounts/api_views.py | 13 + apps/api/urls.py | 1 + apps/connect/__init__.py | 0 apps/connect/api_urls.py | 17 + apps/connect/api_views.py | 133 +++++++ apps/connect/apps.py | 8 + apps/connect/handlers/__init__.py | 0 apps/connect/handlers/api.py | 0 apps/connect/handlers/base.py | 12 + apps/connect/handlers/script.py | 28 ++ apps/connect/handlers/webhook.py | 10 + apps/connect/migrations/0001_initial.py | 52 +++ apps/connect/migrations/__init__.py | 0 apps/connect/models.py | 36 ++ apps/connect/serializers.py | 46 +++ apps/connect/utils.py | 73 ++++ core/utils.py | 74 +++- dispatcharr/settings.py | 1 + frontend/src/App.jsx | 7 + frontend/src/api.js | 367 ++++++++++++++----- frontend/src/components/Sidebar.jsx | 164 +++++++-- frontend/src/components/forms/Connection.jsx | 189 ++++++++++ frontend/src/components/forms/Recording.jsx | 80 +++- frontend/src/components/sidebar.css | 20 +- frontend/src/constants.js | 22 +- frontend/src/pages/Connect.jsx | 204 +++++++++++ frontend/src/pages/ConnectLogs.jsx | 299 +++++++++++++++ frontend/src/store/connect.jsx | 46 +++ 28 files changed, 1761 insertions(+), 141 deletions(-) create mode 100644 apps/connect/__init__.py create mode 100644 apps/connect/api_urls.py create mode 100644 apps/connect/api_views.py create mode 100644 apps/connect/apps.py create mode 100644 apps/connect/handlers/__init__.py create mode 100644 apps/connect/handlers/api.py create mode 100644 apps/connect/handlers/base.py create mode 100644 apps/connect/handlers/script.py create mode 100644 apps/connect/handlers/webhook.py create mode 100644 apps/connect/migrations/0001_initial.py create mode 100644 apps/connect/migrations/__init__.py create mode 100644 apps/connect/models.py create mode 100644 apps/connect/serializers.py create mode 100644 apps/connect/utils.py create mode 100644 frontend/src/components/forms/Connection.jsx create mode 100644 frontend/src/pages/Connect.jsx create mode 100644 frontend/src/pages/ConnectLogs.jsx create mode 100644 frontend/src/store/connect.jsx diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 607ce199..188cd8cb 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -1,4 +1,5 @@ from django.contrib.auth import authenticate, login, logout +import logging from django.contrib.auth.models import Group, Permission from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt @@ -15,6 +16,8 @@ from .models import User from .serializers import UserSerializer, GroupSerializer, PermissionSerializer from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +logger = logging.getLogger(__name__) + class TokenObtainPairView(TokenObtainPairView): def post(self, request, *args, **kwargs): @@ -25,6 +28,7 @@ class TokenObtainPairView(TokenObtainPairView): username = request.data.get("username", 'unknown') client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user=username, @@ -43,6 +47,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') try: + logger.debug(f"Attempting JWT login for user={username}") response = super().post(request, *args, **kwargs) # If login was successful, update last_login and log success @@ -61,6 +66,7 @@ class TokenObtainPairView(TokenObtainPairView): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Login success: user={username} ip={client_ip}") except User.DoesNotExist: pass # User doesn't exist, but login somehow succeeded else: @@ -72,6 +78,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent=user_agent, reason='Invalid credentials', ) + logger.info(f"Login failed: user={username} ip={client_ip}") return response @@ -84,6 +91,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent=user_agent, reason=f'Authentication error: {str(e)[:100]}', ) + logger.error(f"Login error for user={username}: {e}") raise # Re-raise the exception to maintain normal error flow @@ -95,6 +103,7 @@ class TokenRefreshView(TokenRefreshView): from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user='token_refresh', @@ -167,6 +176,7 @@ class AuthViewSet(viewsets.ViewSet): from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.debug(f"Login attempt via session: user={username} ip={client_ip}") if user: login(request, user) @@ -182,6 +192,7 @@ class AuthViewSet(viewsets.ViewSet): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Login success via session: user={username} ip={client_ip}") return Response( { @@ -203,6 +214,7 @@ class AuthViewSet(viewsets.ViewSet): user_agent=user_agent, reason='Invalid credentials', ) + logger.info(f"Login failed via session: user={username} ip={client_ip}") return Response({"error": "Invalid credentials"}, status=400) @extend_schema( @@ -222,6 +234,7 @@ class AuthViewSet(viewsets.ViewSet): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Logout: user={username} ip={client_ip}") logout(request) return Response({"message": "Logout successful"}) diff --git a/apps/api/urls.py b/apps/api/urls.py index 5a688778..b176bc1b 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')), path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')), path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')), + path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')), # path('output/', include(('apps.output.api_urls', 'output'), namespace='output')), #path('player/', include(('apps.player.api_urls', 'player'), namespace='player')), #path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')), diff --git a/apps/connect/__init__.py b/apps/connect/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/api_urls.py b/apps/connect/api_urls.py new file mode 100644 index 00000000..664c437b --- /dev/null +++ b/apps/connect/api_urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from .api_views import ( + IntegrationViewSet, + EventSubscriptionViewSet, + DeliveryLogViewSet, +) + +app_name = 'connect' + +router = DefaultRouter() +router.register(r'integrations', IntegrationViewSet, basename='integration') +router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription') +router.register(r'logs', DeliveryLogViewSet, basename='delivery-log') + +urlpatterns = [] +urlpatterns += router.urls diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py new file mode 100644 index 00000000..1402de04 --- /dev/null +++ b/apps/connect/api_views.py @@ -0,0 +1,133 @@ +from rest_framework import viewsets, status +from rest_framework.pagination import PageNumberPagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.response import Response +from rest_framework.decorators import action +from .models import Integration, EventSubscription, DeliveryLog +from .serializers import ( + IntegrationSerializer, + EventSubscriptionSerializer, + DeliveryLogSerializer, +) +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, +) + + +class IntegrationViewSet(viewsets.ModelViewSet): + queryset = Integration.objects.all() + serializer_class = IntegrationSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + @action(detail=True, methods=["get"], url_path="subscriptions") + def list_subscriptions(self, request, pk=None): + qs = EventSubscription.objects.filter(integration_id=pk) + serializer = EventSubscriptionSerializer(qs, many=True) + return Response(serializer.data) + + @action(detail=True, methods=["put"], url_path=r"subscriptions/set") + def set_subscriptions(self, request, pk=None): + """ + Replace the integration's subscriptions with the provided list. + Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...] + Any existing subscriptions not in the list will be deleted; missing ones will be created/updated. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response( + {"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND + ) + + data = request.data + if not isinstance(data, list): + return Response( + {"detail": "Expected a list of subscriptions"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate incoming items using serializer (without integration field) + # We'll attach the integration explicitly + valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES) + incoming = [] + for item in data: + if not isinstance(item, dict): + return Response( + {"detail": "Each subscription must be an object"}, + status=status.HTTP_400_BAD_REQUEST, + ) + event = item.get("event") + if event not in valid_events: + return Response( + {"detail": f"Invalid event: {event}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming.append( + { + "event": event, + "enabled": bool(item.get("enabled", True)), + "payload_template": item.get("payload_template"), + } + ) + + incoming_events = {s["event"] for s in incoming} + + # Delete subscriptions that are no longer present + EventSubscription.objects.filter(integration=integration).exclude( + event__in=incoming_events + ).delete() + + # Upsert incoming subscriptions + updated = [] + for sub in incoming: + obj, _created = EventSubscription.objects.update_or_create( + integration=integration, + event=sub["event"], + defaults={ + "enabled": sub["enabled"], + "payload_template": sub.get("payload_template"), + }, + ) + updated.append(obj) + + serializer = EventSubscriptionSerializer(updated, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class EventSubscriptionViewSet(viewsets.ModelViewSet): + queryset = EventSubscription.objects.all() + serializer_class = EventSubscriptionSerializer + + +class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet): + queryset = DeliveryLog.objects.all().order_by("-created_at") + serializer_class = DeliveryLogSerializer + filter_backends = [DjangoFilterBackend] + + # Support server-side pagination with page_size query param + class ConnectLogsPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 250 + + pagination_class = ConnectLogsPagination + + def get_queryset(self): + qs = super().get_queryset() + + # Optional filters: integration id and type + integration_id = self.request.query_params.get("integration") + if integration_id: + qs = qs.filter(subscription__integration_id=integration_id) + + integration_type = self.request.query_params.get("type") + if integration_type: + qs = qs.filter(subscription__integration__type=integration_type) + + return qs diff --git a/apps/connect/apps.py b/apps/connect/apps.py new file mode 100644 index 00000000..db063fa4 --- /dev/null +++ b/apps/connect/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ConnectConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.connect' + verbose_name = "Connect Integrations" + label = 'dispatcharr_connect' diff --git a/apps/connect/handlers/__init__.py b/apps/connect/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/api.py b/apps/connect/handlers/api.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/base.py b/apps/connect/handlers/base.py new file mode 100644 index 00000000..d61b7c53 --- /dev/null +++ b/apps/connect/handlers/base.py @@ -0,0 +1,12 @@ +# connect/handlers/base.py +import abc + +class IntegrationHandler(abc.ABC): + def __init__(self, integration, subscription, payload): + self.integration = integration + self.subscription = subscription + self.payload = payload + + @abc.abstractmethod + def execute(self): + pass diff --git a/apps/connect/handlers/script.py b/apps/connect/handlers/script.py new file mode 100644 index 00000000..3fa8215b --- /dev/null +++ b/apps/connect/handlers/script.py @@ -0,0 +1,28 @@ +# connect/handlers/script.py +import os +import subprocess +from .base import IntegrationHandler + +class ScriptHandler(IntegrationHandler): + def execute(self): + script_path = self.integration.config.get("path") + + # Build environment variables from payload (prefixed for clarity) + env = os.environ.copy() + for key, value in (self.payload or {}).items(): + # Convert keys to upper snake case and prefix + env_key = f"DISPATCHARR_{str(key).upper()}" + env[env_key] = str(value) if value is not None else "" + + result = subprocess.run( + [script_path], + capture_output=True, + text=True, + env=env, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "success": result.returncode == 0, + } diff --git a/apps/connect/handlers/webhook.py b/apps/connect/handlers/webhook.py new file mode 100644 index 00000000..ccca530a --- /dev/null +++ b/apps/connect/handlers/webhook.py @@ -0,0 +1,10 @@ +# connect/handlers/webhook.py +import requests +from .base import IntegrationHandler + +class WebhookHandler(IntegrationHandler): + def execute(self): + url = self.integration.config.get("url") + headers = self.integration.config.get("headers", {}) + response = requests.post(url, json=self.payload, headers=headers, timeout=10) + return {"status_code": response.status_code, "body": response.text, "success": response.ok} diff --git a/apps/connect/migrations/0001_initial.py b/apps/connect/migrations/0001_initial.py new file mode 100644 index 00000000..cd8f6b42 --- /dev/null +++ b/apps/connect/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-01-27 21:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EventSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)), + ('enabled', models.BooleanField(default=True)), + ('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)), + ], + ), + migrations.CreateModel( + name='Integration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)), + ('config', models.JSONField(default=dict)), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='DeliveryLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)), + ('request_payload', models.JSONField(blank=True, default=dict)), + ('response_payload', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')), + ], + ), + migrations.AddField( + model_name='eventsubscription', + name='integration', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'), + ), + ] diff --git a/apps/connect/migrations/__init__.py b/apps/connect/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/models.py b/apps/connect/models.py new file mode 100644 index 00000000..99e89ac8 --- /dev/null +++ b/apps/connect/models.py @@ -0,0 +1,36 @@ +from django.db import models + + +class Integration(models.Model): + TYPE_CHOICES = [ + ("webhook", "Webhook"), + ("api", "API"), + ("script", "Custom Script"), + ] + name = models.CharField(max_length=255) + type = models.CharField(max_length=50, choices=TYPE_CHOICES) + config = models.JSONField(default=dict) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class EventSubscription(models.Model): + EVENT_CHOICES = [ + ("channel_start", "Channel Started"), + ("channel_stop", "Channel Stopped"), + ("movie_added", "Movie Added"), + ("series_added", "Series Added"), + ("download_complete", "Download Complete"), + ] + event = models.CharField(max_length=100, choices=EVENT_CHOICES) + integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions") + enabled = models.BooleanField(default=True) + payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload") + +class DeliveryLog(models.Model): + subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs") + status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")]) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/connect/serializers.py b/apps/connect/serializers.py new file mode 100644 index 00000000..859295f3 --- /dev/null +++ b/apps/connect/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers +from .models import Integration, EventSubscription, DeliveryLog + + +class EventSubscriptionSerializer(serializers.ModelSerializer): + class Meta: + model = EventSubscription + fields = [ + "id", + "event", + "enabled", + "payload_template", + "integration", + ] + + +class IntegrationSerializer(serializers.ModelSerializer): + subscriptions = EventSubscriptionSerializer(many=True, read_only=True) + + class Meta: + model = Integration + fields = [ + "id", + "name", + "type", + "config", + "enabled", + "created_at", + "subscriptions", + ] + + +class DeliveryLogSerializer(serializers.ModelSerializer): + subscription = EventSubscriptionSerializer(read_only=True) + + class Meta: + model = DeliveryLog + fields = [ + "id", + "subscription", + "status", + "request_payload", + "response_payload", + "error_message", + "created_at", + ] diff --git a/apps/connect/utils.py b/apps/connect/utils.py new file mode 100644 index 00000000..dcd44132 --- /dev/null +++ b/apps/connect/utils.py @@ -0,0 +1,73 @@ +# connect/utils.py +import logging +from django.template import Template, Context +from .models import EventSubscription, DeliveryLog +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler + +logger = logging.getLogger(__name__) + +HANDLERS = { + "webhook": WebhookHandler, + "script": ScriptHandler, +} + + +def trigger_event(event_name, payload): + logger.debug(f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}") + subscriptions = ( + EventSubscription.objects.filter(event=event_name, enabled=True) + .select_related("integration") + ) + + count = subscriptions.count() + logger.info(f"Found {count} connect subscription(s) for event '{event_name}'") + + for sub in subscriptions: + integration = sub.integration + if not integration.enabled: + logger.debug(f"Skipping disabled integration id={integration.id} name={integration.name}") + continue + + # apply optional payload template + final_payload = payload + if sub.payload_template: + try: + template = Template(sub.payload_template) + rendered = template.render(Context(payload)) + final_payload = {"message": rendered} + except Exception as e: + logger.error(f"Payload template render failed for subscription id={sub.id}: {e}") + final_payload = payload + + handler_cls = HANDLERS.get(integration.type) + if not handler_cls: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=f"No handler for integration type '{integration.type}'", + ) + logger.error(f"No handler for integration type '{integration.type}' (integration id={integration.id})") + continue + + handler = handler_cls(integration, sub, final_payload) + logger.debug(f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}") + + try: + result = handler.execute() + DeliveryLog.objects.create( + subscription=sub, + status="success" if result.get("success") else "failed", + request_payload=final_payload, + response_payload=result, + ) + logger.info(f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'") + except Exception as e: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=str(e), + ) + logger.error(f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}") diff --git a/core/utils.py b/core/utils.py index 5c5a0045..cdd9f0bd 100644 --- a/core/utils.py +++ b/core/utils.py @@ -415,6 +415,79 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details): details=details ) + # Trigger connect integrations for specific events + if event_type in ("channel_start", "channel_stop"): + try: + from apps.connect.utils import trigger_event + from apps.channels.models import Channel, Stream + from core.models import StreamProfile + from core.utils import RedisClient + + payload = {} + + channel_obj = None + if channel_id: + try: + channel_obj = Channel.objects.get(uuid=channel_id) + payload["channel_name"] = channel_obj.name + except Exception: + payload["channel_name"] = channel_name or None + else: + payload["channel_name"] = channel_name or None + + # Resolve current stream info + stream_id = details.get("stream_id") + stream_obj = None + if not stream_id and channel_obj: + try: + redis = RedisClient.get_client() + sid = redis.get(f"channel_stream:{channel_obj.id}") + if sid: + stream_id = int(sid) + except Exception: + stream_id = None + + if stream_id: + try: + stream_obj = Stream.objects.get(id=stream_id) + except Exception: + stream_obj = None + + # Populate stream details + payload["stream_name"] = getattr(stream_obj, "name", None) + payload["stream_url"] = getattr(stream_obj, "url", None) + + # Channel URL: use stream URL as best-effort + payload["channel_url"] = payload.get("stream_url") + + # Provider name from M3U account + provider_name = None + try: + if stream_obj and stream_obj.m3u_account: + provider_name = stream_obj.m3u_account.name + except Exception: + provider_name = None + payload["provider_name"] = provider_name + + # Profile used + profile_used = None + try: + if stream_id: + redis = RedisClient.get_client() + pid = redis.get(f"stream_profile:{stream_id}") + if pid: + profile = StreamProfile.objects.filter(id=int(pid)).first() + profile_used = profile.name if profile else None + except Exception: + profile_used = None + + payload["profile_used"] = profile_used + + trigger_event(event_type, payload) + except Exception as e: + # Don't fail main path if connect dispatch fails + pass + # Get max events from settings (default 100) try: from .models import CoreSettings @@ -509,4 +582,3 @@ def send_notification_dismissed(notification_key): ) except Exception as e: logger.error(f"Failed to send notification dismissed event: {e}") - diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index cfc499da..7afa75c5 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -31,6 +31,7 @@ INSTALLED_APPS = [ "apps.proxy.apps.ProxyConfig", "apps.proxy.ts_proxy", "apps.vod.apps.VODConfig", + "apps.connect.apps.ConnectConfig", "core", "daphne", "drf_spectacular", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3869740e..86e8a7d7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,8 @@ import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; import PluginsPage from './pages/Plugins'; +import ConnectPage from './pages/Connect'; +import ConnectLogsPage from './pages/ConnectLogs'; import Users from './pages/Users'; import LogosPage from './pages/Logos'; import VODsPage from './pages/VODs'; @@ -152,6 +154,11 @@ const App = () => { } /> } /> } /> + } /> + } + /> } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 9cc5494c..fe8bf275 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -12,6 +12,7 @@ import { notifications } from '@mantine/notifications'; import useChannelsTableStore from './store/channelsTable'; import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; +import useConnectStore from './store/connect'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV @@ -171,7 +172,7 @@ export default class API { static async logout() { return await request(`${host}/api/accounts/auth/logout/`, { - auth: true, // Send JWT token so backend can identify the user + auth: true, // Send JWT token so backend can identify the user method: 'POST', }); } @@ -261,15 +262,11 @@ export default class API { API.lastQueryParams = newParams; const [response, ids] = await Promise.all([ - request( - `${host}/api/channels/channels/?${newParams.toString()}` - ), + request(`${host}/api/channels/channels/?${newParams.toString()}`), API.getAllChannelIds(newParams), ]); - useChannelsTableStore - .getState() - .queryChannels(response, newParams); + useChannelsTableStore.getState().queryChannels(response, newParams); useChannelsTableStore.getState().setAllQueryIds(ids); return response; @@ -390,7 +387,8 @@ export default class API { channelData.channel_number === '' || channelData.channel_number === null || channelData.channel_number === undefined || - (typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '') + (typeof channelData.channel_number === 'string' && + channelData.channel_number.trim() === '') ) { delete channelData.channel_number; } @@ -719,7 +717,11 @@ export default class API { } } - static async createChannelsFromStreamsAsync(streamIds, channelProfileIds = null, startingChannelNumber = null) { + static async createChannelsFromStreamsAsync( + streamIds, + channelProfileIds = null, + startingChannelNumber = null + ) { try { const requestBody = { stream_ids: streamIds, @@ -815,15 +817,11 @@ export default class API { try { const [response, ids] = await Promise.all([ - request( - `${host}/api/channels/streams/?${params.toString()}` - ), + request(`${host}/api/channels/streams/?${params.toString()}`), API.getAllStreamIds(params), ]); - useStreamsTableStore - .getState() - .queryStreams(response, params); + useStreamsTableStore.getState().queryStreams(response, params); useStreamsTableStore.getState().setAllQueryIds(ids); return response; @@ -1179,13 +1177,10 @@ export default class API { static async getCurrentPrograms(channelIds = null) { try { - const response = await request( - `${host}/api/epg/current-programs/`, - { - method: 'POST', - body: { channel_ids: channelIds }, - } - ); + const response = await request(`${host}/api/epg/current-programs/`, { + method: 'POST', + body: { channel_ids: channelIds }, + }); return response; } catch (e) { @@ -1318,9 +1313,15 @@ export default class API { errorNotification('Failed to retrieve timezones', e); // Return fallback data instead of throwing return { - timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'], + timezones: [ + 'UTC', + 'US/Eastern', + 'US/Central', + 'US/Mountain', + 'US/Pacific', + ], grouped: {}, - count: 5 + count: 5, }; } } @@ -1444,16 +1445,22 @@ export default class API { static async refreshAccountInfo(profileId) { try { - const response = await request(`${host}/api/m3u/refresh-account-info/${profileId}/`, { - method: 'POST', - }); + const response = await request( + `${host}/api/m3u/refresh-account-info/${profileId}/`, + { + method: 'POST', + } + ); return response; } catch (e) { // If it's a structured error response, return it instead of throwing if (e.body && typeof e.body === 'object') { return e.body; } - errorNotification(`Failed to refresh account info for profile ${profileId}`, e); + errorNotification( + `Failed to refresh account info for profile ${profileId}`, + e + ); throw e; } } @@ -1580,7 +1587,11 @@ export default class API { }); // Wait for the task to complete using token for auth - const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token); + const result = await API.waitForBackupTask( + response.task_id, + onProgress, + response.task_token + ); return result; } catch (e) { errorNotification('Failed to create backup', e); @@ -1593,13 +1604,10 @@ export default class API { const formData = new FormData(); formData.append('file', file); - const response = await request( - `${host}/api/backups/upload/`, - { - method: 'POST', - body: formData, - } - ); + const response = await request(`${host}/api/backups/upload/`, { + method: 'POST', + body: formData, + }); return response; } catch (e) { errorNotification('Failed to upload backup', e); @@ -1622,7 +1630,9 @@ export default class API { static async getDownloadToken(filename) { // Get a download token from the server try { - const response = await request(`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`); + const response = await request( + `${host}/api/backups/${encodeURIComponent(filename)}/download-token/` + ); return response.token; } catch (e) { throw e; @@ -1666,7 +1676,11 @@ export default class API { // Wait for the task to complete using token for auth // Token-based auth allows status polling even after DB restore invalidates user sessions - const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token); + const result = await API.waitForBackupTask( + response.task_id, + onProgress, + response.task_token + ); return result; } catch (e) { errorNotification('Failed to restore backup', e); @@ -1741,17 +1755,27 @@ export default class API { return response; } catch (e) { // Show only the concise error message for plugin import - const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin'; - notifications.show({ title: 'Import failed', message: msg, color: 'red' }); + const msg = + (e?.body && (e.body.error || e.body.detail)) || + e?.message || + 'Failed to import plugin'; + notifications.show({ + title: 'Import failed', + message: msg, + color: 'red', + }); throw e; } } static async deletePlugin(key) { try { - const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, { - method: 'DELETE', - }); + const response = await request( + `${host}/api/plugins/plugins/${key}/delete/`, + { + method: 'DELETE', + } + ); return response; } catch (e) { errorNotification('Failed to delete plugin', e); @@ -1776,10 +1800,13 @@ export default class API { static async runPluginAction(key, action, params = {}) { try { - const response = await request(`${host}/api/plugins/plugins/${key}/run/`, { - method: 'POST', - body: { action, params }, - }); + const response = await request( + `${host}/api/plugins/plugins/${key}/run/`, + { + method: 'POST', + body: { action, params }, + } + ); return response; } catch (e) { errorNotification('Failed to run plugin action', e); @@ -1788,10 +1815,13 @@ export default class API { static async setPluginEnabled(key, enabled) { try { - const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, { - method: 'POST', - body: { enabled }, - }); + const response = await request( + `${host}/api/plugins/plugins/${key}/enabled/`, + { + method: 'POST', + body: { enabled }, + } + ); return response; } catch (e) { errorNotification('Failed to update plugin enabled state', e); @@ -1973,7 +2003,7 @@ export default class API { if (!logoIds || logoIds.length === 0) return []; const params = new URLSearchParams(); - logoIds.forEach(id => params.append('ids', id)); + logoIds.forEach((id) => params.append('ids', id)); // Disable pagination for ID-based queries to get all matching logos params.append('no_pagination', 'true'); @@ -2440,10 +2470,13 @@ export default class API { static async updateRecurringRule(ruleId, payload) { try { - const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, { - method: 'PATCH', - body: payload, - }); + const response = await request( + `${host}/api/channels/recurring-rules/${ruleId}/`, + { + method: 'PATCH', + body: payload, + } + ); return response; } catch (e) { errorNotification(`Failed to update recurring rule ${ruleId}`, e); @@ -2462,9 +2495,13 @@ export default class API { static async deleteRecording(id) { try { - await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' }); + await request(`${host}/api/channels/recordings/${id}/`, { + method: 'DELETE', + }); // Optimistically remove locally for instant UI update - try { useChannelsStore.getState().removeRecording(id); } catch {} + try { + useChannelsStore.getState().removeRecording(id); + } catch {} } catch (e) { errorNotification(`Failed to delete recording ${id}`, e); } @@ -2472,9 +2509,12 @@ export default class API { static async runComskip(recordingId) { try { - const resp = await request(`${host}/api/channels/recordings/${recordingId}/comskip/`, { - method: 'POST', - }); + const resp = await request( + `${host}/api/channels/recordings/${recordingId}/comskip/`, + { + method: 'POST', + } + ); // Refresh recordings list to reflect comskip status when done later // This endpoint just queues the task; the websocket/refresh will update eventually return resp; @@ -2512,7 +2552,9 @@ export default class API { static async deleteSeriesRule(tvgId) { try { const encodedTvgId = encodeURIComponent(tvgId); - await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { method: 'DELETE' }); + await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { + method: 'DELETE', + }); notifications.show({ title: 'Series rule removed' }); } catch (e) { errorNotification('Failed to remove series rule', e); @@ -2522,9 +2564,12 @@ export default class API { static async deleteAllUpcomingRecordings() { try { - const resp = await request(`${host}/api/channels/recordings/bulk-delete-upcoming/`, { - method: 'POST', - }); + const resp = await request( + `${host}/api/channels/recordings/bulk-delete-upcoming/`, + { + method: 'POST', + } + ); notifications.show({ title: `Removed ${resp.removed || 0} upcoming` }); useChannelsStore.getState().fetchRecordings(); return resp; @@ -2545,12 +2590,19 @@ export default class API { } } - static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title' }) { + static async bulkRemoveSeriesRecordings({ + tvg_id, + title = null, + scope = 'title', + }) { try { - const resp = await request(`${host}/api/channels/series-rules/bulk-remove/`, { - method: 'POST', - body: { tvg_id, title, scope }, - }); + const resp = await request( + `${host}/api/channels/series-rules/bulk-remove/`, + { + method: 'POST', + body: { tvg_id, title, scope }, + } + ); notifications.show({ title: `Removed ${resp.removed || 0} scheduled` }); return resp; } catch (e) { @@ -2712,13 +2764,10 @@ export default class API { try { // Use POST for large ID lists to avoid URL length limitations if (ids.length > 50) { - const response = await request( - `${host}/api/channels/streams/by-ids/`, - { - method: 'POST', - body: { ids }, - } - ); + const response = await request(`${host}/api/channels/streams/by-ids/`, { + method: 'POST', + body: { ids }, + }); return response; } else { // Use GET for small ID lists for backward compatibility @@ -2744,8 +2793,9 @@ export default class API { return response; } catch (e) { // Don't show error notification for "Invalid page" errors as they're handled gracefully - const isInvalidPage = e.body?.detail?.includes('Invalid page') || - e.message?.includes('Invalid page'); + const isInvalidPage = + e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve movies', e); @@ -2762,8 +2812,9 @@ export default class API { return response; } catch (e) { // Don't show error notification for "Invalid page" errors as they're handled gracefully - const isInvalidPage = e.body?.detail?.includes('Invalid page') || - e.message?.includes('Invalid page'); + const isInvalidPage = + e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve series', e); @@ -2774,7 +2825,10 @@ export default class API { static async getAllContent(params = new URLSearchParams()) { try { - console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`); + console.log( + 'Calling getAllContent with URL:', + `${host}/api/vod/all/?${params.toString()}` + ); const response = await request( `${host}/api/vod/all/?${params.toString()}` ); @@ -2787,8 +2841,9 @@ export default class API { console.error('Error message:', e.message); // Don't show error notification for "Invalid page" errors as they're handled gracefully - const isInvalidPage = e.body?.detail?.includes('Invalid page') || - e.message?.includes('Invalid page'); + const isInvalidPage = + e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve content', e); @@ -2911,9 +2966,8 @@ export default class API { ); // Update the store with fetched notifications - const { default: useNotificationsStore } = await import( - './store/notifications' - ); + const { default: useNotificationsStore } = + await import('./store/notifications'); useNotificationsStore.getState().setNotifications(response.notifications); return response; @@ -2928,9 +2982,8 @@ export default class API { const response = await request(`${host}/api/core/notifications/count/`); // Update the store with the count - const { default: useNotificationsStore } = await import( - './store/notifications' - ); + const { default: useNotificationsStore } = + await import('./store/notifications'); useNotificationsStore.getState().setUnreadCount(response.unread_count); return response; @@ -2962,10 +3015,11 @@ export default class API { ); // Update the store - const { default: useNotificationsStore } = await import( - './store/notifications' - ); - useNotificationsStore.getState().dismissNotification(response.notification_key); + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore + .getState() + .dismissNotification(response.notification_key); return response; } catch (e) { @@ -2984,9 +3038,8 @@ export default class API { ); // Update the store - const { default: useNotificationsStore } = await import( - './store/notifications' - ); + const { default: useNotificationsStore } = + await import('./store/notifications'); useNotificationsStore.getState().dismissAllNotifications(); return response; @@ -2994,4 +3047,124 @@ export default class API { errorNotification('Failed to dismiss all notifications', e); } } + + static async getConnectIntegrations() { + try { + return await request(`${host}/api/connect/integrations/`); + } catch (e) { + errorNotification('Failed to fetch connect integrations', e); + } + } + + static async createConnectIntegration(values) { + try { + const response = await request(`${host}/api/connect/integrations/`, { + method: 'POST', + body: values, + }); + + useConnectStore.getState().addIntegration(response); + + return response; + } catch (e) { + errorNotification('Failed to create integration', e); + } + } + + static async updateConnectIntegration(id, values) { + try { + const response = await request( + `${host}/api/connect/integrations/${id}/`, + { + method: 'PUT', + body: values, + } + ); + + if (response.id) { + useConnectStore.getState().updateIntegration(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update integration', e); + } + } + + static async deleteConnectIntegration(id) { + try { + await request(`${host}/api/connect/integrations/${id}/`, { + method: 'DELETE', + }); + + useConnectStore.getState().removeIntegration(id); + + return true; + } catch (e) { + errorNotification('Failed to delete integration', e); + throw e; + } + } + + static async createConnectSubscription(values) { + try { + await request(`${host}/api/connect/subscriptions/`, { + method: 'POST', + body: values, + }); + + return true; + } catch (e) { + errorNotification('Failed to create subscription', e); + } + } + + static async listConnectSubscriptions(integrationId) { + try { + return await request( + `${host}/api/connect/integrations/${integrationId}/subscriptions/` + ); + } catch (e) { + errorNotification('Failed to fetch subscriptions', e); + } + } + + static async setConnectSubscriptions(integrationId, subscriptions) { + // subscriptions: [{ event, enabled, payload_template }] + console.log(subscriptions); + try { + const response = await request( + `${host}/api/connect/integrations/${integrationId}/subscriptions/set/`, + { + method: 'PUT', + body: subscriptions, + } + ); + + useConnectStore + .getState() + .updateIntegrationSubscriptions(integrationId, response); + + return true; + } catch (e) { + errorNotification('Failed to set subscriptions', e); + throw e; + } + } + + static async getConnectLogs(params = {}) { + try { + const search = new URLSearchParams(); + if (params.page) search.set('page', params.page); + if (params.page_size) search.set('page_size', params.page_size); + if (params.type) search.set('type', params.type); + if (params.integration) search.set('integration', params.integration); + + return await request( + `${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}` + ); + } catch (e) { + errorNotification('Failed to fetch connect logs', e); + } + } } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 6c997cfe..4598427d 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -15,6 +15,11 @@ import { LogOut, User, FileImage, + Webhook, + Logs, + ChevronDown, + ChevronRight, + MonitorCog, } from 'lucide-react'; import { Avatar, @@ -27,6 +32,7 @@ import { TextInput, ActionIcon, Menu, + ScrollArea, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import logo from '../images/logo.png'; @@ -70,6 +76,74 @@ const NavLink = ({ item, isActive, collapsed }) => { ); }; +function NavGroup({ label, icon, paths, location, collapsed }) { + const [open, setOpen] = useState(() => + location.pathname.startsWith('/connect') + ); + + const parentActive = paths + .map((path) => path.path) + .includes(location.pathname); + + return ( + + setOpen((o) => !o)} + className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`} + style={{ width: '100%' }} + > + {icon} + {!collapsed && ( + + + {label} + + + + {open ? : } + + + )} + + + {open && ( + + + {paths.map((child) => { + const active = location.pathname === child.path; + return ( + + + + ); + })} + + + )} + + ); +} + const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const location = useLocation(); @@ -111,19 +185,41 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { { label: 'Stats', icon: , path: '/stats' }, { label: 'Plugins', icon: , path: '/plugins' }, { - label: 'Users', - icon: , - path: '/users', - }, - { - label: 'Logo Manager', - icon: , - path: '/logos', + label: 'Connect', + icon: , + paths: [ + { + label: 'Connections', + icon: , + path: '/connect', + }, + { + label: 'Logs', + icon: , + path: '/connect/logs', + }, + ], }, { label: 'Settings', icon: , - path: '/settings', + paths: [ + { + label: 'Users', + icon: , + path: '/users', + }, + { + label: 'Logo Manager', + icon: , + path: '/logos', + }, + { + label: 'System', + icon: , + path: '/settings', + }, + ], }, ] : [ @@ -205,20 +301,44 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {/* Navigation Links */} - - {navItems.map((item) => { - const isActive = location.pathname === item.path; + + + {navItems.map((item) => { + if (item.paths) { + return ( + + ); + } - return ( - - ); - })} - + const isActive = location.pathname === item.path; + + return ( + + ); + })} + + {/* Profile Section */} ({ + value, + label, + }) +); + +const ConnectionForm = ({ connection = null, isOpen, onClose }) => { + const [submitting, setSubmitting] = useState(false); + const [selectedEvents, setSelectedEvents] = useState([]); + + // One-time form + const form = useForm({ + mode: 'controlled', + initialValues: { + name: connection?.name || '', + type: connection?.type || 'webhook', + url: connection?.config?.url || '', + script_path: connection?.config?.path || '', + enabled: connection?.enabled ?? true, + }, + validate: { + name: isNotEmpty('Provide a name'), + type: isNotEmpty('Select a type'), + url: (value, values) => { + if (values.type === 'webhook' && !value.trim()) { + return 'Provide a webhook URL'; + } + return null; + }, + script_path: (value, values) => { + if (values.type === 'script' && !value.trim()) { + return 'Provide a script path'; + } + return null; + }, + }, + }); + + useEffect(() => { + if (connection) { + const values = { + name: connection.name, + type: connection.type, + url: connection.config?.url, + script_path: connection.config?.path, + enabled: connection.enabled, + }; + form.setValues(values); + setSelectedEvents( + connection.subscriptions.reduce((acc, sub) => { + if (sub.enabled) acc.push(sub.event); + return acc; + }, []) + ); + } else { + form.reset(); + } + }, [connection]); + + const handleClose = () => { + onClose?.(); + }; + + const onSubmit = async (values) => { + console.log(values); + try { + setSubmitting(true); + const config = + values.type === 'webhook' + ? { url: values.url } + : { path: values.script_path }; + + if (connection) { + await API.updateConnectIntegration(connection.id, { + name: values.name, + type: values.type, + config, + enabled: values.enabled, + }); + } else { + connection = await API.createConnectIntegration({ + name: values.name, + type: values.type, + config, + enabled: values.enabled, + }); + } + + await API.setConnectSubscriptions( + connection.id, + Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({ + event, + enabled: selectedEvents.includes(event), + })) + ); + handleClose(); + } catch (error) { + console.error('Failed to create connection', error); + } finally { + setSubmitting(false); + } + }; + + const toggleEvent = (event) => { + setSelectedEvents((prev) => + prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event] + ); + }; + + if (!isOpen) return null; + + return ( + + +
+ + + setFilters((prev) => ({ ...prev, type: value })) + } + style={{ width: 150 }} + /> + Integration + handleScheduleChange('frequency', value)} + data={[ + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + ]} + disabled={!schedule.enabled} + /> + {schedule.frequency === 'weekly' && ( + { + const minute = displayTime + ? displayTime.split(':')[1] + : '00'; + handleTimeChange12h(`${value}:${minute}`, null); + }} + data={Array.from({ length: 12 }, (_, i) => ({ + value: String(i + 1), + label: String(i + 1), + }))} + disabled={!schedule.enabled} + searchable + /> + handleTimeChange12h(null, value)} + data={[ + { value: 'AM', label: 'AM' }, + { value: 'PM', label: 'PM' }, + ]} + disabled={!schedule.enabled} + /> + + ) : ( + <> + { + const hour = schedule.time + ? schedule.time.split(':')[0] + : '00'; + handleTimeChange24h(`${hour}:${value}`); + }} + data={Array.from({ length: 60 }, (_, i) => ({ + value: String(i).padStart(2, '0'), + label: String(i).padStart(2, '0'), + }))} + disabled={!schedule.enabled} + searchable + /> + + )} + + + {scheduleLoading ? ( ) : ( <> - {advancedMode ? ( - <> - - - handleScheduleChange( - 'cron_expression', - e.currentTarget.value - ) - } - placeholder="0 3 * * *" - description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)" - disabled={!schedule.enabled} - error={cronError} - /> - - Examples:
0 3 * * * - Every day at 3:00 - AM -
0 2 * * 0 - Every Sunday at 2:00 AM -
0 */6 * * * - Every 6 hours -
30 14 1 * * - 1st of every month at - 2:30 PM -
-
- - - handleScheduleChange('retention_count', value || 0) - } - min={0} - disabled={!schedule.enabled} - /> - - - - ) : ( - - - - handleScheduleChange('day_of_week', parseInt(value, 10)) - } - data={DAYS_OF_WEEK} - disabled={!schedule.enabled} - /> - )} - {is12Hour ? ( - <> - { - const hour = displayTime - ? displayTime.split(':')[0] - : '12'; - handleTimeChange12h(`${hour}:${value}`, null); - }} - data={Array.from({ length: 60 }, (_, i) => ({ - value: String(i).padStart(2, '0'), - label: String(i).padStart(2, '0'), - }))} - disabled={!schedule.enabled} - searchable - /> - { - const minute = schedule.time - ? schedule.time.split(':')[1] - : '00'; - handleTimeChange24h(`${value}:${minute}`); - }} - data={Array.from({ length: 24 }, (_, i) => ({ - value: String(i).padStart(2, '0'), - label: String(i).padStart(2, '0'), - }))} - disabled={!schedule.enabled} - searchable - /> - } + /> + + {frequency !== 'hourly' && ( + } + /> + )} + + } + /> + + {frequency === 'weekly' && ( + { + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === group.channel_group + ) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + channel_numbering_mode: value || 'fixed', + }, + }; + } + return state; + }) + ); + }} + data={[ + { + value: 'fixed', + label: 'Fixed Start Number', + }, + { + value: 'provider', + label: 'Use Provider Number', + }, + { + value: 'next_available', + label: 'Next Available', + }, + ]} + size="xs" + /> + + + {(!group.custom_properties?.channel_numbering_mode || + group.custom_properties?.channel_numbering_mode === + 'fixed') && ( + + updateChannelStart(group.channel_group, value) + } + min={1} + step={1} + size="xs" + precision={0} + /> + )} + + {group.custom_properties?.channel_numbering_mode === + 'provider' && ( + { + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === group.channel_group + ) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + channel_numbering_fallback: value || 1, + }, + }; + } + return state; + }) + ); + }} + min={1} + step={1} + size="xs" + precision={0} + /> + )} {/* Auto Channel Sync Options Multi-Select */} Date: Fri, 13 Feb 2026 15:07:24 -0600 Subject: [PATCH 14/89] Bug Fix: Fixes a bug with auto channel sync where channel numbers could be duplicated if multiple groups overlapped. --- apps/m3u/tasks.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 80880671..0a1d4fc4 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1648,6 +1648,13 @@ def sync_auto_channels(account_id, scan_start_time=None): channels_updated = 0 channels_deleted = 0 + # Get all channel numbers that are already in use by other channels (not auto-created by this account) + used_numbers = set( + Channel.objects.exclude( + auto_created=True, auto_created_by=account + ).values_list("channel_number", flat=True) + ) + for group_relation in auto_sync_groups: channel_group = group_relation.channel_group start_number = group_relation.auto_sync_channel_start or 1.0 @@ -1841,13 +1848,6 @@ def sync_auto_channels(account_id, scan_start_time=None): channels_to_renumber = [] temp_channel_number = start_number - # Get all channel numbers that are already in use by other channels (not auto-created by this account) - used_numbers = set( - Channel.objects.exclude( - auto_created=True, auto_created_by=account - ).values_list("channel_number", flat=True) - ) - for stream in current_streams: if stream.id in existing_channel_map: channel = existing_channel_map[stream.id] From 5ed8b7a954dff84d7fd9983eab52e7782254a8ba Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 13 Feb 2026 15:39:27 -0600 Subject: [PATCH 15/89] changelog: Update changelog for auto channel sync changes. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c768e1e..3ef85239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups: + - **Fixed Start Number** (default): Start at a specified number and increment sequentially + - **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) + ### Fixed +- 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) ## [0.19.0] - 2026-02-10 From 4842bb87fa793578109c2f0ad59a8c7810b65cc6 Mon Sep 17 00:00:00 2001 From: None Date: Fri, 13 Feb 2026 19:37:06 -0600 Subject: [PATCH 16/89] 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 45d0f180fe34dc05216253b66bf20be823a4b072 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 15 Feb 2026 07:36:08 -0500 Subject: [PATCH 17/89] added endpoint for testing connections with a dummy payload --- apps/connect/api_views.py | 67 +++++++++++++++++++++++++++++++++++-- apps/connect/serializers.py | 22 ++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py index 1402de04..de3903c0 100644 --- a/apps/connect/api_views.py +++ b/apps/connect/api_views.py @@ -3,6 +3,7 @@ from rest_framework.pagination import PageNumberPagination from django_filters.rest_framework import DjangoFilterBackend from rest_framework.response import Response from rest_framework.decorators import action +from django.utils import timezone from .models import Integration, EventSubscription, DeliveryLog from .serializers import ( IntegrationSerializer, @@ -12,7 +13,10 @@ from .serializers import ( from apps.accounts.permissions import ( Authenticated, permission_classes_by_action, + IsAdmin, ) +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler class IntegrationViewSet(viewsets.ModelViewSet): @@ -21,9 +25,11 @@ class IntegrationViewSet(viewsets.ModelViewSet): def get_permissions(self): try: - return [perm() for perm in permission_classes_by_action[self.action]] + perms = permission_classes_by_action[self.action] except KeyError: - return [Authenticated()] + # Respect view/action-specific permission_classes if provided; fallback to Authenticated + perms = getattr(self, "permission_classes", [Authenticated]) + return [perm() for perm in perms] @action(detail=True, methods=["get"], url_path="subscriptions") def list_subscriptions(self, request, pk=None): @@ -99,6 +105,63 @@ class IntegrationViewSet(viewsets.ModelViewSet): serializer = EventSubscriptionSerializer(updated, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin]) + def test(self, request, pk=None): + """ + Execute a saved integration (connect) with a dummy payload to verify configuration. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND) + + # Build a dummy payload similar to system events + now = timezone.now().isoformat() + dummy_payload = { + "event": "test", + "timestamp": now, + "channel_name": "Test Channel", + "stream_name": "Test Stream", + "stream_url": "http://example.com/stream.m3u8", + "channel_url": "http://example.com/stream.m3u8", + "provider_name": "Test Provider", + "profile_used": "Default", + "test": True, + } + + # Choose handler based on saved type + if integration.type == "webhook": + handler = WebhookHandler(integration, None, dummy_payload) + elif integration.type == "script": + handler = ScriptHandler(integration, None, dummy_payload) + else: + return Response( + {"success": False, "error": f"Unsupported integration type: {integration.type}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + result = handler.execute() + return Response( + { + "success": bool(result.get("success")), + "type": integration.type, + "request_payload": dummy_payload, + "result": result, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + { + "success": False, + "type": integration.type, + "request_payload": dummy_payload, + "error": str(e), + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + class EventSubscriptionViewSet(viewsets.ModelViewSet): queryset = EventSubscription.objects.all() diff --git a/apps/connect/serializers.py b/apps/connect/serializers.py index 859295f3..832fc14b 100644 --- a/apps/connect/serializers.py +++ b/apps/connect/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from .models import Integration, EventSubscription, DeliveryLog +import os class EventSubscriptionSerializer(serializers.ModelSerializer): @@ -29,6 +30,27 @@ class IntegrationSerializer(serializers.ModelSerializer): "subscriptions", ] + def validate(self, attrs): + type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None) + config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {}) + + if type == "script": + path = (config or {}).get("path") + if not path or not isinstance(path, str): + raise serializers.ValidationError({"config": "Script config must include a 'path' string"}) + + real_path = os.path.abspath(os.path.realpath(path)) + if not os.path.exists(real_path): + raise serializers.ValidationError({"config": f"Script path does not exist: {path}"}) + elif type == "webhook": + url = (config or {}).get("url") + if not url or not isinstance(url, str): + raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"}) + else: + raise serializers.ValidationError({"type": "Unsupported integration type"}) + + return attrs + class DeliveryLogSerializer(serializers.ModelSerializer): subscription = EventSubscriptionSerializer(read_only=True) From 055d2604ca609e035d63b84283bf9a660bcb3e03 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 15 Feb 2026 07:36:29 -0500 Subject: [PATCH 18/89] better form validation --- frontend/src/components/forms/Connection.jsx | 40 +++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/Connection.jsx b/frontend/src/components/forms/Connection.jsx index d55ee522..e8077d43 100644 --- a/frontend/src/components/forms/Connection.jsx +++ b/frontend/src/components/forms/Connection.jsx @@ -25,6 +25,7 @@ const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map( const ConnectionForm = ({ connection = null, isOpen, onClose }) => { const [submitting, setSubmitting] = useState(false); const [selectedEvents, setSelectedEvents] = useState([]); + const [apiError, setApiError] = useState(''); // One-time form const form = useForm({ @@ -77,6 +78,7 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { }, [connection]); const handleClose = () => { + setApiError(''); onClose?.(); }; @@ -84,6 +86,7 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { console.log(values); try { setSubmitting(true); + setApiError(''); const config = values.type === 'webhook' ? { url: values.url } @@ -114,7 +117,37 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { ); handleClose(); } catch (error) { - console.error('Failed to create connection', error); + console.error('Failed to create/update connection', error); + // Try to map server-side validation errors to form fields + const body = error?.body; + + if (body && typeof body === 'object') { + const fieldErrors = {}; + if (body.name) { + fieldErrors.name = body.name; + } + if (body.type) { + fieldErrors.type = body.type; + } + if (body.config) { + if (values.type === 'webhook') { + fieldErrors.url = msg; + } else { + fieldErrors.script_path = msg; + } + } + + const nonField = body.non_field_errors || body.detail; + if (Object.keys(fieldErrors).length > 0) { + form.setErrors(fieldErrors); + } + if (nonField) setApiError(nonField); + if (!nonField && Object.keys(fieldErrors).length === 0) { + setApiError(body); + } + } else { + setApiError(error?.message || 'Unknown error'); + } } finally { setSubmitting(false); } @@ -132,6 +165,11 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { + {apiError ? ( + + {apiError} + + ) : null} Date: Sun, 15 Feb 2026 07:37:00 -0500 Subject: [PATCH 19/89] hardening of script handling with configurable variables - making script execution more secure --- apps/connect/handlers/script.py | 69 +++++++++++++++++++++++++++++---- dispatcharr/settings.py | 15 +++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/apps/connect/handlers/script.py b/apps/connect/handlers/script.py index 3fa8215b..b6aef79c 100644 --- a/apps/connect/handlers/script.py +++ b/apps/connect/handlers/script.py @@ -1,28 +1,81 @@ # connect/handlers/script.py import os +import stat import subprocess +from django.conf import settings from .base import IntegrationHandler + +def _is_path_allowed(real_path: str) -> bool: + # Ensure path is within one of the allowed directories + for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []): + base_abs = os.path.abspath(base) + os.sep + if real_path.startswith(base_abs): + return True + return False + + class ScriptHandler(IntegrationHandler): def execute(self): - script_path = self.integration.config.get("path") + raw_path = self.integration.config.get("path") + if not raw_path: + raise ValueError("Missing 'path' in integration config") - # Build environment variables from payload (prefixed for clarity) - env = os.environ.copy() + # Resolve and validate path + real_path = os.path.abspath(os.path.realpath(raw_path)) + + if not os.path.exists(real_path): + raise FileNotFoundError(f"Script not found: {real_path}") + + if not _is_path_allowed(real_path): + raise PermissionError( + f"Script path '{real_path}' not within allowed directories: " + f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}" + ) + + if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True): + if not os.access(real_path, os.X_OK): + raise PermissionError(f"Script is not executable: {real_path}") + + if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True): + st = os.stat(real_path) + if st.st_mode & stat.S_IWOTH: + raise PermissionError( + f"Refusing to execute world-writable script: {real_path}" + ) + + # Build a sanitized minimal environment; avoid inheriting secrets + env = { + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } for key, value in (self.payload or {}).items(): - # Convert keys to upper snake case and prefix env_key = f"DISPATCHARR_{str(key).upper()}" - env[env_key] = str(value) if value is not None else "" + env[env_key] = "" if value is None else str(value) + + # Run with a timeout to prevent hanging scripts + timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10) + max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536) result = subprocess.run( - [script_path], + [real_path], capture_output=True, text=True, env=env, + timeout=timeout, + cwd=os.path.dirname(real_path) or None, ) + + # Truncate outputs to avoid excessive memory/logging + stdout = result.stdout or "" + stderr = result.stderr or "" + if len(stdout) > max_out: + stdout = stdout[:max_out] + "... [truncated]" + if len(stderr) > max_out: + stderr = stderr[:max_out] + "... [truncated]" + return { "exit_code": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, + "stdout": stdout, + "stderr": stderr, "success": result.returncode == 0, } diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 8fba3143..1fbf566b 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -412,3 +412,18 @@ LOGGING = { "level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO' }, } + +# Connect script execution safety settings +# Allowed base directories for custom scripts; real paths must be inside +_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/plugins") +CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p] + +# Max execution time (seconds) for scripts +CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10")) + +# Truncate stdout/stderr to this many characters to avoid large outputs +CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536")) + +# Require executable bit and disallow world-writable files +CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True +CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True From 968551c1a6e712b6142ac4f865ab53831aa393f8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 16 Feb 2026 13:45:57 -0600 Subject: [PATCH 20/89] Enhancement: 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) --- CHANGELOG.md | 4 ++++ apps/output/views.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6718253a..688c11bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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) +### Changed + +- 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 - 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. diff --git a/apps/output/views.py b/apps/output/views.py index 1d53237a..21377489 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -183,8 +183,9 @@ def generate_m3u(request, profile_name=None, user=None): # Check if this is an XC API request (has username/password in GET params and user is authenticated) xc_username = request.GET.get('username') xc_password = request.GET.get('password') + is_xc_request = user is not None and xc_username and xc_password - if user is not None and xc_username and xc_password: + if is_xc_request: # This is an XC API request - use XC-style EPG URL base_url = build_absolute_uri_with_port(request, '') epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" @@ -254,8 +255,12 @@ def generate_m3u(request, profile_name=None, user=None): f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' ) - # Determine the stream URL based on the direct parameter - if use_direct_urls: + # Determine the stream URL based on request type + if is_xc_request: + # XC API request - use XC-style stream URL format + base_url = build_absolute_uri_with_port(request, '') + stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" + elif use_direct_urls: # Try to get the first stream's direct URL first_stream = channel.streams.order_by('channelstream__order').first() if first_stream and first_stream.url: From 50918b45fb2830694ef30de71038bb74854690a2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 16 Feb 2026 15:34:09 -0600 Subject: [PATCH 21/89] changelog: Update changelog for webhooks. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688c11bd..320cf90b 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 +- 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: - **Fixed Start Number** (default): Start at a specified number and increment sequentially From 74cc9bd376d1ed1ffea570ce3c1971cd7fde8471 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 16 Feb 2026 16:10:51 -0600 Subject: [PATCH 22/89] 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 23/89] 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 24/89] 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 25/89] 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); + }} + /> +