From e7d4dbd6375d4a17e3b34c71424a548fabc539aa Mon Sep 17 00:00:00 2001 From: MotWakorb <31100779+MotWakorb@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:41:58 -0600 Subject: [PATCH 01/52] Add restart policy to dispatcharr container Adding a restart policy so that the default recommendation is to use unless-stopped. --- docker/docker-compose.aio.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index 2b1fd2ae..d24ee041 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -4,6 +4,7 @@ services: # context: . # dockerfile: Dockerfile image: ghcr.io/dispatcharr/dispatcharr:latest + restart: unless-stopped container_name: dispatcharr ports: - 9191:9191 From dd0f5e0b00c3f51da488063acb1d7bebd1148d66 Mon Sep 17 00:00:00 2001 From: MotWakorb <31100779+MotWakorb@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:43:17 -0600 Subject: [PATCH 02/52] Add restart policy to Docker services Adding a restart policy so that the default recommendation is to use unless-stopped. --- docker/docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 519b288a..e212bd37 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,7 @@ services: web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web + restart: unless-stopped ports: - 9191:9191 volumes: @@ -80,6 +81,7 @@ services: celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery + restart: unless-stopped depends_on: - db - redis @@ -132,6 +134,7 @@ services: db: image: postgres:17 container_name: dispatcharr_db + restart: unless-stopped ports: - "5436:5432" environment: @@ -152,6 +155,7 @@ services: redis: image: redis:latest container_name: dispatcharr_redis + restart: unless-stopped # --- Authentication Configuration (Optional) --- # By default, Redis runs without authentication. From 39f2f8f970b79e8020b17a1c70bc66e959ef70cb Mon Sep 17 00:00:00 2001 From: MotWakorb <31100779+MotWakorb@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:44:07 -0600 Subject: [PATCH 03/52] Add restart policy to Docker services Adding a restart policy so that the default recommendation is to use unless-stopped. --- docker/docker-compose.dev.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index b20c3296..e640e843 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -5,6 +5,7 @@ services: # dockerfile: docker/Dockerfile.dev image: ghcr.io/dispatcharr/dispatcharr:base container_name: dispatcharr_dev + restart: unless-stopped ports: - 5656:5656 - 9191:9191 @@ -33,6 +34,7 @@ services: pgadmin: image: dpage/pgadmin4 + restart: unless-stopped environment: PGADMIN_DEFAULT_EMAIL: admin@admin.com PGADMIN_DEFAULT_PASSWORD: admin @@ -43,6 +45,7 @@ services: redis-commander: image: rediscommander/redis-commander:latest + restart: unless-stopped environment: - REDIS_HOSTS=dispatcharr:dispatcharr:6379:0 - TRUST_PROXY=true From 8999e832ff2d95295f0c42682d23b89903e2ea72 Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 19 Feb 2026 23:26:44 +0100 Subject: [PATCH 04/52] Fix: add tmdb_id and imdb_id to Xtream get_series response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get_series endpoint was omitting tmdb_id and imdb_id fields that are already present on the Series model and already included in the get_vod_streams (movies) response. Xtream clients such as Chillio use these IDs to fetch proper metadata from TMDB, including sanitized series titles and poster images. Without them, clients fall back to the raw provider name (which may include provider prefixes like ┃NL┃) and the proxied cover URL instead of TMDB's poster. Co-Authored-By: Claude Sonnet 4.6 --- apps/output/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/output/views.py b/apps/output/views.py index 1d53237a..6604d49d 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -2523,6 +2523,8 @@ def xc_get_series(request, user, category_id=None): "episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "", "category_id": str(relation.category.id) if relation.category else "0", "category_ids": [int(relation.category.id)] if relation.category else [], + "tmdb_id": series.tmdb_id or "", + "imdb_id": series.imdb_id or "", }) return series_list From aece3367ce9c1b4f1ae9ddf92d387896857d0577 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 21 Feb 2026 13:45:07 -0500 Subject: [PATCH 05/52] backend work for api key authentication --- apps/accounts/api_urls.py | 2 + apps/accounts/api_views.py | 54 +++++++++++++++++++ apps/accounts/authentication.py | 49 +++++++++++++++++ apps/accounts/migrations/0004_user_api_key.py | 18 +++++++ apps/accounts/models.py | 4 ++ apps/accounts/serializers.py | 2 + dispatcharr/settings.py | 1 + 7 files changed, 130 insertions(+) create mode 100644 apps/accounts/authentication.py create mode 100644 apps/accounts/migrations/0004_user_api_key.py 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..38bc061b 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 @@ -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/models.py b/apps/accounts/models.py index da5e36bc..4ecaa559 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,6 +1,9 @@ # apps/accounts/models.py from django.db import models from django.contrib.auth.models import AbstractUser, Permission +from django.conf import settings +from django.utils import timezone +from django.contrib.auth.hashers import make_password, check_password class User(AbstractUser): @@ -22,6 +25,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..b7268658 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -28,12 +28,14 @@ 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", diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 1fbf566b..1458c5f9 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -168,6 +168,7 @@ REST_FRAMEWORK = { ], "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", + "apps.accounts.authentication.ApiKeyAuthentication", ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } From 6cf808a4df5a6cc128d85de61037969109edcd15 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 21 Feb 2026 13:45:21 -0500 Subject: [PATCH 06/52] frontend components for API keys --- frontend/src/api.js | 116 ++++++++++++++++++++----- frontend/src/components/forms/User.jsx | 76 ++++++++++++++++ 2 files changed, 170 insertions(+), 22 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index c3107aab..98c94322 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); } @@ -2832,6 +2875,35 @@ 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 updateUser(id, body) { try { const response = await request(`${host}/api/accounts/users/${id}/`, { diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 29c93f30..825d4405 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -17,10 +17,12 @@ import { Tooltip, } from '@mantine/core'; import { RotateCcwKey, X } from 'lucide-react'; +import { Copy, Key } from 'lucide-react'; import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; import useAuthStore from '../../store/auth'; +import { copyToClipboard } from '../../utils'; const User = ({ user = null, isOpen, onClose }) => { const profiles = useChannelsStore((s) => s.profiles); @@ -29,6 +31,9 @@ const User = ({ user = null, isOpen, onClose }) => { const [, setEnableXC] = useState(false); const [selectedProfiles, setSelectedProfiles] = useState(new Set()); + const [generating, setGenerating] = useState(false); + const [generatedKey, setGeneratedKey] = useState(null); + const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null); const form = useForm({ mode: 'uncontrolled', @@ -139,6 +144,10 @@ const User = ({ user = null, isOpen, onClose }) => { if (customProps.xc_password) { setEnableXC(true); } + + if (user?.api_key) { + setUserAPIKey(user.api_key); + } } else { form.reset(); } @@ -157,6 +166,34 @@ const User = ({ user = null, isOpen, onClose }) => { const showPermissions = authUser.user_level == USER_LEVELS.ADMIN && authUser.id !== user?.id; + const canGenerateKey = + authUser.user_level == USER_LEVELS.ADMIN || authUser.id === user?.id; + + const onGenerateKey = async () => { + if (!canGenerateKey) { + return; + } + + setGenerating(true); + try { + const payload = {}; + if (authUser.user_level == USER_LEVELS.ADMIN && user?.id) { + payload.user_id = user.id; + } + + const resp = await API.generateApiKey(payload); + const newKey = resp && (resp.key || resp.raw_key); + if (newKey) { + setGeneratedKey(newKey); + setUserAPIKey(newKey); + } + } catch (e) { + // API shows notifications + } finally { + setGenerating(false); + } + }; + return (
@@ -269,6 +306,45 @@ const User = ({ user = null, isOpen, onClose }) => { )} + + {canGenerateKey && ( + + {userAPIKey && ( + + copyToClipboard(userAPIKey, { + successTitle: 'API Key Copied!', + successMessage: + 'The API Key has been copied to your clipboard.', + }) + } + > + + + } + /> + )} + + + + )} From c410b1c1e798ef608dcb2d98dc2502dddf1414c1 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 21 Feb 2026 13:45:33 -0500 Subject: [PATCH 07/52] updated wording / icon for integrations --- frontend/src/components/Sidebar.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 04453b08..5fde3313 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -20,6 +20,7 @@ import { ChevronDown, ChevronRight, MonitorCog, + Blocks, } from 'lucide-react'; import { Avatar, @@ -185,8 +186,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', From a0ed2a9c84fa8121af842803b014fb754ca77f24 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 20 Feb 2026 16:52:48 -0600 Subject: [PATCH 08/52] specify minimum version for django-celery-beat --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 82961ccb..9071f8a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "channels", "channels-redis==4.3.0", "django-filter", - "django-celery-beat", + "django-celery-beat>=2.8.1", "lxml==6.0.2", "packaging", ] From ac37973a9a1f224a461d35333e8943273e55e17e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 20 Feb 2026 16:14:11 -0600 Subject: [PATCH 09/52] Roll django back to version 5 (still has remediations). django-celery-beat does not support Django 6 quite yet. Should soon. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9071f8a6..089a5219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license = "CC-BY-NC-SA-4.0" requires-python = ">=3.13" dynamic = ["version"] dependencies = [ - "Django==5.2.9", + "Django==5.2.11", "psycopg2-binary==2.9.11", "celery[redis]==5.6.0", "djangorestframework==3.16.1", From 29a2e07f967d8b57d15427b4e542bb9659cdacd8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 20 Feb 2026 15:12:08 -0600 Subject: [PATCH 10/52] =?UTF-8?q?-=20Dependency=20updates:=20=20=20-=20`Dj?= =?UTF-8?q?ango`=205.2.9=20=E2=86=92=206.0.2=20(major=20version=20upgrade;?= =?UTF-8?q?=20includes=20security=20fixes)=20=20=20-=20`celery`=205.6.0=20?= =?UTF-8?q?=E2=86=92=205.6.2=20=20=20-=20`psutil`=207.1.3=20=E2=86=92=207.?= =?UTF-8?q?2.2=20=20=20-=20`torch`=202.9.1+cpu=20=E2=86=92=202.10.0+cpu=20?= =?UTF-8?q?=20=20-=20`sentence-transformers`=205.2.0=20=E2=86=92=205.2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 089a5219..8b87a9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,10 @@ dynamic = ["version"] dependencies = [ "Django==5.2.11", "psycopg2-binary==2.9.11", - "celery[redis]==5.6.0", + "celery[redis]==5.6.2", "djangorestframework==3.16.1", "requests==2.32.5", - "psutil==7.1.3", + "psutil==7.2.2", "pillow", "drf-spectacular>=0.29.0", "streamlink", @@ -27,8 +27,8 @@ dependencies = [ "regex", "tzlocal", "pytz", - "torch==2.9.1+cpu", - "sentence-transformers==5.2.0", + "torch==2.10.0+cpu", + "sentence-transformers==5.2.3", "channels", "channels-redis==4.3.0", "django-filter", From 64cbe0cc366c7b0e085bbe8604aab91dd2332614 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 21 Feb 2026 19:26:53 -0500 Subject: [PATCH 11/52] swapped naming / icons for settings / system --- frontend/src/components/Sidebar.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 5fde3313..7bbf691f 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -202,8 +202,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { ], }, { - label: 'System', - icon: , + label: 'Settings', + icon: , paths: [ { label: 'Users', @@ -216,8 +216,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { path: '/logos', }, { - label: 'Settings', - icon: , + label: 'System', + icon: , path: '/settings', }, ], From db318e4981e3e8e115ceeb810a19415e36c5889e Mon Sep 17 00:00:00 2001 From: None Date: Sun, 22 Feb 2026 18:05:02 -0600 Subject: [PATCH 12/52] Fix #954: Superuser detection; Feature #1004: User account disable/enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug #954: The initialize_superuser endpoint only checked is_superuser=True, missing admin accounts created via the API (user_level=10). This caused users to intermittently see the "Create Super User" page instead of login. Feature #1004: Admins can now disable/enable user accounts. Disabled users are blocked from JWT login, token refresh, XC API access, and all authenticated endpoints. The last active admin cannot be disabled. Files changed: Backend: - apps/accounts/api_views.py — Superuser check uses user_level>=10; token refresh blocks disabled users - apps/accounts/models.py — CustomUserManager ensures create_superuser() always sets user_level=10 - apps/accounts/serializers.py — Last-admin protection guard; respect is_active on user creation - apps/accounts/tests.py — new tests covering superuser detection, token refresh blocking, last-admin protection, disabled user login/access - apps/backups/api_views.py — Switched from DRF's IsAdminUser (checks is_staff) to app's IsAdmin (checks user_level) on all 8 endpoints - apps/backups/tests.py — new tests verifying user_level-based admin permission on backup endpoints - apps/output/views.py — is_active check on xc_get_user(), xc_movie_stream(), xc_series_stream() - apps/proxy/ts_proxy/views.py — is_active check on stream_xc() - core/api_views.py — Admin notification filter uses user_level>=10 - core/developer_notifications.py — Admin checks use user_level>=10 Frontend: - frontend/src/App.jsx — Null-safe superuser existence check - frontend/src/components/forms/User.jsx — Account Enabled switch with self-disable prevention and tooltip - frontend/src/components/tables/UsersTable.jsx — Status column (Active/Disabled badge) --- apps/accounts/api_views.py | 22 +- apps/accounts/models.py | 9 +- apps/accounts/serializers.py | 14 +- apps/accounts/tests.py | 283 ++++++++++++++++++ apps/backups/api_views.py | 23 +- apps/backups/tests.py | 88 ++++++ apps/output/views.py | 10 + apps/proxy/ts_proxy/views.py | 3 + core/api_views.py | 2 +- core/developer_notifications.py | 4 +- frontend/src/App.jsx | 2 +- frontend/src/components/forms/User.jsx | 32 +- frontend/src/components/tables/UsersTable.jsx | 16 + 13 files changed, 487 insertions(+), 21 deletions(-) create mode 100644 apps/accounts/tests.py diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 188cd8cb..aa3f836d 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -113,13 +113,31 @@ class TokenRefreshView(TokenRefreshView): ) return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN) + # Check if user account is still active before issuing new access token + raw_token = request.data.get("refresh") + if raw_token: + try: + from rest_framework_simplejwt.tokens import RefreshToken as RefreshTokenClass + token = RefreshTokenClass(raw_token) + user_id = token.payload.get("user_id") + if user_id: + user = User.objects.filter(id=user_id).first() + if user and not user.is_active: + logger.info(f"Token refresh blocked for disabled user: user_id={user_id}") + return Response( + {"error": "Account is disabled"}, + status=status.HTTP_403_FORBIDDEN + ) + except Exception: + pass # Let parent handle invalid tokens + return super().post(request, *args, **kwargs) @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": diff --git a/apps/accounts/models.py b/apps/accounts/models.py index da5e36bc..b4bbc7f6 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. diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 865d29af..54bb6731 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -52,9 +52,10 @@ class UserSerializer(serializers.ModelSerializer): def create(self, validated_data): channel_profiles = validated_data.pop("channel_profiles", []) + is_active = validated_data.pop("is_active", True) user = User(**validated_data) user.set_password(validated_data["password"]) - user.is_active = True + user.is_active = is_active user.save() user.channel_profiles.set(channel_profiles) @@ -62,6 +63,17 @@ class UserSerializer(serializers.ModelSerializer): return user def update(self, instance, validated_data): + # Prevent disabling the last active admin account + if 'is_active' in validated_data and not validated_data['is_active']: + other_active_admins = User.objects.filter( + user_level__gte=10, + is_active=True + ).exclude(id=instance.id).exists() + if not other_active_admins: + raise serializers.ValidationError( + {"is_active": "Cannot disable the last active admin account."} + ) + password = validated_data.pop("password", None) channel_profiles = validated_data.pop("channel_profiles", None) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 00000000..f3afdbc4 --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,283 @@ +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +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()) + + +class TokenRefreshDisabledUserTests(TestCase): + """Tests for blocking token refresh on disabled users""" + + def setUp(self): + self.client = APIClient() + self.token_url = "/api/accounts/token/" + self.refresh_url = "/api/accounts/token/refresh/" + + def test_refresh_works_for_active_user(self): + """Active user should be able to refresh their token""" + user = User.objects.create_user(username="active", password="testpass123") + user.user_level = 1 + user.is_active = True + user.save() + + # Get tokens + login_response = self.client.post( + self.token_url, + {"username": "active", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, 200) + refresh_token = login_response.data["refresh"] + + # Refresh should succeed + refresh_response = self.client.post( + self.refresh_url, + {"refresh": refresh_token}, + format="json", + ) + self.assertEqual(refresh_response.status_code, 200) + self.assertIn("access", refresh_response.data) + + def test_refresh_blocked_for_disabled_user(self): + """Disabled user should not be able to refresh their token""" + user = User.objects.create_user(username="disabled", password="testpass123") + user.user_level = 1 + user.is_active = True + user.save() + + # Get tokens while user is active + login_response = self.client.post( + self.token_url, + {"username": "disabled", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, 200) + refresh_token = login_response.data["refresh"] + + # Disable the user + user.is_active = False + user.save() + + # Refresh should be blocked + refresh_response = self.client.post( + self.refresh_url, + {"refresh": refresh_token}, + format="json", + ) + self.assertEqual(refresh_response.status_code, 403) + + +class LastAdminProtectionTests(TestCase): + """Tests for preventing disabling the last active admin""" + + def setUp(self): + self.admin = User.objects.create_user(username="admin1", password="testpass123") + self.admin.user_level = 10 + self.admin.save() + + self.client = APIClient() + self.client.force_authenticate(user=self.admin) + + def test_cannot_disable_last_admin(self): + """Should reject disabling the only active admin""" + response = self.client.patch( + f"/api/accounts/users/{self.admin.id}/", + {"is_active": False}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("is_active", response.data) + + # Verify admin is still active + self.admin.refresh_from_db() + self.assertTrue(self.admin.is_active) + + def test_can_disable_admin_when_another_exists(self): + """Should allow disabling an admin when another active admin exists""" + admin2 = User.objects.create_user(username="admin2", password="testpass123") + admin2.user_level = 10 + admin2.save() + + response = self.client.patch( + f"/api/accounts/users/{admin2.id}/", + {"is_active": False}, + format="json", + ) + self.assertEqual(response.status_code, 200) + + admin2.refresh_from_db() + self.assertFalse(admin2.is_active) + + def test_can_disable_non_admin_user(self): + """Should always allow disabling non-admin users""" + regular = User.objects.create_user(username="regular", password="testpass123") + regular.user_level = 1 + regular.save() + + response = self.client.patch( + f"/api/accounts/users/{regular.id}/", + {"is_active": False}, + format="json", + ) + self.assertEqual(response.status_code, 200) + + regular.refresh_from_db() + self.assertFalse(regular.is_active) + + def test_can_reenable_disabled_user(self): + """Should allow re-enabling a disabled user""" + regular = User.objects.create_user(username="disabled", password="testpass123") + regular.user_level = 1 + regular.is_active = False + regular.save() + + response = self.client.patch( + f"/api/accounts/users/{regular.id}/", + {"is_active": True}, + format="json", + ) + self.assertEqual(response.status_code, 200) + + regular.refresh_from_db() + self.assertTrue(regular.is_active) + + +class DisabledUserLoginTests(TestCase): + """Tests that disabled users cannot log in""" + + def setUp(self): + self.client = APIClient() + self.token_url = "/api/accounts/token/" + + def test_disabled_user_cannot_login(self): + """Disabled user should get rejected at login""" + user = User.objects.create_user(username="disabled", password="testpass123") + user.is_active = False + user.save() + + response = self.client.post( + self.token_url, + {"username": "disabled", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 401) + + def test_active_user_can_login(self): + """Active user should be able to log in""" + user = User.objects.create_user(username="active", password="testpass123") + user.is_active = True + user.save() + + response = self.client.post( + self.token_url, + {"username": "active", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertIn("access", response.data) + + +class DisabledUserAccessTokenTests(TestCase): + """Tests that a disabled user's existing access token is rejected on authenticated endpoints""" + + def setUp(self): + self.client = APIClient() + self.token_url = "/api/accounts/token/" + self.users_url = "/api/accounts/users/" + + def test_existing_token_rejected_after_disable(self): + """Access token obtained while active should be rejected after user is disabled""" + user = User.objects.create_user(username="willdisable", password="testpass123") + user.user_level = 10 # Admin so they can access the users endpoint + user.is_active = True + user.save() + + # Get tokens while user is active + login_response = self.client.post( + self.token_url, + {"username": "willdisable", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, 200) + access_token = login_response.data["access"] + + # Verify token works while active + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + response = self.client.get(self.users_url) + self.assertEqual(response.status_code, 200) + + # Disable the user + user.is_active = False + user.save() + + # Same token should now be rejected + response = self.client.get(self.users_url) + self.assertIn(response.status_code, [401, 403]) \ 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/output/views.py b/apps/output/views.py index d5b6037a..a219a6f4 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1945,6 +1945,10 @@ def xc_get_user(request): return None user = get_object_or_404(User, username=username) + + if not user.is_active: + return None + custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: @@ -2924,6 +2928,9 @@ def xc_movie_stream(request, username, password, stream_id, extension): user = get_object_or_404(User, username=username) + if not user.is_active: + return JsonResponse({"error": "Account is disabled"}, status=403) + custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: @@ -2961,6 +2968,9 @@ def xc_series_stream(request, username, password, stream_id, extension): user = get_object_or_404(User, username=username) + if not user.is_active: + return JsonResponse({"error": "Account is disabled"}, status=403) + custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 91f254a7..c4c7d29c 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -514,6 +514,9 @@ def stream_ts(request, channel_id): def stream_xc(request, username, password, channel_id): user = get_object_or_404(User, username=username) + if not user.is_active: + return JsonResponse({"error": "Account is disabled"}, status=403) + extension = pathlib.Path(channel_id).suffix channel_id = pathlib.Path(channel_id).stem diff --git a/core/api_views.py b/core/api_views.py index 20ef6249..3d131ab9 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -506,7 +506,7 @@ class SystemNotificationViewSet(viewsets.ModelViewSet): ) # Filter admin-only notifications for non-admins - if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10: + if getattr(user, 'user_level', 0) < 10: queryset = queryset.filter(admin_only=False) # For developer notifications, evaluate conditions diff --git a/core/developer_notifications.py b/core/developer_notifications.py index cf16e7a2..f4214b43 100644 --- a/core/developer_notifications.py +++ b/core/developer_notifications.py @@ -200,7 +200,7 @@ def should_show_notification(notification_data: dict, user) -> 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/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/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 29c93f30..d76dd2ec 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -42,6 +42,7 @@ const User = ({ user = null, isOpen, onClose }) => { xc_password: '', channel_profiles: [], hide_adult_content: false, + is_active: true, }, validate: (values) => ({ @@ -134,6 +135,7 @@ const User = ({ user = null, isOpen, onClose }) => { : ['0'], xc_password: customProps.xc_password || '', hide_adult_content: customProps.hide_adult_content || false, + is_active: user.is_active !== false, }); if (customProps.xc_password) { @@ -154,8 +156,9 @@ const User = ({ user = null, isOpen, onClose }) => { return <>; } - const showPermissions = - authUser.user_level == USER_LEVELS.ADMIN && authUser.id !== user?.id; + const isAdmin = authUser.user_level == USER_LEVELS.ADMIN; + const isEditingSelf = authUser.id === user?.id; + const showPermissions = isAdmin && !isEditingSelf; return ( @@ -199,6 +202,31 @@ const User = ({ user = null, isOpen, onClose }) => { key={form.key('user_level')} /> )} + + {isAdmin && ( + + +
+ +
+
+
+ )} diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index af4e3087..e9cf0a4c 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -8,6 +8,7 @@ import useWarningsStore from '../../store/warnings'; import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react'; import { ActionIcon, + Badge, Box, Text, Paper, @@ -147,6 +148,20 @@ const UsersTable = () => { {USER_LEVEL_LABELS[getValue()]} ), }, + { + header: 'Status', + accessorKey: 'is_active', + size: 90, + cell: ({ getValue }) => ( + + {getValue() !== false ? 'Active' : 'Disabled'} + + ), + }, { header: 'Username', accessorKey: 'username', @@ -316,6 +331,7 @@ const UsersTable = () => { name: renderHeaderCell, email: renderHeaderCell, user_level: renderHeaderCell, + is_active: renderHeaderCell, last_login: renderHeaderCell, date_joined: renderHeaderCell, custom_properties: renderHeaderCell, From f453380f5cb482e292d986e36a9bfc55b96e80a4 Mon Sep 17 00:00:00 2001 From: None Date: Sun, 22 Feb 2026 20:02:08 -0600 Subject: [PATCH 13/52] Remove user account disable/enable UI (keep backend guards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the frontend UI exposure while keeping backend guards as defensive measures. Removed: - frontend/src/components/forms/User.jsx — Account Enabled switch - frontend/src/components/tables/UsersTable.jsx — Status column - apps/accounts/api_views.py — is_active check in TokenRefreshView - apps/accounts/tests.py — related tests --- apps/accounts/api_views.py | 18 -- apps/accounts/tests.py | 215 +----------------- frontend/src/components/forms/User.jsx | 26 --- frontend/src/components/tables/UsersTable.jsx | 16 -- 4 files changed, 2 insertions(+), 273 deletions(-) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index aa3f836d..82e914e0 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -113,24 +113,6 @@ class TokenRefreshView(TokenRefreshView): ) return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN) - # Check if user account is still active before issuing new access token - raw_token = request.data.get("refresh") - if raw_token: - try: - from rest_framework_simplejwt.tokens import RefreshToken as RefreshTokenClass - token = RefreshTokenClass(raw_token) - user_id = token.payload.get("user_id") - if user_id: - user = User.objects.filter(id=user_id).first() - if user and not user.is_active: - logger.info(f"Token refresh blocked for disabled user: user_id={user_id}") - return Response( - {"error": "Account is disabled"}, - status=status.HTTP_403_FORBIDDEN - ) - except Exception: - pass # Let parent handle invalid tokens - return super().post(request, *args, **kwargs) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py index f3afdbc4..629207ba 100644 --- a/apps/accounts/tests.py +++ b/apps/accounts/tests.py @@ -1,7 +1,6 @@ -from django.test import TestCase, RequestFactory +from django.test import TestCase from django.contrib.auth import get_user_model from rest_framework.test import APIClient -from rest_framework import status User = get_user_model() @@ -70,214 +69,4 @@ class InitializeSuperuserTests(TestCase): 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()) - - -class TokenRefreshDisabledUserTests(TestCase): - """Tests for blocking token refresh on disabled users""" - - def setUp(self): - self.client = APIClient() - self.token_url = "/api/accounts/token/" - self.refresh_url = "/api/accounts/token/refresh/" - - def test_refresh_works_for_active_user(self): - """Active user should be able to refresh their token""" - user = User.objects.create_user(username="active", password="testpass123") - user.user_level = 1 - user.is_active = True - user.save() - - # Get tokens - login_response = self.client.post( - self.token_url, - {"username": "active", "password": "testpass123"}, - format="json", - ) - self.assertEqual(login_response.status_code, 200) - refresh_token = login_response.data["refresh"] - - # Refresh should succeed - refresh_response = self.client.post( - self.refresh_url, - {"refresh": refresh_token}, - format="json", - ) - self.assertEqual(refresh_response.status_code, 200) - self.assertIn("access", refresh_response.data) - - def test_refresh_blocked_for_disabled_user(self): - """Disabled user should not be able to refresh their token""" - user = User.objects.create_user(username="disabled", password="testpass123") - user.user_level = 1 - user.is_active = True - user.save() - - # Get tokens while user is active - login_response = self.client.post( - self.token_url, - {"username": "disabled", "password": "testpass123"}, - format="json", - ) - self.assertEqual(login_response.status_code, 200) - refresh_token = login_response.data["refresh"] - - # Disable the user - user.is_active = False - user.save() - - # Refresh should be blocked - refresh_response = self.client.post( - self.refresh_url, - {"refresh": refresh_token}, - format="json", - ) - self.assertEqual(refresh_response.status_code, 403) - - -class LastAdminProtectionTests(TestCase): - """Tests for preventing disabling the last active admin""" - - def setUp(self): - self.admin = User.objects.create_user(username="admin1", password="testpass123") - self.admin.user_level = 10 - self.admin.save() - - self.client = APIClient() - self.client.force_authenticate(user=self.admin) - - def test_cannot_disable_last_admin(self): - """Should reject disabling the only active admin""" - response = self.client.patch( - f"/api/accounts/users/{self.admin.id}/", - {"is_active": False}, - format="json", - ) - self.assertEqual(response.status_code, 400) - self.assertIn("is_active", response.data) - - # Verify admin is still active - self.admin.refresh_from_db() - self.assertTrue(self.admin.is_active) - - def test_can_disable_admin_when_another_exists(self): - """Should allow disabling an admin when another active admin exists""" - admin2 = User.objects.create_user(username="admin2", password="testpass123") - admin2.user_level = 10 - admin2.save() - - response = self.client.patch( - f"/api/accounts/users/{admin2.id}/", - {"is_active": False}, - format="json", - ) - self.assertEqual(response.status_code, 200) - - admin2.refresh_from_db() - self.assertFalse(admin2.is_active) - - def test_can_disable_non_admin_user(self): - """Should always allow disabling non-admin users""" - regular = User.objects.create_user(username="regular", password="testpass123") - regular.user_level = 1 - regular.save() - - response = self.client.patch( - f"/api/accounts/users/{regular.id}/", - {"is_active": False}, - format="json", - ) - self.assertEqual(response.status_code, 200) - - regular.refresh_from_db() - self.assertFalse(regular.is_active) - - def test_can_reenable_disabled_user(self): - """Should allow re-enabling a disabled user""" - regular = User.objects.create_user(username="disabled", password="testpass123") - regular.user_level = 1 - regular.is_active = False - regular.save() - - response = self.client.patch( - f"/api/accounts/users/{regular.id}/", - {"is_active": True}, - format="json", - ) - self.assertEqual(response.status_code, 200) - - regular.refresh_from_db() - self.assertTrue(regular.is_active) - - -class DisabledUserLoginTests(TestCase): - """Tests that disabled users cannot log in""" - - def setUp(self): - self.client = APIClient() - self.token_url = "/api/accounts/token/" - - def test_disabled_user_cannot_login(self): - """Disabled user should get rejected at login""" - user = User.objects.create_user(username="disabled", password="testpass123") - user.is_active = False - user.save() - - response = self.client.post( - self.token_url, - {"username": "disabled", "password": "testpass123"}, - format="json", - ) - self.assertEqual(response.status_code, 401) - - def test_active_user_can_login(self): - """Active user should be able to log in""" - user = User.objects.create_user(username="active", password="testpass123") - user.is_active = True - user.save() - - response = self.client.post( - self.token_url, - {"username": "active", "password": "testpass123"}, - format="json", - ) - self.assertEqual(response.status_code, 200) - self.assertIn("access", response.data) - - -class DisabledUserAccessTokenTests(TestCase): - """Tests that a disabled user's existing access token is rejected on authenticated endpoints""" - - def setUp(self): - self.client = APIClient() - self.token_url = "/api/accounts/token/" - self.users_url = "/api/accounts/users/" - - def test_existing_token_rejected_after_disable(self): - """Access token obtained while active should be rejected after user is disabled""" - user = User.objects.create_user(username="willdisable", password="testpass123") - user.user_level = 10 # Admin so they can access the users endpoint - user.is_active = True - user.save() - - # Get tokens while user is active - login_response = self.client.post( - self.token_url, - {"username": "willdisable", "password": "testpass123"}, - format="json", - ) - self.assertEqual(login_response.status_code, 200) - access_token = login_response.data["access"] - - # Verify token works while active - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") - response = self.client.get(self.users_url) - self.assertEqual(response.status_code, 200) - - # Disable the user - user.is_active = False - user.save() - - # Same token should now be rejected - response = self.client.get(self.users_url) - self.assertIn(response.status_code, [401, 403]) \ No newline at end of file + self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index d76dd2ec..44ec17ac 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -42,7 +42,6 @@ const User = ({ user = null, isOpen, onClose }) => { xc_password: '', channel_profiles: [], hide_adult_content: false, - is_active: true, }, validate: (values) => ({ @@ -135,7 +134,6 @@ const User = ({ user = null, isOpen, onClose }) => { : ['0'], xc_password: customProps.xc_password || '', hide_adult_content: customProps.hide_adult_content || false, - is_active: user.is_active !== false, }); if (customProps.xc_password) { @@ -203,30 +201,6 @@ const User = ({ user = null, isOpen, onClose }) => { /> )} - {isAdmin && ( - - -
- -
-
-
- )}
diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index e9cf0a4c..af4e3087 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -8,7 +8,6 @@ import useWarningsStore from '../../store/warnings'; import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react'; import { ActionIcon, - Badge, Box, Text, Paper, @@ -148,20 +147,6 @@ const UsersTable = () => { {USER_LEVEL_LABELS[getValue()]} ), }, - { - header: 'Status', - accessorKey: 'is_active', - size: 90, - cell: ({ getValue }) => ( - - {getValue() !== false ? 'Active' : 'Disabled'} - - ), - }, { header: 'Username', accessorKey: 'username', @@ -331,7 +316,6 @@ const UsersTable = () => { name: renderHeaderCell, email: renderHeaderCell, user_level: renderHeaderCell, - is_active: renderHeaderCell, last_login: renderHeaderCell, date_joined: renderHeaderCell, custom_properties: renderHeaderCell, From c841cfc8fc801df78469b616ace4816daadb3615 Mon Sep 17 00:00:00 2001 From: None Date: Mon, 23 Feb 2026 00:34:28 -0600 Subject: [PATCH 14/52] Fix #861: Prevent M3U endless downloading caused by Redis lock expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large M3U downloads that exceed the 300s Redis lock TTL caused Celery Beat to re-queue duplicate tasks, creating overlapping downloads that never complete. Files changed: - core/utils.py: Add TaskLockRenewer class — a daemon thread that periodically renews Redis lock TTL (every 120s) to prevent expiry during long-running tasks. - apps/m3u/tasks.py: Apply TaskLockRenewer to refresh_single_m3u_account and refresh_m3u_groups; add HTTP timeout (30s connect, 60s read) to M3U download (the only download path missing one); stream M3U download to disk instead of accumulating in memory; add Celery task time limits (1 hour hard limit). - apps/epg/tasks.py: Apply TaskLockRenewer to refresh_epg_data and parse_programs_for_tvg_id; add Celery task time limits (30 min / 1 hour). Verified with a throttled test server: locks renewed at T+120s, T+240s, T+360s; 50,000 streams processed with no duplicate tasks. --- apps/epg/tasks.py | 26 +++- apps/m3u/tasks.py | 301 ++++++++++++++++++++++++++-------------------- core/utils.py | 69 +++++++++++ 3 files changed, 264 insertions(+), 132 deletions(-) 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/tasks.py b/apps/m3u/tasks.py index dc178317..0f2bdf66 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 [ - ' Date: Mon, 23 Feb 2026 00:48:07 -0600 Subject: [PATCH 15/52] Fix uncovered lock_renewer.stop() paths in refresh_single_m3u_account Five early-return paths released the Redis lock without stopping the renewal thread, and the final release_task_lock was outside the finally block making it unreachable on exception. All exit paths now properly stop the renewer before releasing the lock. --- apps/m3u/tasks.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 0f2bdf66..a9413143 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -2653,6 +2653,7 @@ def refresh_single_m3u_account(account_id): account = M3UAccount.objects.get(id=account_id, is_active=True) if not account.is_active: logger.debug(f"Account {account_id} is not active, skipping.") + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return @@ -2682,6 +2683,7 @@ def refresh_single_m3u_account(account_id): else: logger.debug(f"No orphaned task found for M3U account {account_id}") + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up" @@ -2732,6 +2734,7 @@ def refresh_single_m3u_account(account_id): logger.error( f"Failed to refresh M3U groups for account {account_id}: {result}" ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account - download failed or other error" @@ -2765,6 +2768,7 @@ def refresh_single_m3u_account(account_id): status="error", error=f"Error refreshing M3U groups: {str(e)}", ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account" @@ -2788,6 +2792,7 @@ def refresh_single_m3u_account(account_id): status="error", error="No data available for processing", ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account, no data available" @@ -3100,8 +3105,7 @@ def refresh_single_m3u_account(account_id): raise # Re-raise the exception for Celery to handle finally: lock_renewer.stop() - - release_task_lock("refresh_single_m3u_account", account_id) + release_task_lock("refresh_single_m3u_account", account_id) # Aggressive garbage collection # Only delete variables if they exist From 54f3ee2185b488e4f4155f8d7f68c7a90c1debcf Mon Sep 17 00:00:00 2001 From: None Date: Mon, 23 Feb 2026 18:39:54 -0600 Subject: [PATCH 16/52] Remove is_Active reference to user accounts Removed is_active references and checks for users accounts as the functionality does not align with project direction --- apps/accounts/admin.py | 2 +- apps/accounts/serializers.py | 14 -------------- apps/output/views.py | 9 --------- apps/proxy/ts_proxy/views.py | 3 --- 4 files changed, 1 insertion(+), 27 deletions(-) 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/serializers.py b/apps/accounts/serializers.py index 54bb6731..b9980f0e 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -40,7 +40,6 @@ class UserSerializer(serializers.ModelSerializer): "channel_profiles", "custom_properties", "avatar_config", - "is_active", "is_staff", "is_superuser", "last_login", @@ -52,10 +51,8 @@ class UserSerializer(serializers.ModelSerializer): def create(self, validated_data): channel_profiles = validated_data.pop("channel_profiles", []) - is_active = validated_data.pop("is_active", True) user = User(**validated_data) user.set_password(validated_data["password"]) - user.is_active = is_active user.save() user.channel_profiles.set(channel_profiles) @@ -63,17 +60,6 @@ class UserSerializer(serializers.ModelSerializer): return user def update(self, instance, validated_data): - # Prevent disabling the last active admin account - if 'is_active' in validated_data and not validated_data['is_active']: - other_active_admins = User.objects.filter( - user_level__gte=10, - is_active=True - ).exclude(id=instance.id).exists() - if not other_active_admins: - raise serializers.ValidationError( - {"is_active": "Cannot disable the last active admin account."} - ) - password = validated_data.pop("password", None) channel_profiles = validated_data.pop("channel_profiles", None) diff --git a/apps/output/views.py b/apps/output/views.py index a219a6f4..20f1736f 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1946,9 +1946,6 @@ def xc_get_user(request): user = get_object_or_404(User, username=username) - if not user.is_active: - return None - custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: @@ -2928,9 +2925,6 @@ def xc_movie_stream(request, username, password, stream_id, extension): user = get_object_or_404(User, username=username) - if not user.is_active: - return JsonResponse({"error": "Account is disabled"}, status=403) - custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: @@ -2968,9 +2962,6 @@ def xc_series_stream(request, username, password, stream_id, extension): user = get_object_or_404(User, username=username) - if not user.is_active: - return JsonResponse({"error": "Account is disabled"}, status=403) - custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index c4c7d29c..91f254a7 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -514,9 +514,6 @@ def stream_ts(request, channel_id): def stream_xc(request, username, password, channel_id): user = get_object_or_404(User, username=username) - if not user.is_active: - return JsonResponse({"error": "Account is disabled"}, status=403) - extension = pathlib.Path(channel_id).suffix channel_id = pathlib.Path(channel_id).stem From dca72f330ea56c912c62d491bfb12ceb07bfc7ca Mon Sep 17 00:00:00 2001 From: znake-oil Date: Tue, 24 Feb 2026 11:51:49 +0100 Subject: [PATCH 17/52] Remove clearing of existing episodes on refresh These 4 lines were wiping episodes before batch_process_episodes could update them in place, causing new UUIDs to be generated on every refresh. # batch_process_episodes already handles create vs update correctly using (series, season_number, episode_number) as the stable natural key. --- apps/vod/tasks.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index c24023d6..7055dbff 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1258,12 +1258,6 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N else: episodes_data = {} - # Clear existing episodes for this account to handle deletions - Episode.objects.filter( - series=series, - m3u_relations__m3u_account=account - ).delete() - # Process all episodes in batch batch_process_episodes(account, series, episodes_data) From 5785fef4df87523e3102bf18886d909042035dc7 Mon Sep 17 00:00:00 2001 From: dekzter Date: Tue, 24 Feb 2026 10:09:34 -0500 Subject: [PATCH 18/52] ability to revoke API keys, fixed bug showing another user's api key in the UI --- frontend/src/api.js | 27 +++++++++ frontend/src/components/forms/User.jsx | 76 +++++++++++++++++++++----- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index 98c94322..915633f7 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2904,6 +2904,33 @@ export default class API { } } + 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/forms/User.jsx b/frontend/src/components/forms/User.jsx index 825d4405..3f6259c0 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -15,8 +15,11 @@ import { Switch, Box, Tooltip, + Grid, + SimpleGrid, + useMantineTheme, } from '@mantine/core'; -import { RotateCcwKey, X } from 'lucide-react'; +import { RotateCcwKey, RotateCw, X } from 'lucide-react'; import { Copy, Key } from 'lucide-react'; import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; @@ -35,6 +38,8 @@ const User = ({ user = null, isOpen, onClose }) => { const [generatedKey, setGeneratedKey] = useState(null); const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null); + const theme = useMantineTheme(); + const form = useForm({ mode: 'uncontrolled', initialValues: { @@ -120,6 +125,7 @@ const User = ({ user = null, isOpen, onClose }) => { } form.reset(); + setUserAPIKey(null); onClose(); }; @@ -145,9 +151,7 @@ const User = ({ user = null, isOpen, onClose }) => { setEnableXC(true); } - if (user?.api_key) { - setUserAPIKey(user.api_key); - } + setUserAPIKey(user.api_key || null); } else { form.reset(); } @@ -194,6 +198,34 @@ const User = ({ user = null, isOpen, onClose }) => { } }; + const onRevokeKey = async () => { + if (!canGenerateKey) return; + + setGenerating(true); + try { + const payload = {}; + if (authUser.user_level == USER_LEVELS.ADMIN && user?.id) { + payload.user_id = user.id; + } + + const resp = await API.revokeApiKey(payload); + // backend returns { success: true } - clear local state + if (resp && resp.success) { + setGeneratedKey(null); + setUserAPIKey(null); + + // If we're revoking the current authenticated user's key, update auth store + if (user?.id && authUser?.id === user.id) { + setUser({ ...authUser, api_key: null }); + } + } + } catch (e) { + // API shows notifications + } finally { + setGenerating(false); + } + }; + return ( @@ -333,16 +365,32 @@ const User = ({ user = null, isOpen, onClose }) => { /> )} - + + + + {userAPIKey && ( + + )} + )} From 74ce971b756988fa61fb32dbea1bb48c5c1b19f7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 10:48:17 -0600 Subject: [PATCH 19/52] Bug Fix: Cron schedule got overwrote when changing/loading settings (signals issue) --- apps/epg/serializers.py | 10 +++++++++- apps/epg/signals.py | 30 +++++++++++++++++++++++++++--- apps/m3u/serializers.py | 9 ++++++++- apps/m3u/signals.py | 30 +++++++++++++++++++++++++++--- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 60ce097b..07775809 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -53,7 +53,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/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, From 9470600474ae0d7a50cc0e982ecfc6edde786d72 Mon Sep 17 00:00:00 2001 From: dekzter Date: Tue, 24 Feb 2026 12:13:21 -0500 Subject: [PATCH 20/52] pass all details into event triggers, updated custom script default path --- core/utils.py | 2 +- dispatcharr/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/utils.py b/core/utils.py index bd80782e..744a2c89 100644 --- a/core/utils.py +++ b/core/utils.py @@ -404,7 +404,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: diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 1fbf566b..37010f2a 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -415,7 +415,7 @@ LOGGING = { # 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") +_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/scripts") CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p] # Max execution time (seconds) for scripts From c12c77a198085980a8d3cbe1bda0ae387ef6cf64 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 11:18:06 -0600 Subject: [PATCH 21/52] changelog: Update changelog for api keys feature. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab8110f..1bf9517f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Personal API keys: Admin users can now generate personal API keys for use in scripts, integrations, and third-party tooling. Keys authenticate via the `Authorization: ApiKey ` header or the `X-API-Key: ` header. - 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) From 9bd9985d63db74af4a12032b01a6965fd312e9f6 Mon Sep 17 00:00:00 2001 From: dekzter Date: Tue, 24 Feb 2026 12:19:41 -0500 Subject: [PATCH 22/52] remove empty keys --- core/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/utils.py b/core/utils.py index 744a2c89..2c5f8ea7 100644 --- a/core/utils.py +++ b/core/utils.py @@ -464,6 +464,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: From 9e5fed44969e9478a7d3e56188041d973dcc9e0e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 11:25:47 -0600 Subject: [PATCH 23/52] Revert labels for sidebar back to System >>> Settings Remove unused imports and code. --- frontend/src/components/Sidebar.jsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 7bbf691f..49e54b45 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -5,7 +5,6 @@ import { ListOrdered, Play, Database, - SlidersHorizontal, LayoutGrid, Settings as LucideSettings, Copy, @@ -32,10 +31,8 @@ import { UnstyledButton, TextInput, ActionIcon, - Menu, ScrollArea, } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import logo from '../images/logo.png'; import useChannelsStore from '../store/channels'; import './sidebar.css'; @@ -202,7 +199,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { ], }, { - label: 'Settings', + label: 'System', icon: , paths: [ { @@ -216,7 +213,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { path: '/logos', }, { - label: 'System', + label: 'Settings', icon: , path: '/settings', }, @@ -248,11 +245,6 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }); }; - const onLogout = async () => { - await logout(); - window.location.reload(); - }; - return ( Date: Tue, 24 Feb 2026 11:30:39 -0600 Subject: [PATCH 24/52] changelog: Added clarity to api keys feature. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf9517f..bd68a78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Personal API keys: Admin users can now generate personal API keys for use in scripts, integrations, and third-party tooling. Keys authenticate via the `Authorization: ApiKey ` header or the `X-API-Key: ` header. +- 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) From 4bb75d8e847cd450c18c52ef42d71da13015b775 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 14:27:22 -0600 Subject: [PATCH 25/52] changelog: Add to changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab8110f..fb0f27f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,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 From 1791a75f1510085c78eb7f79d03092fd0489917f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 18:32:48 -0600 Subject: [PATCH 26/52] Bug Fix: Remove stale episode relations when series relations are deleted as well as remove episodes not seen in current series scan. Enhancement: Added series_relation FK to M3UEpisodeRelation table to properly link episodes with the m3useriesrelation they came from. --- CHANGELOG.md | 3 + apps/vod/admin.py | 2 +- ...0004_m3uepisoderelation_series_relation.py | 19 ++++++ apps/vod/models.py | 8 +++ apps/vod/tasks.py | 58 +++++++++++++------ 5 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 apps/vod/migrations/0004_m3uepisoderelation_series_relation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab8110f..d259c634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ 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. ### Changed @@ -75,6 +76,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 diff --git a/apps/vod/admin.py b/apps/vod/admin.py index c660f310..6dadab3e 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -60,7 +60,7 @@ class M3USeriesRelationAdmin(admin.ModelAdmin): @admin.register(M3UEpisodeRelation) class M3UEpisodeRelationAdmin(admin.ModelAdmin): - list_display = ['episode', 'm3u_account', 'stream_id', 'created_at'] + list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at'] list_filter = ['m3u_account', 'created_at'] search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py new file mode 100644 index 00000000..e4973627 --- /dev/null +++ b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-24 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='series_relation', + field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index 7067856e..dd7d1753 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -261,6 +261,14 @@ class M3UEpisodeRelation(models.Model): """Links M3U accounts to Episodes with provider-specific information""" m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) # Streaming information (provider-specific) stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 7055dbff..1b12c7b7 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1258,15 +1258,16 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N else: episodes_data = {} - # Process all episodes in batch - batch_process_episodes(account, series, episodes_data) - - # Update the series relation to mark episodes as fetched + # Fetch the series relation once — used both to pass into batch_process_episodes + # (so episode relations get the FK set) and to update metadata afterwards. series_relation = M3USeriesRelation.objects.filter( - series=series, - m3u_account=account + m3u_account=account, + external_series_id=external_series_id ).first() + # Process all episodes in batch + batch_process_episodes(account, series, episodes_data, series_relation=series_relation) + if series_relation: custom_props = series_relation.custom_properties or {} custom_props['episodes_fetched'] = True @@ -1279,13 +1280,18 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}") -def batch_process_episodes(account, series, episodes_data, scan_start_time=None): +def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None): """Process episodes in batches for better performance. Note: Multiple streams can represent the same episode (e.g., different languages or qualities). Each stream has a unique stream_id, but they share the same season/episode number. We create one Episode record per (series, season, episode) and multiple M3UEpisodeRelation records pointing to it. + + series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so + that CASCADE correctly removes episode relations when their parent series relation + is deleted, and so that stale-stream cleanup is scoped precisely to relations that + came from this specific provider query. """ if not episodes_data: return @@ -1439,10 +1445,11 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Update existing relation relation = existing_relations[episode_id] relation.episode = episode + relation.series_relation = series_relation relation.container_extension = episode_data.get('container_extension', 'mp4') relation.custom_properties = { 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, } relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan relations_to_update.append(relation) @@ -1451,11 +1458,12 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) relation = M3UEpisodeRelation( m3u_account=account, episode=episode, + series_relation=series_relation, stream_id=episode_id, container_extension=episode_data.get('container_extension', 'mp4'), custom_properties={ 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, }, last_seen=scan_start_time or timezone.now() # Mark as seen during this scan ) @@ -1518,9 +1526,28 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Update existing episode relations if relations_to_update: M3UEpisodeRelation.objects.bulk_update(relations_to_update, [ - 'episode', 'container_extension', 'custom_properties', 'last_seen' + 'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen' ]) + # Delete relations for streams no longer returned by the provider. + # Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows + # for the same account+series (pre-migration rows whose stream is now gone — the + # update path only backfills the FK for streams still present in the response). + # Falls back to account+series scope when series_relation is None (shouldn't occur). + if series_relation is not None: + stale_qs = M3UEpisodeRelation.objects.filter( + Q(series_relation=series_relation) | + Q(series_relation__isnull=True, m3u_account=account, episode__series=series) + ) + else: + stale_qs = M3UEpisodeRelation.objects.filter( + m3u_account=account, + episode__series=series + ) + removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0] + if removed_count: + logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}") + logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, " f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations") @@ -1605,16 +1632,12 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= stale_movie_count = stale_movie_relations.count() stale_movie_relations.delete() - # Clean up stale series relations + # Clean up stale series relations. + # Episode relations are removed via CASCADE on the series_relation FK. stale_series_relations = M3USeriesRelation.objects.filter(**base_filters) stale_series_count = stale_series_relations.count() stale_series_relations.delete() - # Clean up stale episode relations - stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters) - stale_episode_count = stale_episode_relations.count() - stale_episode_relations.delete() - # Clean up movies with no relations (orphaned) # Safe to delete even during account-specific cleanup because if ANY account # has a relation, m3u_relations will not be null @@ -1631,11 +1654,8 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations") orphaned_series.delete() - # Episodes will be cleaned up via CASCADE when series are deleted - result = (f"Cleaned up {stale_movie_count} stale movie relations, " f"{stale_series_count} stale series relations, " - f"{stale_episode_count} stale episode relations, " f"{orphaned_movie_count} orphaned movies, and " f"{orphaned_series_count} orphaned series") From 26a6753dccdd6f4b7e86610c6d138ec6848779e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 19:48:12 -0600 Subject: [PATCH 27/52] Enhancement: 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. --- frontend/src/components/forms/LoginForm.jsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index 4e973891..d190cba2 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; import useSettingsStore from '../../store/settings'; +import { notifications } from '@mantine/notifications'; import { Paper, Title, @@ -130,6 +131,16 @@ const LoginForm = () => { // Navigation will happen automatically via the useEffect or route protection } catch (e) { console.log(`Failed to login: ${e}`); + if (e?.message === 'Unauthorized') { + notifications.show({ + title: 'Web UI Access Denied', + message: + 'This account is a Streamer account and cannot log into the web UI. ' + + 'Your M3U and stream URLs still work. Contact an admin to upgrade your account level.', + color: 'red', + autoClose: 10000, + }); + } await logout(); setIsLoading(false); } From 246c080f99d41360d4b831efff2c77a92dab59be Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 19:48:59 -0600 Subject: [PATCH 28/52] changelog: Update changelog for recent PR and login notification. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5652640a..a1342811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 @@ -68,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) From e0e195da92a76a340638b13b8e4f4f8a500ced04 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 20:01:56 -0600 Subject: [PATCH 29/52] changelog: Update changelog for get_series api change. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1342811..410baed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ 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) ### Fixed From 03c8d55ab9d9d8718794e64d902099aa66b6e7ae Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Wed, 25 Feb 2026 08:32:40 -0600 Subject: [PATCH 30/52] Fix legacy plugin actions missing events Treat action.events as optional ([] by default) and guard UI rendering so Plugin cards no longer crash. --- frontend/src/components/cards/PluginCard.jsx | 71 +++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) 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 }) => { From 4d6351507f133987f56873a23f3f2a259fadfee8 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 25 Feb 2026 10:59:45 -0500 Subject: [PATCH 31/52] enhanced webhooks to support custom headers and payload templates --- apps/connect/api_views.py | 4 +- apps/connect/handlers/webhook.py | 15 +- apps/connect/utils.py | 9 +- frontend/src/components/forms/Connection.jsx | 284 ++++++++++++++----- 4 files changed, 239 insertions(+), 73 deletions(-) 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/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/frontend/src/components/forms/Connection.jsx b/frontend/src/components/forms/Connection.jsx index e8077d43..c0ec09c1 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} + + +