diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab8110f..af516928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.2] - 2026-03-03 + +### Security + +- Updated frontend npm dependencies to resolve 2 high-severity vulnerabilities: + - Updated `minimatch` to ≥10.2.3, resolving **high** ReDoS via matchOne() combinatorial backtracking with multiple non-adjacent GLOBSTAR segments ([GHSA-7r86-cg39-jmmj](https://github.com/advisories/GHSA-7r86-cg39-jmmj)) + - Updated `rollup` to ≥4.58.1, resolving **high** Arbitrary File Write via Path Traversal ([GHSA-mw96-cpmx-2vgc](https://github.com/advisories/GHSA-mw96-cpmx-2vgc)) + +### Fixed + +- EPG filter regression in channel table (introduced in 0.20.0 channel store refactor): The EPG filter dropdown was showing all EPG sources regardless of whether they had any channels assigned, and the "No EPG" option was never displayed. Fixed by annotating EPGSource records with a `has_channels` flag (via a lightweight `EXISTS` subquery) so only active EPG sources with at least one channel assigned appear as filter options. "No EPG" now appears only when at least one channel globally has no EPG assigned; this is determined by a second `EXISTS` query embedded directly in the paginated channel response (`has_unassigned_epg_channels`), avoiding any additional network requests. +- Stale stream rows missing hover effect: Stale streams in the streams table had no hover color change, unlike channels with no streams assigned. Converted the inline `backgroundColor` style to a CSS class (`stale-stream-row`) so the `:hover` rule can apply correctly. Applied the same fix to the channel-streams sub-table, where the teal expanded-row background caused the semi-transparent red tint to visually mismatch; the sub-table now uses a pre-blended solid color via `color-mix()` to match the appearance of stale rows in the main streams table. +- Channel table onboarding shown when filter returns zero results: The channel store refactor changed to loading only channel IDs instead of full channel objects, leaving `Object.keys(channels).length` always `0` and incorrectly triggering the onboarding state on any empty filter. Fixed by checking `channelIds.length` instead. +- TV Guide scrolls to position 0 when a filter yields no results: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before. +- `debian_install.sh` regressions after `uv` migration on clean/minimal Debian installs: fixed pip-less venv (`ensurepip`), missing `gunicorn` for the systemd unit, and inconsistent `DJANGO_SECRET_KEY` availability (now persisted to `.env` via `EnvironmentFile`). Docker unaffected. - Thanks [@marcinolek](https://github.com/marcinolek) + +## [0.20.1] - 2026-02-26 + +### Fixed + +- Login form disabled after token expiry: The login button was permanently rendered as disabled ("Logging you in...") on page load after a session expired, preventing users from logging back in. A regression in v0.20.0 caused `LoginForm` to check `if (user)` to detect an already-authenticated reload, but the Zustand auth store initializes `user` as a truthy empty object `{ username: '', email: '', user_level: '' }`, so the loading state was set immediately on every mount. Reverted to pre-regression behavior. (Fixes #1029) + +## [0.20.0] - 2026-02-26 + ### Security - Updated Django 5.2.9 → 5.2.11, resolving the following CVEs: @@ -22,9 +46,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- API key authentication: Added support for API key-based authentication as an alternative to JWT tokens. Users can generate and revoke their own personal API key from their profile page, enabling programmatic access for scripts, automations, and third-party integrations without exposing account credentials. Keys authenticate via the `Authorization: ApiKey ` header or the `X-API-Key: ` header. Admin users can additionally generate and revoke keys on behalf of any user. - Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations. - Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942) -- Event-driven webhooks and script execution (Connect): Added new Connect feature that enables event-driven execution of custom scripts and webhooks in response to system events. Supports multiple event types including channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect). Event data is available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads. Plugins can now subscribe to events by specifying an `events` array in their action definitions. Includes connection testing endpoint with dummy payloads for validation. (Closes #203) +- Event-driven webhooks and script execution (Integrations): Added new Integrations feature that enables event-driven execution of custom scripts and webhooks in response to system events. (Closes #203) + - **Supported event types**: 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 delivery**: available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads + - **Plugin support**: plugins can subscribe to events by specifying an `events` array in their action definitions + - **Connection testing**: test endpoint with dummy payloads for validation before going live + - **Custom HTTP headers**: webhook connections support configurable key/value header pairs + - **Per-event Jinja2 payload templates**: each enabled event can have its own template rendered with the full event payload as context; rendered output is sent as JSON (with `Content-Type: application/json` set automatically) if valid, or as a raw string body otherwise + - **Tabbed connection form**: Settings, Event Triggers, and Payload Templates organized into separate tabs for clarity - 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 @@ -32,6 +64,8 @@ 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) - 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) +- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup. +- Streamer accounts attempting to log into the web UI now receive a clear notification explaining they cannot access the UI but their stream URLs still work. Previously the login button would silently stop with no feedback. ### Changed @@ -63,9 +97,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity. - Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible. - XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839) +- XC API `get_series` now includes `tmdb_id` and `imdb_id` fields, matching `get_vod_streams`. Clients that use TMDB enrichment (e.g. Chillio) can now resolve clean series titles and poster images. - Thanks [@firestaerter3](https://github.com/firestaerter3) +- Stats page "Now Playing" EPG lookup updated to use `channel_uuids` directly (the proxy stats already key active channels by UUID), removing the need for a UUID→integer ID conversion step introduced alongside the lazy channel-fetch refactor. Stream preview sessions (which use a content hash rather than a UUID as their channel ID) are now filtered out before any API call is made, preventing a backend `ValidationError` on both the `current-programs` and `by-uuids` endpoints when a stream preview is active on the Stats page. ### Fixed +- Fixed admin permission checks inconsistently using `is_superuser`/`is_staff` instead of `user_level>=10`, causing API-created admin accounts to intermittently see the setup page, lose access to backup endpoints, and miss admin-only notifications. `manage.py createsuperuser` now also correctly sets `user_level=10`. (Fixes #954) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Channel table group filter sort order: The group dropdown in the channel table is now sorted alphabetically. - DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time. - XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978) @@ -75,6 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) +- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil) +- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried. ## [0.19.0] - 2026-02-10 @@ -149,6 +188,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths. - Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action. - Plugin enable/disable toggles now update immediately without requiring a full page refresh. +- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen) ## [0.18.1] - 2026-01-27 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index ac062841..90aa84fa 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -7,7 +7,7 @@ from .models import User class CustomUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('username', 'password', 'avatar_config', 'groups')}), - ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), + ('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}), ('Important dates', {'fields': ('last_login', 'date_joined')}), ) diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py index dda3832c..27de8eb7 100644 --- a/apps/accounts/api_urls.py +++ b/apps/accounts/api_urls.py @@ -4,6 +4,7 @@ from .api_views import ( AuthViewSet, UserViewSet, GroupViewSet, + APIKeyViewSet, TokenObtainPairView, TokenRefreshView, list_permissions, @@ -17,6 +18,7 @@ app_name = "accounts" router = DefaultRouter() router.register(r"users", UserViewSet, basename="user") router.register(r"groups", GroupViewSet, basename="group") +router.register(r"api-keys", APIKeyViewSet, basename="api-key") # 🔹 Custom Authentication Endpoints auth_view = AuthViewSet.as_view({"post": "login"}) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 188cd8cb..cf2d9225 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -9,6 +9,7 @@ from rest_framework import viewsets, status, serializers from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer from drf_spectacular.types import OpenApiTypes import json +import secrets from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed @@ -118,8 +119,8 @@ class TokenRefreshView(TokenRefreshView): @csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists. def initialize_superuser(request): - # If a superuser already exists, always indicate that - if User.objects.filter(is_superuser=True).exists(): + # If an admin-level user already exists, the system is configured + if User.objects.filter(user_level__gte=10).exists(): return JsonResponse({"superuser_exists": True}) if request.method == "POST": @@ -318,6 +319,59 @@ class GroupViewSet(viewsets.ModelViewSet): return super().destroy(request, *args, **kwargs) +# API Key management +class APIKeyViewSet(viewsets.ViewSet): + permission_classes = [Authenticated] + + def list(self, request): + user = request.user + return Response({"key": user.api_key}) + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + raw = secrets.token_urlsafe(40) + target_user.api_key = raw + target_user.save(update_fields=["api_key"]) + + user_data = UserSerializer(target_user).data + return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["post"], url_path="revoke") + def revoke(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + target_user.api_key = None + target_user.save(update_fields=["api_key"]) + + return Response({"success": True}) + + # 🔹 4) Permissions List API @extend_schema( description="Retrieve a list of all permissions", diff --git a/apps/accounts/authentication.py b/apps/accounts/authentication.py new file mode 100644 index 00000000..5380af49 --- /dev/null +++ b/apps/accounts/authentication.py @@ -0,0 +1,49 @@ +from rest_framework import authentication +from rest_framework import exceptions +from django.conf import settings +from .models import User + + +class ApiKeyAuthentication(authentication.BaseAuthentication): + """ + Accepts header `Authorization: ApiKey ` or `X-API-Key: `. + """ + + keyword = "ApiKey" + + def authenticate(self, request): + # Check X-API-Key header first + raw_key = request.META.get("HTTP_X_API_KEY") + + if not raw_key: + auth = authentication.get_authorization_header(request).split() + if not auth: + return None + + if len(auth) != 2: + return None + + scheme = auth[0].decode().lower() + if scheme != self.keyword.lower(): + return None + + raw_key = auth[1].decode() + + if not raw_key: + return None + + if not raw_key: + return None + + try: + user = User.objects.get(api_key=raw_key) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid API key") + + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return (user, None) + + def authenticate_header(self, request): + return self.keyword diff --git a/apps/accounts/migrations/0004_user_api_key.py b/apps/accounts/migrations/0004_user_api_key.py new file mode 100644 index 00000000..fee7c983 --- /dev/null +++ b/apps/accounts/migrations/0004_user_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-21 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_user_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='api_key', + field=models.CharField(blank=True, db_index=True, max_length=200, null=True), + ), + ] diff --git a/apps/accounts/migrations/0005_alter_user_managers.py b/apps/accounts/migrations/0005_alter_user_managers.py new file mode 100644 index 00000000..af66e123 --- /dev/null +++ b/apps/accounts/migrations/0005_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +import apps.accounts.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_user_api_key'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', apps.accounts.models.CustomUserManager()), + ], + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index da5e36bc..e04d66ed 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,9 +1,16 @@ # apps/accounts/models.py from django.db import models -from django.contrib.auth.models import AbstractUser, Permission +from django.contrib.auth.models import AbstractUser, Permission, UserManager + + +class CustomUserManager(UserManager): + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('user_level', 10) + return super().create_superuser(username, email, password, **extra_fields) class User(AbstractUser): + objects = CustomUserManager() """ Custom user model for Dispatcharr. Inherits from Django's AbstractUser to add additional fields if needed. @@ -22,6 +29,7 @@ class User(AbstractUser): ) user_level = models.IntegerField(default=UserLevel.STREAMER) custom_properties = models.JSONField(default=dict, blank=True, null=True) + api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) def __str__(self): return self.username diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 865d29af..05e11ebb 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -28,19 +28,20 @@ class UserSerializer(serializers.ModelSerializer): channel_profiles = serializers.PrimaryKeyRelatedField( queryset=ChannelProfile.objects.all(), many=True, required=False ) + api_key = serializers.CharField(read_only=True, allow_null=True) class Meta: model = User fields = [ "id", "username", + "api_key", "email", "user_level", "password", "channel_profiles", "custom_properties", "avatar_config", - "is_active", "is_staff", "is_superuser", "last_login", @@ -54,7 +55,6 @@ class UserSerializer(serializers.ModelSerializer): user = User(**validated_data) user.set_password(validated_data["password"]) - user.is_active = True user.save() user.channel_profiles.set(channel_profiles) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 00000000..629207ba --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,72 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +User = get_user_model() + + +class InitializeSuperuserTests(TestCase): + """Tests for the initialize_superuser endpoint""" + + def setUp(self): + self.client = APIClient() + self.url = "/api/accounts/initialize-superuser/" + + def test_returns_true_when_superuser_exists(self): + """Superuser with is_superuser=True should be detected""" + User.objects.create_superuser( + username="admin", password="testpass123", user_level=10 + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_true_when_admin_level_user_exists(self): + """User with user_level=10 but is_superuser=False should be detected""" + user = User.objects.create_user(username="admin", password="testpass123") + user.user_level = 10 + user.is_superuser = False + user.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_false_when_no_admin_exists(self): + """No admin or superuser should return false""" + # Create a non-admin user + User.objects.create_user(username="regular", password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_returns_false_when_no_users_exist(self): + """Empty database should return false""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_create_superuser_when_none_exists(self): + """POST should create superuser when none exists""" + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123", "email": "admin@test.com"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists()) + + def test_cannot_create_superuser_when_admin_exists(self): + """POST should fail when an admin-level user already exists""" + user = User.objects.create_user(username="existing", password="testpass123") + user.user_level = 10 + user.save() + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + # Should NOT have created a new user + self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file diff --git a/apps/backups/api_views.py b/apps/backups/api_views.py index c6ff7d26..a5af495e 100644 --- a/apps/backups/api_views.py +++ b/apps/backups/api_views.py @@ -9,7 +9,8 @@ from django.conf import settings from django.http import HttpResponse, StreamingHttpResponse, Http404 from rest_framework import status from rest_framework.decorators import api_view, permission_classes, parser_classes -from rest_framework.permissions import IsAdminUser, AllowAny +from rest_framework.permissions import AllowAny +from apps.accounts.permissions import IsAdmin from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response @@ -33,7 +34,7 @@ def _verify_task_token(task_id: str, token: str) -> bool: @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def list_backups(request): """List all available backup files.""" try: @@ -47,7 +48,7 @@ def list_backups(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def create_backup(request): """Create a new backup (async via Celery).""" try: @@ -86,7 +87,7 @@ def backup_status(request, task_id): ) else: # Fall back to admin auth check - if not request.user.is_authenticated or not request.user.is_staff: + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: return Response( {"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED, @@ -124,7 +125,7 @@ def backup_status(request, task_id): @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def get_download_token(request, filename): """Get a signed token for downloading a backup file.""" try: @@ -168,7 +169,7 @@ def download_backup(request, filename): ) else: # Fall back to admin auth check - if not request.user.is_authenticated or not request.user.is_staff: + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: return Response( {"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED, @@ -230,7 +231,7 @@ def download_backup(request, filename): @api_view(["DELETE"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def delete_backup(request, filename): """Delete a backup file.""" try: @@ -253,7 +254,7 @@ def delete_backup(request, filename): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) @parser_classes([MultiPartParser, FormParser]) def upload_backup(request): """Upload a backup file for restoration.""" @@ -299,7 +300,7 @@ def upload_backup(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def restore_backup(request, filename): """Restore from a backup file (async via Celery). WARNING: This will flush the database!""" try: @@ -332,7 +333,7 @@ def restore_backup(request, filename): @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def get_schedule(request): """Get backup schedule settings.""" try: @@ -346,7 +347,7 @@ def get_schedule(request): @api_view(["PUT"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def update_schedule(request): """Update backup schedule settings.""" try: diff --git a/apps/backups/tests.py b/apps/backups/tests.py index 85076a85..e5743623 100644 --- a/apps/backups/tests.py +++ b/apps/backups/tests.py @@ -735,6 +735,94 @@ class BackupAPITestCase(TestCase): self.assertIn('frequency', data['detail']) +class BackupAdminPermissionTestCase(TestCase): + """Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks. + + This validates the IsAdminUser -> IsAdmin permission change. + API-created admins have user_level=10 but is_staff=False and is_superuser=False. + """ + + def setUp(self): + self.client = APIClient() + # API-created admin: user_level=10 but NOT is_staff or is_superuser + self.api_admin = User.objects.create_user( + username='api_admin', + email='apiadmin@example.com', + password='testpass123' + ) + self.api_admin.user_level = 10 + self.api_admin.is_staff = False + self.api_admin.is_superuser = False + self.api_admin.save() + + # User with is_staff=True but low user_level (should NOT have access) + self.staff_user = User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123' + ) + self.staff_user.is_staff = True + self.staff_user.user_level = 1 + self.staff_user.save() + + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.list_backups') + def test_api_created_admin_can_list_backups(self, mock_list_backups): + """API-created admin (user_level=10, is_staff=False) should access backup endpoints""" + mock_list_backups.return_value = [] + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + + def test_staff_user_without_user_level_cannot_list_backups(self): + """User with is_staff=True but user_level < 10 should NOT access backup endpoints""" + auth_header = self.get_auth_header(self.staff_user) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_api_created_admin_can_create_backup(self, mock_create_task): + """API-created admin should be able to create backups""" + mock_task = MagicMock() + mock_task.id = 'test-task-id' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + + @patch('apps.backups.services.get_backup_dir') + def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir): + """API-created admin should be able to delete backups""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.delete( + '/api/backups/test-backup.zip/delete/', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 204) + + class BackupSchedulerTestCase(TestCase): """Test cases for backup scheduler""" diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index d0fb3d97..55192216 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -413,6 +413,13 @@ class ChannelPagination(PageNumberPagination): return super().paginate_queryset(queryset, request, view) + def get_paginated_response(self, data): + from django.db.models import Exists, OuterRef + has_unassigned = Channel.objects.filter(epg_data__isnull=True).exists() + response = super().get_paginated_response(data) + response.data['has_unassigned_epg_channels'] = has_unassigned + return response + class EPGFilter(django_filters.Filter): """ diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py index de3903c0..739e8e32 100644 --- a/apps/connect/api_views.py +++ b/apps/connect/api_views.py @@ -74,11 +74,13 @@ class IntegrationViewSet(viewsets.ModelViewSet): {"detail": f"Invalid event: {event}"}, status=status.HTTP_400_BAD_REQUEST, ) + # Only accept payload_template when the integration is a webhook + payload_template = item.get("payload_template") if integration.type == "webhook" else None incoming.append( { "event": event, "enabled": bool(item.get("enabled", True)), - "payload_template": item.get("payload_template"), + "payload_template": payload_template, } ) diff --git a/apps/connect/handlers/webhook.py b/apps/connect/handlers/webhook.py index ccca530a..511f0a0e 100644 --- a/apps/connect/handlers/webhook.py +++ b/apps/connect/handlers/webhook.py @@ -1,10 +1,21 @@ # connect/handlers/webhook.py -import requests +import requests, json, logging from .base import IntegrationHandler +logger = logging.getLogger(__name__) + 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) + logger.info(self.payload) + + try: + parsed = json.loads(self.payload) + headers["Content-Type"] = "application/json" + except Exception: + pass + + response = requests.post(url, data=self.payload, headers=headers, timeout=10) + return {"status_code": response.status_code, "body": response.text, "success": response.ok} diff --git a/apps/connect/migrations/0002_alter_eventsubscription_event.py b/apps/connect/migrations/0002_alter_eventsubscription_event.py new file mode 100644 index 00000000..90eee90b --- /dev/null +++ b/apps/connect/migrations/0002_alter_eventsubscription_event.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_connect', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='eventsubscription', + name='event', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100), + ), + ] diff --git a/apps/connect/utils.py b/apps/connect/utils.py index 6144a6b3..c3a0975f 100644 --- a/apps/connect/utils.py +++ b/apps/connect/utils.py @@ -38,13 +38,14 @@ def trigger_event(event_name, payload): ) continue - # apply optional payload template + # apply optional payload template (only for webhook integrations) + # If the rendered template is valid JSON, use that object as the payload. + # Otherwise, pass the rendered string as-is. final_payload = payload - if sub.payload_template: + if integration.type == 'webhook' and sub.payload_template: try: template = Template(sub.payload_template) - rendered = template.render(Context(payload)) - final_payload = {"message": rendered} + final_payload = template.render(Context(payload)).strip() except Exception as e: logger.error( f"Payload template render failed for subscription id={sub.id}: {e}" diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 15613d4d..5858fa0e 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -42,6 +42,17 @@ class EPGSourceViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + from django.db.models import Exists, OuterRef + from apps.channels.models import Channel + return EPGSource.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).annotate( + has_channels=Exists( + Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk')) + ) + ) + def list(self, request, *args, **kwargs): logger.debug("Listing all EPG sources.") return super().list(request, *args, **kwargs) @@ -441,43 +452,32 @@ class CurrentProgramsAPIView(APIView): request=inline_serializer( name="CurrentProgramsRequest", fields={ - "channel_ids": serializers.ListField( - child=serializers.IntegerField(), + "channel_uuids": serializers.ListField( + child=serializers.CharField(), required=False, allow_null=True, - help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.", ), }, ), responses={200: ProgramDataSerializer(many=True)}, ) def post(self, request, format=None): - # Get channel IDs from request body - channel_ids = request.data.get('channel_ids', None) - # Import Channel model from apps.channels.models import Channel # Build query for channels with EPG data query = Channel.objects.filter(epg_data__isnull=False) - # Filter by specific channel IDs if provided - if channel_ids is not None: - if not isinstance(channel_ids, list): + channel_uuids = request.data.get('channel_uuids', None) + + if channel_uuids is not None: + if not isinstance(channel_uuids, list): return Response( - {"error": "channel_ids must be an array of integers or null"}, + {"error": "channel_uuids must be an array of strings or null"}, status=status.HTTP_400_BAD_REQUEST ) - - try: - channel_ids = [int(id) for id in channel_ids] - except (ValueError, TypeError): - return Response( - {"error": "channel_ids must contain valid integers"}, - status=status.HTTP_400_BAD_REQUEST - ) - - query = query.filter(id__in=channel_ids) + query = query.filter(uuid__in=channel_uuids) # Get channels with EPG data channels = query.select_related('epg_data') @@ -497,9 +497,8 @@ class CurrentProgramsAPIView(APIView): ).first() if program: - # Serialize program and add channel_id for easy mapping program_data = ProgramDataSerializer(program).data - program_data['channel_id'] = channel.id + program_data['channel_uuid'] = str(channel.uuid) current_programs.append(program_data) return Response(current_programs, status=status.HTTP_200_OK) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 60ce097b..4b4780ea 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -5,6 +5,7 @@ from apps.channels.models import Channel class EPGSourceSerializer(serializers.ModelSerializer): epg_data_count = serializers.SerializerMethodField() + has_channels = serializers.BooleanField(read_only=True, default=False) read_only_fields = ['created_at', 'updated_at'] url = serializers.CharField( required=False, @@ -32,7 +33,8 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'created_at', 'updated_at', 'custom_properties', - 'epg_data_count' + 'epg_data_count', + 'has_channels', ] def get_epg_data_count(self, obj): @@ -53,7 +55,15 @@ class EPGSourceSerializer(serializers.ModelSerializer): return data def update(self, instance, validated_data): - cron_expr = validated_data.pop('cron_expression', '') + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if 'cron_expression' in validated_data: + cron_expr = validated_data.pop('cron_expression') + else: + cron_expr = '' + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' instance._cron_expression = cron_expr for attr, value in validated_data.items(): setattr(instance, attr, value) diff --git a/apps/epg/signals.py b/apps/epg/signals.py index 89deaa66..97992ef3 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -70,7 +70,7 @@ def create_dummy_epg_data(sender, instance, created, **kwargs): logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})") @receiver(post_save, sender=EPGSource) -def create_or_update_refresh_task(sender, instance, **kwargs): +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): """ Create or update a Celery Beat periodic task when an EPGSource is created/updated. Skip creating tasks for dummy EPG sources as they don't need refreshing. @@ -84,11 +84,35 @@ def create_or_update_refresh_task(sender, instance, **kwargs): instance.refresh_task.save(update_fields=['enabled']) return + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return + task_name = f"epg_source-refresh-{instance.id}" should_be_enabled = instance.is_active - # Read cron_expression from transient attribute set by the serializer - cron_expr = getattr(instance, "_cron_expression", "") + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass task = create_or_update_periodic_task( task_name=task_name, diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 97552171..9dc597d3 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -24,7 +24,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from .models import EPGSource, EPGData, ProgramData -from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event +from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event logger = logging.getLogger(__name__) @@ -146,12 +146,15 @@ def refresh_all_epg_data(): return "EPG data refreshed." -@shared_task +@shared_task(time_limit=1800, soft_time_limit=1700) def refresh_epg_data(source_id): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return + lock_renewer = TaskLockRenewer('refresh_epg_data', source_id) + lock_renewer.start() + source = None try: # Try to get the EPG source @@ -168,6 +171,7 @@ def refresh_epg_data(source_id): logger.info(f"No orphaned task found for EPG source {source_id}") # Release the lock and exit + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -176,6 +180,7 @@ def refresh_epg_data(source_id): # The source exists but is not active, just skip processing if not source.is_active: logger.info(f"EPG source {source_id} is not active. Skipping.") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -184,6 +189,7 @@ def refresh_epg_data(source_id): # Skip refresh for dummy EPG sources - they don't need refreshing if source.source_type == 'dummy': logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) gc.collect() return @@ -194,6 +200,7 @@ def refresh_epg_data(source_id): fetch_success = fetch_xmltv(source) if not fetch_success: logger.error(f"Failed to fetch XMLTV for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -202,6 +209,7 @@ def refresh_epg_data(source_id): parse_channels_success = parse_channels_only(source) if not parse_channels_success: logger.error(f"Failed to parse channels for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -234,6 +242,7 @@ def refresh_epg_data(source_id): source = None # Force garbage collection before releasing the lock gc.collect() + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) @@ -1126,12 +1135,15 @@ def parse_channels_only(source): -@shared_task +@shared_task(time_limit=3600, soft_time_limit=3500) def parse_programs_for_tvg_id(epg_id): if not acquire_task_lock('parse_epg_programs', epg_id): logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") return "Task already running" + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + source_file = None program_parser = None programs_to_create = [] @@ -1161,11 +1173,13 @@ def parse_programs_for_tvg_id(epg_id): # Skip program parsing for dummy EPG sources - they don't have program data files if epg_source.source_type == 'dummy': logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return if not Channel.objects.filter(epg_data=epg).exists(): logger.info(f"No channels matched to EPG {epg.tvg_id}") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1207,6 +1221,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, cannot parse programs" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1217,6 +1232,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, file missing after download" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1232,6 +1248,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"No URL provided, cannot fetch EPG data" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1379,7 +1396,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source = None # Add comprehensive cleanup before releasing lock cleanup_memory(log_usage=should_log_memory, force_collection=True) - # Memory tracking after processing + # Memory tracking after processing if process: try: mem_after = process.memory_info().rss / 1024 / 1024 @@ -1389,6 +1406,7 @@ def parse_programs_for_tvg_id(epg_id): process = None epg = None programs_processed = None + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 8bfa7635..22c4057a 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -204,7 +204,14 @@ class M3UAccountSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): # Pop cron_expression before it reaches model fields - cron_expr = validated_data.pop("cron_expression", "") + # If not present (partial update), preserve the existing cron from the PeriodicTask + if "cron_expression" in validated_data: + cron_expr = validated_data.pop("cron_expression") + else: + cron_expr = "" + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" instance._cron_expression = cron_expr # Handle enable_vod preference and auto_enable_new_groups settings diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index ced6f754..3de67c90 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -20,16 +20,40 @@ def refresh_account_on_save(sender, instance, created, **kwargs): refresh_m3u_groups.delay(instance.id) @receiver(post_save, sender=M3UAccount) -def create_or_update_refresh_task(sender, instance, **kwargs): +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): """ Create or update a Celery Beat periodic task when an M3UAccount is created/updated. Supports both interval-based and cron-based scheduling via the shared utility. """ + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return + task_name = f"m3u_account-refresh-{instance.id}" should_be_enabled = instance.is_active - # Read cron_expression from transient attribute set by the serializer - cron_expr = getattr(instance, "_cron_expression", "") + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass task = create_or_update_periodic_task( task_name=task_name, diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index dc178317..a9413143 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -23,6 +23,7 @@ from core.utils import ( RedisClient, acquire_task_lock, release_task_lock, + TaskLockRenewer, natural_sort_key, log_system_event, ) @@ -66,7 +67,8 @@ def fetch_m3u_lines(account, use_cache=False): account.save(update_fields=["status", "last_message"]) response = requests.get( - account.server_url, headers=headers, stream=True + account.server_url, headers=headers, stream=True, + timeout=(30, 60), # 30s connect, 60s read between chunks ) # Log the actual response details for debugging @@ -126,119 +128,60 @@ def fetch_m3u_lines(account, use_cache=False): start_time = time.time() last_update_time = start_time progress = 0 - temp_content = b"" # Store content temporarily to validate before saving has_content = False - # First, let's collect the content and validate it - send_m3u_update(account.id, "downloading", 0) - for chunk in response.iter_content(chunk_size=8192): - if chunk: - temp_content += chunk - has_content = True + # Stream directly to a temp file to avoid holding the entire + # M3U in memory (large files can be 100MB+, which would use + # ~3x that in RAM in certain approaches). + temp_path = file_path + ".tmp" + try: + send_m3u_update(account.id, "downloading", 0) + with open(temp_path, "wb") as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + has_content = True - downloaded += len(chunk) - elapsed_time = time.time() - start_time + downloaded += len(chunk) + elapsed_time = time.time() - start_time - # Calculate download speed in KB/s - speed = downloaded / elapsed_time / 1024 # in KB/s + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 # in KB/s - # Calculate progress percentage - if total_size and total_size > 0: - progress = (downloaded / total_size) * 100 + # Calculate progress percentage + if total_size and total_size > 0: + progress = (downloaded / total_size) * 100 - # Time remaining (in seconds) - time_remaining = ( - (total_size - downloaded) / (speed * 1024) - if speed > 0 - else 0 - ) - - current_time = time.time() - if current_time - last_update_time >= 0.5: - last_update_time = current_time - if progress > 0: - # Update the account's last_message with detailed progress info - progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" - account.last_message = progress_msg - account.save(update_fields=["last_message"]) - - send_m3u_update( - account.id, - "downloading", - progress, - speed=speed, - elapsed_time=elapsed_time, - time_remaining=time_remaining, - message=progress_msg, + # Time remaining (in seconds) + time_remaining = ( + (total_size - downloaded) / (speed * 1024) + if speed > 0 + else 0 ) - # Check if we actually received any content - logger.info(f"Download completed. Has content: {has_content}, Content length: {len(temp_content)} bytes") - if not has_content or len(temp_content) == 0: - error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account.id, - "downloading", - 100, - status="error", - error=error_msg, - ) - return [], False + current_time = time.time() + if current_time - last_update_time >= 0.5: + last_update_time = current_time + if progress > 0: + # Update the account's last_message with detailed progress info + progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" + account.last_message = progress_msg + account.save(update_fields=["last_message"]) - # Basic validation: check if content looks like an M3U file - try: - content_str = temp_content.decode('utf-8', errors='ignore') - content_lines = content_str.strip().split('\n') + send_m3u_update( + account.id, + "downloading", + progress, + speed=speed, + elapsed_time=elapsed_time, + time_remaining=time_remaining, + message=progress_msg, + ) - # Log first few lines for debugging (be careful not to log too much) - preview_lines = content_lines[:5] - logger.info(f"Content preview (first 5 lines): {preview_lines}") - logger.info(f"Total lines in content: {len(content_lines)}") - - # Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content) - is_valid_m3u = False - - # First, check if this looks like an error response disguised as 200 OK - content_lower = content_str.lower() - if any(error_indicator in content_lower for error_indicator in [ - ' bool: # Check user level user_level = notification_data.get('user_level', 'all') - if user_level == 'admin' and not getattr(user, 'is_superuser', False): + if user_level == 'admin' and getattr(user, 'user_level', 0) < 10: return False # Check conditions @@ -396,7 +396,7 @@ def get_user_developer_notifications(user) -> list: ) # Filter by admin_only based on user - if not getattr(user, 'is_superuser', False): + if getattr(user, 'user_level', 0) < 10: notifications = notifications.filter(admin_only=False) # Filter by conditions diff --git a/core/utils.py b/core/utils.py index bd80782e..a5ea41df 100644 --- a/core/utils.py +++ b/core/utils.py @@ -222,6 +222,75 @@ def release_task_lock(task_name, id): # Remove the lock redis_client.delete(lock_id) + +class TaskLockRenewer: + """Periodically renews a Redis task lock to prevent expiry during long-running tasks. + + Use as a context manager after acquiring a lock: + + if acquire_task_lock("my_task", task_id): + with TaskLockRenewer("my_task", task_id): + # ... long-running work ... + release_task_lock("my_task", task_id) + + A daemon thread extends the lock TTL at regular intervals so that + slow downloads or large parsing jobs don't lose their lock mid-operation. + """ + + def __init__(self, task_name, id, ttl=300, renewal_interval=120): + self.task_name = task_name + self.id = id + self.ttl = ttl + self.renewal_interval = renewal_interval + self.lock_id = f"task_lock_{task_name}_{id}" + self._stop_event = threading.Event() + self._thread = None + + def _renew_loop(self): + """Background loop that extends the lock TTL until stopped.""" + while not self._stop_event.wait(self.renewal_interval): + try: + redis_client = RedisClient.get_client() + if redis_client.exists(self.lock_id): + redis_client.expire(self.lock_id, self.ttl) + logger.debug( + f"Renewed lock {self.lock_id} TTL to {self.ttl}s" + ) + else: + # Lock was deleted externally (e.g. manual release) — stop renewing + logger.warning( + f"Lock {self.lock_id} no longer exists, stopping renewal" + ) + break + except Exception as e: + logger.error(f"Error renewing lock {self.lock_id}: {e}") + + def start(self): + """Start the background renewal thread.""" + self._stop_event.clear() + self._thread = threading.Thread( + target=self._renew_loop, daemon=True, + name=f"lock-renew-{self.task_name}-{self.id}" + ) + self._thread.start() + return self + + def stop(self): + """Stop the renewal thread.""" + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + self._thread = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + return False + + def send_websocket_update(group_name, event_type, data, collect_garbage=False): """ Standardized function to send WebSocket updates with proper memory management. @@ -404,7 +473,7 @@ def dispatch_event_system(event_type, channel_id=None, channel_name=None, **deta from core.models import StreamProfile from core.utils import RedisClient - payload = {} + payload = dict(details) channel_obj = None if channel_id: @@ -464,6 +533,11 @@ def dispatch_event_system(event_type, channel_id=None, channel_name=None, **deta payload["profile_used"] = profile_used + # remove empty keys + for k in list(payload.keys()): + if not payload[k]: + del payload[k] + trigger_event(event_type, payload) except Exception as e: diff --git a/debian_install.sh b/debian_install.sh index 3452e5c1..3630161d 100755 --- a/debian_install.sh +++ b/debian_install.sh @@ -168,24 +168,51 @@ install_uv() { setup_python_env() { echo ">>> Setting up Python virtual environment with UV..." - # Install UV globally first - install_uv su - "$DISPATCH_USER" < /dev/null; then - curl -LsSf https://astral.sh/uv/install.sh | sh + set -euo pipefail + cd "$APP_DIR" export PATH="\$HOME/.local/bin:\$PATH" -fi -# Create venv and install dependencies using UV -uv venv env --python $PYTHON_BIN -uv sync --python env/bin/python --no-install-project --no-dev + + command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh + + rm -rf env + $PYTHON_BIN -m venv env + env/bin/python -m ensurepip --upgrade + + export UV_PROJECT_ENVIRONMENT="$APP_DIR/env" + uv sync --no-dev + + env/bin/python -m pip install -q gunicorn EOSU + ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg" } + +############################################################################## +# 6.1) Ensure Environment File +############################################################################## + +ensure_env_file() { + echo ">>> Ensuring DJANGO_SECRET_KEY exists in ${APP_DIR}/.env..." + su - "$DISPATCH_USER" <> .env +fi +EOSU +} + + ############################################################################## # 7) Build Frontend ############################################################################## @@ -231,17 +258,21 @@ create_directories() { django_migrate_collectstatic() { echo ">>> Running Django migrations & collectstatic..." su - "$DISPATCH_USER" <=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.58.0", - "@rollup/rollup-android-arm64": "4.58.0", - "@rollup/rollup-darwin-arm64": "4.58.0", - "@rollup/rollup-darwin-x64": "4.58.0", - "@rollup/rollup-freebsd-arm64": "4.58.0", - "@rollup/rollup-freebsd-x64": "4.58.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", - "@rollup/rollup-linux-arm-musleabihf": "4.58.0", - "@rollup/rollup-linux-arm64-gnu": "4.58.0", - "@rollup/rollup-linux-arm64-musl": "4.58.0", - "@rollup/rollup-linux-loong64-gnu": "4.58.0", - "@rollup/rollup-linux-loong64-musl": "4.58.0", - "@rollup/rollup-linux-ppc64-gnu": "4.58.0", - "@rollup/rollup-linux-ppc64-musl": "4.58.0", - "@rollup/rollup-linux-riscv64-gnu": "4.58.0", - "@rollup/rollup-linux-riscv64-musl": "4.58.0", - "@rollup/rollup-linux-s390x-gnu": "4.58.0", - "@rollup/rollup-linux-x64-gnu": "4.58.0", - "@rollup/rollup-linux-x64-musl": "4.58.0", - "@rollup/rollup-openbsd-x64": "4.58.0", - "@rollup/rollup-openharmony-arm64": "4.58.0", - "@rollup/rollup-win32-arm64-msvc": "4.58.0", - "@rollup/rollup-win32-ia32-msvc": "4.58.0", - "@rollup/rollup-win32-x64-gnu": "4.58.0", - "@rollup/rollup-win32-x64-msvc": "4.58.0", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 86e8a7d7..7950e8ae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -63,7 +63,7 @@ const App = () => { async function checkSuperuser() { try { const response = await API.fetchSuperUser(); - if (!response.superuser_exists) { + if (response && response.superuser_exists === false) { setSuperuserExists(false); } } catch (error) { diff --git a/frontend/src/api.js b/frontend/src/api.js index c3107aab..10a836ae 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -13,6 +13,7 @@ import useChannelsTableStore from './store/channelsTable'; import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; import useConnectStore from './store/connect'; +import Limiter from './utils'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV @@ -105,6 +106,41 @@ export default class API { return await useAuthStore.getState().getToken(); } + /** + * Fetch all pages for a paginated endpoint when you already know totalCount. + * Builds page calls from totalCount and pageSize and aggregates all results. + * - endpoint: path like "/api/channels/channels/" + * - params: URLSearchParams for filters (will not be mutated) + * - totalCount: total number of matching items + * - pageSize: items per page + * Returns a flat array of results. Supports both array and {results, next} responses. + */ + static async fetchAllByCount(endpoint, params, totalCount, pageSize = 200) { + const total = Number(totalCount) || 0; + const size = Number(pageSize) || 200; + const totalPages = Math.max(1, Math.ceil(total / size)); + + const requests = []; + for (let page = 1; page <= totalPages; page++) { + const q = new URLSearchParams(params || new URLSearchParams()); + q.set('page', String(page)); + q.set('page_size', String(size)); + const url = `${host}${endpoint}?${q.toString()}`; + requests.push(request(url)); + } + + const responses = await Promise.all(requests); + const all = []; + for (const data of responses) { + if (Array.isArray(data)) { + all.push(...data); + } else if (Array.isArray(data?.results)) { + all.push(...data.results); + } + } + return all; + } + static async fetchSuperUser() { try { return await request(`${host}/api/accounts/initialize-superuser/`, { @@ -181,32 +217,39 @@ export default class API { try { // Paginate through channels to avoid heavy single response const pageSize = 200; - let page = 1; - let allChannels = []; + const allChannels = []; - while (true) { - const data = await request( - `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` - ); + // Get first page to get total results count + const data = await request( + `${host}/api/channels/channels/?page=1&page_size=${pageSize}` + ); - // Backward compatibility: if endpoint returns an array (legacy), just return it - if (Array.isArray(data)) { - allChannels = data; - break; - } - - const results = Array.isArray(data?.results) ? data.results : []; - allChannels = allChannels.concat(results); - - const hasMore = Boolean(data?.next); - if (!hasMore || results.length === 0) { - break; - } - - page += 1; + // Backward compatibility: if endpoint returns an array (legacy), just return it + if (Array.isArray(data)) { + return data; } - return allChannels; + allChannels.concat(Array.isArray(data?.results) ? data.results : []); + + const totalPages = Math.max(1, Math.ceil(data.count / pageSize)) - 1; + const apiCalls = []; + for (let page = 2; page <= totalPages; page++) { + apiCalls.push( + new Promise(async (resolve) => { + const response = await request( + `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` + ); + + return resolve( + Array.isArray(response?.results) ? response.results : [] + ); + }) + ); + } + + const allResults = await Limiter.all(5, apiCalls); + + return allResults; } catch (e) { errorNotification('Failed to retrieve channels', e); } @@ -1271,7 +1314,6 @@ export default class API { static async getEPGs() { try { const response = await request(`${host}/api/epg/sources/`); - return response; } catch (e) { errorNotification('Failed to retrieve EPGs', e); @@ -1288,11 +1330,11 @@ export default class API { } } - static async getCurrentPrograms(channelIds = null) { + static async getCurrentPrograms(channelUUIDs = null) { try { const response = await request(`${host}/api/epg/current-programs/`, { method: 'POST', - body: { channel_ids: channelIds }, + body: { channel_uuids: channelUUIDs }, }); return response; @@ -2832,6 +2874,62 @@ export default class API { } } + static async generateApiKey({ user_id = null, name = '' } = {}) { + try { + const body = {}; + if (user_id) body.user_id = user_id; + if (name) body.name = name; + + const response = await request( + `${host}/api/accounts/api-keys/generate/`, + { + method: 'POST', + body, + } + ); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to generate API key', e); + } + } + + static async revokeApiKey({ user_id = null } = {}) { + try { + const body = {}; + if (user_id) { + body.user_id = user_id; + } + + const response = await request(`${host}/api/accounts/api-keys/revoke/`, { + method: 'POST', + body, + }); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to revoke API key', e); + } + } + static async updateUser(id, body) { try { const response = await request(`${host}/api/accounts/users/${id}/`, { diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index d02c2cec..a90a8d96 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight, MonitorCog, + Blocks, } from 'lucide-react'; import { Avatar, @@ -30,7 +31,6 @@ import { TextInput, ActionIcon, AppShellNavbar, - Menu, ScrollArea, } from '@mantine/core'; import logo from '../images/logo.png'; @@ -183,8 +183,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { { label: 'Stats', icon: , path: '/stats' }, { label: 'Plugins', icon: , path: '/plugins' }, { - label: 'Connect', - icon: , + label: 'Integrations', + icon: , paths: [ { label: 'Connections', diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index b8e69af0..81d1148b 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -37,38 +37,45 @@ const PluginActionList = ({ runningActionId, handlePluginRun, }) => { - return plugin.actions.map((action) => ( - -
- {action.label} - {action.description && ( - - {action.description} - - )} - - Event Triggers - - {action.events.map((event) => ( - - {SUBSCRIPTION_EVENTS[event] || event} - - ))} -
- -
- )); + return plugin.actions.map((action) => { + const events = Array.isArray(action?.events) ? action.events : []; + return ( + +
+ {action.label} + {action.description && ( + + {action.description} + + )} + {events.length > 0 && ( + <> + + Event Triggers + + {events.map((event) => ( + + {SUBSCRIPTION_EVENTS[event] || event} + + ))} + + )} +
+ +
+ ); + }); }; const PluginActionStatus = ({ running, lastResult }) => { diff --git a/frontend/src/components/forms/Connection.jsx b/frontend/src/components/forms/Connection.jsx index e8077d43..f4bec8b2 100644 --- a/frontend/src/components/forms/Connection.jsx +++ b/frontend/src/components/forms/Connection.jsx @@ -11,6 +11,11 @@ import { Checkbox, Text, SimpleGrid, + Textarea, + Group, + Tabs, + Accordion, + Alert, } from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; import { SUBSCRIPTION_EVENTS } from '../../constants'; @@ -25,6 +30,8 @@ const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map( const ConnectionForm = ({ connection = null, isOpen, onClose }) => { const [submitting, setSubmitting] = useState(false); const [selectedEvents, setSelectedEvents] = useState([]); + const [headers, setHeaders] = useState([]); + const [payloadTemplates, setPayloadTemplates] = useState({}); const [apiError, setApiError] = useState(''); // One-time form @@ -71,9 +78,24 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { return acc; }, []) ); + // Initialize headers array from config.headers object + const cfgHeaders = connection.config?.headers || {}; + const hdrs = Object.keys(cfgHeaders).length + ? Object.entries(cfgHeaders).map(([k, v]) => ({ key: k, value: v })) + : [{ key: '', value: '' }]; + setHeaders(hdrs); + + // Initialize payload templates per subscription + const templates = {}; + connection.subscriptions.forEach((sub) => { + if (sub.payload_template) templates[sub.event] = sub.payload_template; + }); + setPayloadTemplates(templates); } else { form.reset(); setSelectedEvents([]); + setHeaders([{ key: '', value: '' }]); + setPayloadTemplates({}); } }, [connection]); @@ -87,10 +109,18 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { try { setSubmitting(true); setApiError(''); - const config = - values.type === 'webhook' - ? { url: values.url } - : { path: values.script_path }; + // Build config including optional headers + let config; + if (values.type === 'webhook') { + const hdrs = {}; + headers.forEach((h) => { + if (h.key && h.key.trim()) hdrs[h.key] = h.value; + }); + config = { url: values.url }; + if (Object.keys(hdrs).length) config.headers = hdrs; + } else { + config = { path: values.script_path }; + } if (connection) { await API.updateConnectIntegration(connection.id, { @@ -108,13 +138,14 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { }); } - await API.setConnectSubscriptions( - connection.id, - Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({ - event, - enabled: selectedEvents.includes(event), - })) - ); + // Build subscription list including optional payload templates + const subs = Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({ + event, + enabled: selectedEvents.includes(event), + payload_template: payloadTemplates[event] || null, + })); + + await API.setConnectSubscriptions(connection.id, subs); handleClose(); } catch (error) { console.error('Failed to create/update connection', error); @@ -164,64 +195,185 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { return (
- - {apiError ? ( - - {apiError} - - ) : null} - - + {form.getValues().type === 'webhook' ? ( + + ) : ( + + )} + + {form.getValues().type === 'webhook' ? ( + + + Custom Headers (optional) + + + {headers.map((h, idx) => ( + + { + const next = [...headers]; + next[idx] = { ...next[idx], key: e.target.value }; + setHeaders(next); + }} + style={{ flex: 1 }} + /> + { + const next = [...headers]; + next[idx] = { + ...next[idx], + value: e.target.value, + }; + setHeaders(next); + }} + style={{ flex: 1 }} + /> + + + ))} + + + + ) : null} - + - - - - + + + {EVENT_OPTIONS.map((opt) => ( + toggleEvent(opt.value)} + /> + ))} + + + + {form.getValues().type === 'webhook' && ( + + + + + Enable event triggers to set individual templates. + + +
+
+ + {EVENT_OPTIONS.map( + (opt) => + selectedEvents.includes(opt.value) && ( + + + {opt.label} + + +