Merge remote-tracking branch 'upstream/dev' into test/component-cleanup

This commit is contained in:
Nick Sandstrom 2026-03-02 23:19:43 -08:00
commit 08d79fb4db
56 changed files with 1670 additions and 554 deletions

View file

@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.20.2] - 2026-03-03
### Security
- Updated frontend npm dependencies to resolve 2 high-severity vulnerabilities:
- Updated `minimatch` to ≥10.2.3, resolving **high** ReDoS via matchOne() combinatorial backtracking with multiple non-adjacent GLOBSTAR segments ([GHSA-7r86-cg39-jmmj](https://github.com/advisories/GHSA-7r86-cg39-jmmj))
- Updated `rollup` to ≥4.58.1, resolving **high** Arbitrary File Write via Path Traversal ([GHSA-mw96-cpmx-2vgc](https://github.com/advisories/GHSA-mw96-cpmx-2vgc))
### Fixed
- EPG filter regression in channel table (introduced in 0.20.0 channel store refactor): The EPG filter dropdown was showing all EPG sources regardless of whether they had any channels assigned, and the "No EPG" option was never displayed. Fixed by annotating EPGSource records with a `has_channels` flag (via a lightweight `EXISTS` subquery) so only active EPG sources with at least one channel assigned appear as filter options. "No EPG" now appears only when at least one channel globally has no EPG assigned; this is determined by a second `EXISTS` query embedded directly in the paginated channel response (`has_unassigned_epg_channels`), avoiding any additional network requests.
- Stale stream rows missing hover effect: Stale streams in the streams table had no hover color change, unlike channels with no streams assigned. Converted the inline `backgroundColor` style to a CSS class (`stale-stream-row`) so the `:hover` rule can apply correctly. Applied the same fix to the channel-streams sub-table, where the teal expanded-row background caused the semi-transparent red tint to visually mismatch; the sub-table now uses a pre-blended solid color via `color-mix()` to match the appearance of stale rows in the main streams table.
- Channel table onboarding shown when filter returns zero results: The channel store refactor changed to loading only channel IDs instead of full channel objects, leaving `Object.keys(channels).length` always `0` and incorrectly triggering the onboarding state on any empty filter. Fixed by checking `channelIds.length` instead.
- TV Guide scrolls to position 0 when a filter yields no results: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before.
- `debian_install.sh` regressions after `uv` migration on clean/minimal Debian installs: fixed pip-less venv (`ensurepip`), missing `gunicorn` for the systemd unit, and inconsistent `DJANGO_SECRET_KEY` availability (now persisted to `.env` via `EnvironmentFile`). Docker unaffected. - Thanks [@marcinolek](https://github.com/marcinolek)
## [0.20.1] - 2026-02-26
### Fixed
- Login form disabled after token expiry: The login button was permanently rendered as disabled ("Logging you in...") on page load after a session expired, preventing users from logging back in. A regression in v0.20.0 caused `LoginForm` to check `if (user)` to detect an already-authenticated reload, but the Zustand auth store initializes `user` as a truthy empty object `{ username: '', email: '', user_level: '' }`, so the loading state was set immediately on every mount. Reverted to pre-regression behavior. (Fixes #1029)
## [0.20.0] - 2026-02-26
### Security
- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs:
@ -22,9 +46,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- API key authentication: Added support for API key-based authentication as an alternative to JWT tokens. Users can generate and revoke their own personal API key from their profile page, enabling programmatic access for scripts, automations, and third-party integrations without exposing account credentials. Keys authenticate via the `Authorization: ApiKey <key>` header or the `X-API-Key: <key>` header. Admin users can additionally generate and revoke keys on behalf of any user.
- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations.
- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942)
- Event-driven webhooks and script execution (Connect): Added new Connect feature that enables event-driven execution of custom scripts and webhooks in response to system events. Supports multiple event types including channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect). Event data is available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads. Plugins can now subscribe to events by specifying an `events` array in their action definitions. Includes connection testing endpoint with dummy payloads for validation. (Closes #203)
- Event-driven webhooks and script execution (Integrations): Added new Integrations feature that enables event-driven execution of custom scripts and webhooks in response to system events. (Closes #203)
- **Supported event types**: channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect)
- **Event data delivery**: available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads
- **Plugin support**: plugins can subscribe to events by specifying an `events` array in their action definitions
- **Connection testing**: test endpoint with dummy payloads for validation before going live
- **Custom HTTP headers**: webhook connections support configurable key/value header pairs
- **Per-event Jinja2 payload templates**: each enabled event can have its own template rendered with the full event payload as context; rendered output is sent as JSON (with `Content-Type: application/json` set automatically) if valid, or as a raw string body otherwise
- **Tabbed connection form**: Settings, Event Triggers, and Payload Templates organized into separate tabs for clarity
- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165)
- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups:
- **Fixed Start Number** (default): Start at a specified number and increment sequentially
@ -32,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Next Available**: Auto-assign starting from 1, skipping all used channel numbers
Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433)
- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd)
- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup.
- Streamer accounts attempting to log into the web UI now receive a clear notification explaining they cannot access the UI but their stream URLs still work. Previously the login button would silently stop with no feedback.
### Changed
@ -63,9 +97,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity.
- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible.
- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839)
- XC API `get_series` now includes `tmdb_id` and `imdb_id` fields, matching `get_vod_streams`. Clients that use TMDB enrichment (e.g. Chillio) can now resolve clean series titles and poster images. - Thanks [@firestaerter3](https://github.com/firestaerter3)
- Stats page "Now Playing" EPG lookup updated to use `channel_uuids` directly (the proxy stats already key active channels by UUID), removing the need for a UUID→integer ID conversion step introduced alongside the lazy channel-fetch refactor. Stream preview sessions (which use a content hash rather than a UUID as their channel ID) are now filtered out before any API call is made, preventing a backend `ValidationError` on both the `current-programs` and `by-uuids` endpoints when a stream preview is active on the Stats page.
### Fixed
- Fixed admin permission checks inconsistently using `is_superuser`/`is_staff` instead of `user_level>=10`, causing API-created admin accounts to intermittently see the setup page, lose access to backup endpoints, and miss admin-only notifications. `manage.py createsuperuser` now also correctly sets `user_level=10`. (Fixes #954) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Channel table group filter sort order: The group dropdown in the channel table is now sorted alphabetically.
- DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time.
- XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978)
@ -75,6 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique.
- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil)
- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried.
## [0.19.0] - 2026-02-10
@ -149,6 +188,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths.
- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action.
- Plugin enable/disable toggles now update immediately without requiring a full page refresh.
- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.18.1] - 2026-01-27

View file

@ -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')}),
)

View file

@ -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"})

View file

@ -9,6 +9,7 @@ from rest_framework import viewsets, status, serializers
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
import json
import secrets
from .permissions import IsAdmin, Authenticated
from dispatcharr.utils import network_access_allowed
@ -118,8 +119,8 @@ class TokenRefreshView(TokenRefreshView):
@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists.
def initialize_superuser(request):
# If a superuser already exists, always indicate that
if User.objects.filter(is_superuser=True).exists():
# If an admin-level user already exists, the system is configured
if User.objects.filter(user_level__gte=10).exists():
return JsonResponse({"superuser_exists": True})
if request.method == "POST":
@ -318,6 +319,59 @@ class GroupViewSet(viewsets.ModelViewSet):
return super().destroy(request, *args, **kwargs)
# API Key management
class APIKeyViewSet(viewsets.ViewSet):
permission_classes = [Authenticated]
def list(self, request):
user = request.user
return Response({"key": user.api_key})
@action(detail=False, methods=["post"], url_path="generate")
def generate(self, request):
target_user = request.user
user_id = request.data.get("user_id")
if user_id:
from .permissions import IsAdmin
if not IsAdmin().has_permission(request, self):
return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN)
try:
target_user = User.objects.get(id=int(user_id))
except Exception:
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
raw = secrets.token_urlsafe(40)
target_user.api_key = raw
target_user.save(update_fields=["api_key"])
user_data = UserSerializer(target_user).data
return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=["post"], url_path="revoke")
def revoke(self, request):
target_user = request.user
user_id = request.data.get("user_id")
if user_id:
from .permissions import IsAdmin
if not IsAdmin().has_permission(request, self):
return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN)
try:
target_user = User.objects.get(id=int(user_id))
except Exception:
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
target_user.api_key = None
target_user.save(update_fields=["api_key"])
return Response({"success": True})
# 🔹 4) Permissions List API
@extend_schema(
description="Retrieve a list of all permissions",

View file

@ -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 <key>` or `X-API-Key: <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

View file

@ -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),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.11 on 2026-02-26 19:24
import apps.accounts.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_user_api_key'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', apps.accounts.models.CustomUserManager()),
],
),
]

View file

@ -1,9 +1,16 @@
# apps/accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import AbstractUser, Permission, UserManager
class CustomUserManager(UserManager):
def create_superuser(self, username, email=None, password=None, **extra_fields):
extra_fields.setdefault('user_level', 10)
return super().create_superuser(username, email, password, **extra_fields)
class User(AbstractUser):
objects = CustomUserManager()
"""
Custom user model for Dispatcharr.
Inherits from Django's AbstractUser to add additional fields if needed.
@ -22,6 +29,7 @@ class User(AbstractUser):
)
user_level = models.IntegerField(default=UserLevel.STREAMER)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
def __str__(self):
return self.username

View file

@ -28,19 +28,20 @@ class UserSerializer(serializers.ModelSerializer):
channel_profiles = serializers.PrimaryKeyRelatedField(
queryset=ChannelProfile.objects.all(), many=True, required=False
)
api_key = serializers.CharField(read_only=True, allow_null=True)
class Meta:
model = User
fields = [
"id",
"username",
"api_key",
"email",
"user_level",
"password",
"channel_profiles",
"custom_properties",
"avatar_config",
"is_active",
"is_staff",
"is_superuser",
"last_login",
@ -54,7 +55,6 @@ class UserSerializer(serializers.ModelSerializer):
user = User(**validated_data)
user.set_password(validated_data["password"])
user.is_active = True
user.save()
user.channel_profiles.set(channel_profiles)

72
apps/accounts/tests.py Normal file
View file

@ -0,0 +1,72 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
User = get_user_model()
class InitializeSuperuserTests(TestCase):
"""Tests for the initialize_superuser endpoint"""
def setUp(self):
self.client = APIClient()
self.url = "/api/accounts/initialize-superuser/"
def test_returns_true_when_superuser_exists(self):
"""Superuser with is_superuser=True should be detected"""
User.objects.create_superuser(
username="admin", password="testpass123", user_level=10
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
def test_returns_true_when_admin_level_user_exists(self):
"""User with user_level=10 but is_superuser=False should be detected"""
user = User.objects.create_user(username="admin", password="testpass123")
user.user_level = 10
user.is_superuser = False
user.save()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
def test_returns_false_when_no_admin_exists(self):
"""No admin or superuser should return false"""
# Create a non-admin user
User.objects.create_user(username="regular", password="testpass123")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["superuser_exists"])
def test_returns_false_when_no_users_exist(self):
"""Empty database should return false"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["superuser_exists"])
def test_create_superuser_when_none_exists(self):
"""POST should create superuser when none exists"""
response = self.client.post(
self.url,
{"username": "newadmin", "password": "testpass123", "email": "admin@test.com"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists())
def test_cannot_create_superuser_when_admin_exists(self):
"""POST should fail when an admin-level user already exists"""
user = User.objects.create_user(username="existing", password="testpass123")
user.user_level = 10
user.save()
response = self.client.post(
self.url,
{"username": "newadmin", "password": "testpass123"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
# Should NOT have created a new user
self.assertFalse(User.objects.filter(username="newadmin").exists())

View file

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

View file

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

View file

@ -413,6 +413,13 @@ class ChannelPagination(PageNumberPagination):
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):
from django.db.models import Exists, OuterRef
has_unassigned = Channel.objects.filter(epg_data__isnull=True).exists()
response = super().get_paginated_response(data)
response.data['has_unassigned_epg_channels'] = has_unassigned
return response
class EPGFilter(django_filters.Filter):
"""

View file

@ -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,
}
)

View file

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

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-26 19:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_connect', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='eventsubscription',
name='event',
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100),
),
]

View file

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

View file

@ -42,6 +42,17 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_queryset(self):
from django.db.models import Exists, OuterRef
from apps.channels.models import Channel
return EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).annotate(
has_channels=Exists(
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
)
)
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG sources.")
return super().list(request, *args, **kwargs)
@ -441,43 +452,32 @@ class CurrentProgramsAPIView(APIView):
request=inline_serializer(
name="CurrentProgramsRequest",
fields={
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
"channel_uuids": serializers.ListField(
child=serializers.CharField(),
required=False,
allow_null=True,
help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
),
},
),
responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Get channel IDs from request body
channel_ids = request.data.get('channel_ids', None)
# Import Channel model
from apps.channels.models import Channel
# Build query for channels with EPG data
query = Channel.objects.filter(epg_data__isnull=False)
# Filter by specific channel IDs if provided
if channel_ids is not None:
if not isinstance(channel_ids, list):
channel_uuids = request.data.get('channel_uuids', None)
if channel_uuids is not None:
if not isinstance(channel_uuids, list):
return Response(
{"error": "channel_ids must be an array of integers or null"},
{"error": "channel_uuids must be an array of strings or null"},
status=status.HTTP_400_BAD_REQUEST
)
try:
channel_ids = [int(id) for id in channel_ids]
except (ValueError, TypeError):
return Response(
{"error": "channel_ids must contain valid integers"},
status=status.HTTP_400_BAD_REQUEST
)
query = query.filter(id__in=channel_ids)
query = query.filter(uuid__in=channel_uuids)
# Get channels with EPG data
channels = query.select_related('epg_data')
@ -497,9 +497,8 @@ class CurrentProgramsAPIView(APIView):
).first()
if program:
# Serialize program and add channel_id for easy mapping
program_data = ProgramDataSerializer(program).data
program_data['channel_id'] = channel.id
program_data['channel_uuid'] = str(channel.uuid)
current_programs.append(program_data)
return Response(current_programs, status=status.HTTP_200_OK)

View file

@ -5,6 +5,7 @@ from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer):
epg_data_count = serializers.SerializerMethodField()
has_channels = serializers.BooleanField(read_only=True, default=False)
read_only_fields = ['created_at', 'updated_at']
url = serializers.CharField(
required=False,
@ -32,7 +33,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
'created_at',
'updated_at',
'custom_properties',
'epg_data_count'
'epg_data_count',
'has_channels',
]
def get_epg_data_count(self, obj):
@ -53,7 +55,15 @@ class EPGSourceSerializer(serializers.ModelSerializer):
return data
def update(self, instance, validated_data):
cron_expr = validated_data.pop('cron_expression', '')
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if 'cron_expression' in validated_data:
cron_expr = validated_data.pop('cron_expression')
else:
cron_expr = ''
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
instance._cron_expression = cron_expr
for attr, value in validated_data.items():
setattr(instance, attr, value)

View file

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

View file

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

View file

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

View file

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

View file

@ -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 [
'<html', '<!doctype html', 'error', 'not found', '404', '403', '500',
'access denied', 'unauthorized', 'forbidden', 'invalid', 'expired'
]):
logger.warning(f"Content appears to be an error response disguised as HTTP 200: {content_str[:200]!r}")
# Continue with M3U validation, but this gives us a clue
if content_lines and content_lines[0].strip().upper().startswith('#EXTM3U'):
is_valid_m3u = True
logger.info("Content validated as M3U: starts with #EXTM3U")
elif any(line.strip().startswith('#EXTINF:') for line in content_lines):
is_valid_m3u = True
logger.info("Content validated as M3U: contains #EXTINF entries")
elif any(line.strip().startswith('http') for line in content_lines):
# Has HTTP URLs, might be a simple M3U without headers
is_valid_m3u = True
logger.info("Content validated as M3U: contains HTTP URLs")
elif any(line.strip().startswith(('rtsp', 'rtp', 'udp')) for line in content_lines):
# Has RTSP/RTP/UDP URLs, might be a simple M3U without headers
is_valid_m3u = True
logger.info("Content validated as M3U: contains RTSP/RTP/UDP URLs")
if not is_valid_m3u:
# Log what we actually received for debugging
logger.error(f"Invalid M3U content received. First 200 characters: {content_str[:200]!r}")
# Try to provide more specific error messages based on content
if '<html' in content_lower or '<!doctype html' in content_lower:
error_msg = f"Server returned HTML page instead of M3U file from URL: {account.server_url}. This usually indicates an error or authentication issue."
elif 'error' in content_lower or 'not found' in content_lower:
error_msg = f"Server returned an error message instead of M3U file from URL: {account.server_url}. Content: {content_str[:100]}"
elif len(content_str.strip()) == 0:
error_msg = f"Server returned completely empty response from URL: {account.server_url}"
else:
error_msg = f"Server provided invalid M3U content from URL: {account.server_url}. Content does not appear to be a valid M3U file."
# Check if we actually received any content
logger.info(f"Download completed. Has content: {has_content}, Content length: {downloaded} bytes")
if not has_content or downloaded == 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
@ -252,31 +195,113 @@ def fetch_m3u_lines(account, use_cache=False):
)
return [], False
except UnicodeDecodeError:
logger.error(f"Non-text content received. First 200 bytes: {temp_content[:200]!r}")
error_msg = f"Server provided non-text content from URL: {account.server_url}. Unable to process as M3U file."
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
# Validate the file by reading only the first portion from
# disk — no need to load the entire file into memory just
# to check the header.
VALIDATION_READ_SIZE = 32768 # 32KB covers headers comfortably
try:
with open(temp_path, "rb") as vf:
head_bytes = vf.read(VALIDATION_READ_SIZE)
head_str = head_bytes.decode('utf-8', errors='ignore')
head_lines = head_str.strip().split('\n')
# Content is valid, save it to file
with open(file_path, "wb") as file:
file.write(temp_content)
# Count total lines efficiently without loading full file
with open(temp_path, "rb") as vf:
total_lines = sum(1 for _ in vf)
# Final update with 100% progress
final_msg = f"Download complete. Size: {total_size/1024/1024:.2f} MB, Time: {time.time() - start_time:.1f}s"
account.last_message = final_msg
account.save(update_fields=["last_message"])
send_m3u_update(account.id, "downloading", 100, message=final_msg)
# Log first few lines for debugging (be careful not to log too much)
preview_lines = head_lines[:5]
logger.info(f"Content preview (first 5 lines): {preview_lines}")
logger.info(f"Total lines in content: {total_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
head_lower = head_str.lower()
if any(error_indicator in head_lower for error_indicator in [
'<html', '<!doctype html', 'error', 'not found', '404', '403', '500',
'access denied', 'unauthorized', 'forbidden', 'invalid', 'expired'
]):
logger.warning(f"Content appears to be an error response disguised as HTTP 200: {head_str[:200]!r}")
# Continue with M3U validation, but this gives us a clue
if head_lines and head_lines[0].strip().upper().startswith('#EXTM3U'):
is_valid_m3u = True
logger.info("Content validated as M3U: starts with #EXTM3U")
elif any(line.strip().startswith('#EXTINF:') for line in head_lines):
is_valid_m3u = True
logger.info("Content validated as M3U: contains #EXTINF entries")
elif any(line.strip().startswith('http') for line in head_lines):
# Has HTTP URLs, might be a simple M3U without headers
is_valid_m3u = True
logger.info("Content validated as M3U: contains HTTP URLs")
elif any(line.strip().startswith(('rtsp', 'rtp', 'udp')) for line in head_lines):
# Has RTSP/RTP/UDP URLs, might be a simple M3U without headers
is_valid_m3u = True
logger.info("Content validated as M3U: contains RTSP/RTP/UDP URLs")
if not is_valid_m3u:
# Log what we actually received for debugging
logger.error(f"Invalid M3U content received. First 200 characters: {head_str[:200]!r}")
# Try to provide more specific error messages based on content
if '<html' in head_lower or '<!doctype html' in head_lower:
error_msg = f"Server returned HTML page instead of M3U file from URL: {account.server_url}. This usually indicates an error or authentication issue."
elif 'error' in head_lower or 'not found' in head_lower:
error_msg = f"Server returned an error message instead of M3U file from URL: {account.server_url}. Content: {head_str[:100]}"
elif len(head_str.strip()) == 0:
error_msg = f"Server returned completely empty response from URL: {account.server_url}"
else:
error_msg = f"Server provided invalid M3U content from URL: {account.server_url}. Content does not appear to be a valid M3U file."
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
except UnicodeDecodeError:
with open(temp_path, "rb") as vf:
first_bytes = vf.read(200)
logger.error(f"Non-text content received. First 200 bytes: {first_bytes!r}")
error_msg = f"Server provided non-text content from URL: {account.server_url}. Unable to process as M3U file."
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
# Validation passed — promote temp file to final path
os.replace(temp_path, file_path)
# Final update with 100% progress
dl_size = downloaded / 1024 / 1024
final_msg = f"Download complete. Size: {dl_size:.2f} MB, Time: {time.time() - start_time:.1f}s"
account.last_message = final_msg
account.save(update_fields=["last_message"])
send_m3u_update(account.id, "downloading", 100, message=final_msg)
finally:
# Clean up temp file on any failure path
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
except requests.exceptions.HTTPError as e:
# Handle HTTP errors specifically with more context
status_code = e.response.status_code if e.response else "unknown"
@ -1210,9 +1235,13 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
if not acquire_task_lock("refresh_m3u_account_groups", account_id):
return f"Task already running for account_id={account_id}.", None
lock_renewer = TaskLockRenewer("refresh_m3u_account_groups", account_id)
lock_renewer.start()
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
except M3UAccount.DoesNotExist:
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return f"M3UAccount with ID={account_id} not found or inactive.", None
@ -1238,6 +1267,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
send_m3u_update(
account_id, "processing_groups", 100, status="error", error=error_msg
)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
@ -1250,6 +1280,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
send_m3u_update(
account_id, "processing_groups", 100, status="error", error=error_msg
)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
@ -1359,6 +1390,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
status="error",
error=error_msg,
)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
@ -1397,6 +1429,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
status="error",
error=error_msg,
)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
@ -1413,6 +1446,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
status="error",
error=error_msg,
)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
except Exception as e:
@ -1424,6 +1458,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
send_m3u_update(
account_id, "processing_groups", 100, status="error", error=error_msg
)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return error_msg, None
else:
@ -1431,6 +1466,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
lines, success = fetch_m3u_lines(account, use_cache)
if not success:
# If fetch failed, don't continue processing
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
return f"Failed to fetch M3U data for account_id={account_id}.", None
@ -1525,6 +1561,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
process_groups(account, groups, scan_start_time)
lock_renewer.stop()
release_task_lock("refresh_m3u_account_groups", account_id)
if not full_refresh:
@ -2593,12 +2630,18 @@ def refresh_account_info(profile_id):
release_task_lock("refresh_account_info", profile_id)
return error_msg
@shared_task
@shared_task(time_limit=3600, soft_time_limit=3500)
def refresh_single_m3u_account(account_id):
"""Splits M3U processing into chunks and dispatches them as parallel tasks."""
if not acquire_task_lock("refresh_single_m3u_account", account_id):
return f"Task already running for account_id={account_id}."
# Keep the lock alive while this long-running task is working.
# Without renewal, the 300s lock TTL can expire during large
# downloads/parses, allowing duplicate tasks to start.
lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id)
lock_renewer.start()
# Record start time
refresh_start_timestamp = timezone.now() # For the cleanup function
start_time = time.time() # For tracking elapsed time as float
@ -2610,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
@ -2639,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"
@ -2689,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"
@ -2722,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"
@ -2745,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"
@ -3055,8 +3103,9 @@ def refresh_single_m3u_account(account_id):
account.last_message = f"Error processing M3U: {str(e)}"
account.save(update_fields=["status", "last_message"])
raise # Re-raise the exception for Celery to handle
release_task_lock("refresh_single_m3u_account", account_id)
finally:
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
# Aggressive garbage collection
# Only delete variables if they exist

View file

@ -1945,6 +1945,7 @@ def xc_get_user(request):
return None
user = get_object_or_404(User, username=username)
custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
@ -2559,6 +2560,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

View file

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

View file

@ -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'),
),
]

View file

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

View file

@ -1258,20 +1258,15 @@ 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()
# 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(
m3u_account=account,
external_series_id=external_series_id
).first()
# Process all episodes in batch
batch_process_episodes(account, series, episodes_data)
# Update the series relation to mark episodes as fetched
series_relation = M3USeriesRelation.objects.filter(
series=series,
m3u_account=account
).first()
batch_process_episodes(account, series, episodes_data, series_relation=series_relation)
if series_relation:
custom_props = series_relation.custom_properties or {}
@ -1285,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
@ -1445,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)
@ -1457,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
)
@ -1524,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")
@ -1611,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
@ -1637,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")

View file

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

View file

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

View file

@ -222,6 +222,75 @@ def release_task_lock(task_name, id):
# Remove the lock
redis_client.delete(lock_id)
class TaskLockRenewer:
"""Periodically renews a Redis task lock to prevent expiry during long-running tasks.
Use as a context manager after acquiring a lock:
if acquire_task_lock("my_task", task_id):
with TaskLockRenewer("my_task", task_id):
# ... long-running work ...
release_task_lock("my_task", task_id)
A daemon thread extends the lock TTL at regular intervals so that
slow downloads or large parsing jobs don't lose their lock mid-operation.
"""
def __init__(self, task_name, id, ttl=300, renewal_interval=120):
self.task_name = task_name
self.id = id
self.ttl = ttl
self.renewal_interval = renewal_interval
self.lock_id = f"task_lock_{task_name}_{id}"
self._stop_event = threading.Event()
self._thread = None
def _renew_loop(self):
"""Background loop that extends the lock TTL until stopped."""
while not self._stop_event.wait(self.renewal_interval):
try:
redis_client = RedisClient.get_client()
if redis_client.exists(self.lock_id):
redis_client.expire(self.lock_id, self.ttl)
logger.debug(
f"Renewed lock {self.lock_id} TTL to {self.ttl}s"
)
else:
# Lock was deleted externally (e.g. manual release) — stop renewing
logger.warning(
f"Lock {self.lock_id} no longer exists, stopping renewal"
)
break
except Exception as e:
logger.error(f"Error renewing lock {self.lock_id}: {e}")
def start(self):
"""Start the background renewal thread."""
self._stop_event.clear()
self._thread = threading.Thread(
target=self._renew_loop, daemon=True,
name=f"lock-renew-{self.task_name}-{self.id}"
)
self._thread.start()
return self
def stop(self):
"""Stop the renewal thread."""
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
self._thread = None
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
return False
def send_websocket_update(group_name, event_type, data, collect_garbage=False):
"""
Standardized function to send WebSocket updates with proper memory management.
@ -404,7 +473,7 @@ def dispatch_event_system(event_type, channel_id=None, channel_name=None, **deta
from core.models import StreamProfile
from core.utils import RedisClient
payload = {}
payload = dict(details)
channel_obj = None
if channel_id:
@ -464,6 +533,11 @@ def dispatch_event_system(event_type, channel_id=None, channel_name=None, **deta
payload["profile_used"] = profile_used
# remove empty keys
for k in list(payload.keys()):
if not payload[k]:
del payload[k]
trigger_event(event_type, payload)
except Exception as e:

View file

@ -168,24 +168,51 @@ install_uv() {
setup_python_env() {
echo ">>> Setting up Python virtual environment with UV..."
# Install UV globally first
install_uv
su - "$DISPATCH_USER" <<EOSU
cd "$APP_DIR"
export PATH="\$HOME/.local/bin:\$PATH"
# Install UV for the dispatch user if not already available
if ! command -v uv &> /dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | sh
set -euo pipefail
cd "$APP_DIR"
export PATH="\$HOME/.local/bin:\$PATH"
fi
# Create venv and install dependencies using UV
uv venv env --python $PYTHON_BIN
uv sync --python env/bin/python --no-install-project --no-dev
command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
rm -rf env
$PYTHON_BIN -m venv env
env/bin/python -m ensurepip --upgrade
export UV_PROJECT_ENVIRONMENT="$APP_DIR/env"
uv sync --no-dev
env/bin/python -m pip install -q gunicorn
EOSU
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
}
##############################################################################
# 6.1) Ensure Environment File
##############################################################################
ensure_env_file() {
echo ">>> Ensuring DJANGO_SECRET_KEY exists in ${APP_DIR}/.env..."
su - "$DISPATCH_USER" <<EOSU
set -euo pipefail
cd "$APP_DIR"
touch .env
chmod 600 .env
if ! grep -q '^DJANGO_SECRET_KEY=' .env; then
key=\$(env/bin/python - <<'PY'
import secrets
print(secrets.token_urlsafe(64))
PY
)
echo "DJANGO_SECRET_KEY=\$key" >> .env
fi
EOSU
}
##############################################################################
# 7) Build Frontend
##############################################################################
@ -231,17 +258,21 @@ create_directories() {
django_migrate_collectstatic() {
echo ">>> Running Django migrations & collectstatic..."
su - "$DISPATCH_USER" <<EOSU
set -euo pipefail
cd "$APP_DIR"
source env/bin/activate
set -a
source .env
set +a
export POSTGRES_DB="$POSTGRES_DB"
export POSTGRES_USER="$POSTGRES_USER"
export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"
export POSTGRES_HOST="localhost"
python manage.py migrate --noinput
python manage.py collectstatic --noinput
env/bin/python manage.py migrate --noinput
env/bin/python manage.py collectstatic --noinput
EOSU
}
##############################################################################
# 10) Configure Services & Nginx
##############################################################################
@ -261,6 +292,7 @@ Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
RuntimeDirectoryMode=0775
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
@ -293,6 +325,7 @@ Requires=dispatcharr.service
User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
@ -320,6 +353,7 @@ Requires=dispatcharr.service
User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
@ -347,6 +381,7 @@ Requires=dispatcharr.service
User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
@ -448,6 +483,7 @@ main() {
setup_python_env
build_frontend
create_directories
ensure_env_file
django_migrate_collectstatic
configure_services
start_services

View file

@ -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"],
}
@ -415,7 +416,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

View file

@ -4,6 +4,7 @@ services:
# context: .
# dockerfile: Dockerfile
image: ghcr.io/dispatcharr/dispatcharr:latest
restart: unless-stopped
container_name: dispatcharr
ports:
- 9191:9191

View file

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

View file

@ -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
@ -137,6 +139,7 @@ services:
db:
image: postgres:17
container_name: dispatcharr_db
restart: unless-stopped
ports:
- "5436:5432"
environment:
@ -157,6 +160,7 @@ services:
redis:
image: redis:latest
container_name: dispatcharr_redis
restart: unless-stopped
# --- Authentication Configuration (Optional) ---
# By default, Redis runs without authentication.

View file

@ -10,6 +10,7 @@ DATA_DIRS=(
"/data/epgs"
"/data/plugins"
"/data/models"
"/data/scripts"
)
APP_DIRS=(
@ -77,4 +78,4 @@ if [ "$(id -u)" = "0" ]; then
fi
chmod +x /data
fi
fi

View file

@ -1491,9 +1491,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz",
"integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@ -1505,9 +1505,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz",
"integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@ -1519,9 +1519,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz",
"integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@ -1533,9 +1533,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz",
"integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@ -1547,9 +1547,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz",
"integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@ -1561,9 +1561,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz",
"integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@ -1575,9 +1575,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz",
"integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@ -1589,9 +1589,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz",
"integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@ -1603,9 +1603,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz",
"integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@ -1617,9 +1617,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz",
"integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@ -1631,9 +1631,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz",
"integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@ -1645,9 +1645,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz",
"integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@ -1659,9 +1659,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz",
"integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@ -1673,9 +1673,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz",
"integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@ -1687,9 +1687,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz",
"integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@ -1701,9 +1701,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz",
"integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@ -1715,9 +1715,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz",
"integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@ -1729,9 +1729,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz",
"integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@ -1743,9 +1743,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz",
"integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@ -1757,9 +1757,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz",
"integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@ -1771,9 +1771,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz",
"integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@ -1785,9 +1785,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz",
"integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@ -1799,9 +1799,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz",
"integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@ -1813,9 +1813,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz",
"integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@ -1827,9 +1827,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz",
"integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@ -4112,9 +4112,9 @@
}
},
"node_modules/minimatch": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@ -4925,9 +4925,9 @@
}
},
"node_modules/rollup": {
"version": "4.58.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
"integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4941,31 +4941,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.58.0",
"@rollup/rollup-android-arm64": "4.58.0",
"@rollup/rollup-darwin-arm64": "4.58.0",
"@rollup/rollup-darwin-x64": "4.58.0",
"@rollup/rollup-freebsd-arm64": "4.58.0",
"@rollup/rollup-freebsd-x64": "4.58.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.58.0",
"@rollup/rollup-linux-arm-musleabihf": "4.58.0",
"@rollup/rollup-linux-arm64-gnu": "4.58.0",
"@rollup/rollup-linux-arm64-musl": "4.58.0",
"@rollup/rollup-linux-loong64-gnu": "4.58.0",
"@rollup/rollup-linux-loong64-musl": "4.58.0",
"@rollup/rollup-linux-ppc64-gnu": "4.58.0",
"@rollup/rollup-linux-ppc64-musl": "4.58.0",
"@rollup/rollup-linux-riscv64-gnu": "4.58.0",
"@rollup/rollup-linux-riscv64-musl": "4.58.0",
"@rollup/rollup-linux-s390x-gnu": "4.58.0",
"@rollup/rollup-linux-x64-gnu": "4.58.0",
"@rollup/rollup-linux-x64-musl": "4.58.0",
"@rollup/rollup-openbsd-x64": "4.58.0",
"@rollup/rollup-openharmony-arm64": "4.58.0",
"@rollup/rollup-win32-arm64-msvc": "4.58.0",
"@rollup/rollup-win32-ia32-msvc": "4.58.0",
"@rollup/rollup-win32-x64-gnu": "4.58.0",
"@rollup/rollup-win32-x64-msvc": "4.58.0",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},

View file

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

View file

@ -13,6 +13,7 @@ import useChannelsTableStore from './store/channelsTable';
import useStreamsTableStore from './store/streamsTable';
import useUsersStore from './store/users';
import useConnectStore from './store/connect';
import Limiter from './utils';
// If needed, you can set a base host or keep it empty if relative requests
const host = import.meta.env.DEV
@ -105,6 +106,41 @@ export default class API {
return await useAuthStore.getState().getToken();
}
/**
* Fetch all pages for a paginated endpoint when you already know totalCount.
* Builds page calls from totalCount and pageSize and aggregates all results.
* - endpoint: path like "/api/channels/channels/"
* - params: URLSearchParams for filters (will not be mutated)
* - totalCount: total number of matching items
* - pageSize: items per page
* Returns a flat array of results. Supports both array and {results, next} responses.
*/
static async fetchAllByCount(endpoint, params, totalCount, pageSize = 200) {
const total = Number(totalCount) || 0;
const size = Number(pageSize) || 200;
const totalPages = Math.max(1, Math.ceil(total / size));
const requests = [];
for (let page = 1; page <= totalPages; page++) {
const q = new URLSearchParams(params || new URLSearchParams());
q.set('page', String(page));
q.set('page_size', String(size));
const url = `${host}${endpoint}?${q.toString()}`;
requests.push(request(url));
}
const responses = await Promise.all(requests);
const all = [];
for (const data of responses) {
if (Array.isArray(data)) {
all.push(...data);
} else if (Array.isArray(data?.results)) {
all.push(...data.results);
}
}
return all;
}
static async fetchSuperUser() {
try {
return await request(`${host}/api/accounts/initialize-superuser/`, {
@ -181,32 +217,39 @@ export default class API {
try {
// Paginate through channels to avoid heavy single response
const pageSize = 200;
let page = 1;
let allChannels = [];
const allChannels = [];
while (true) {
const data = await request(
`${host}/api/channels/channels/?page=${page}&page_size=${pageSize}`
);
// Get first page to get total results count
const data = await request(
`${host}/api/channels/channels/?page=1&page_size=${pageSize}`
);
// Backward compatibility: if endpoint returns an array (legacy), just return it
if (Array.isArray(data)) {
allChannels = data;
break;
}
const results = Array.isArray(data?.results) ? data.results : [];
allChannels = allChannels.concat(results);
const hasMore = Boolean(data?.next);
if (!hasMore || results.length === 0) {
break;
}
page += 1;
// Backward compatibility: if endpoint returns an array (legacy), just return it
if (Array.isArray(data)) {
return data;
}
return allChannels;
allChannels.concat(Array.isArray(data?.results) ? data.results : []);
const totalPages = Math.max(1, Math.ceil(data.count / pageSize)) - 1;
const apiCalls = [];
for (let page = 2; page <= totalPages; page++) {
apiCalls.push(
new Promise(async (resolve) => {
const response = await request(
`${host}/api/channels/channels/?page=${page}&page_size=${pageSize}`
);
return resolve(
Array.isArray(response?.results) ? response.results : []
);
})
);
}
const allResults = await Limiter.all(5, apiCalls);
return allResults;
} catch (e) {
errorNotification('Failed to retrieve channels', e);
}
@ -1271,7 +1314,6 @@ export default class API {
static async getEPGs() {
try {
const response = await request(`${host}/api/epg/sources/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve EPGs', e);
@ -1288,11 +1330,11 @@ export default class API {
}
}
static async getCurrentPrograms(channelIds = null) {
static async getCurrentPrograms(channelUUIDs = null) {
try {
const response = await request(`${host}/api/epg/current-programs/`, {
method: 'POST',
body: { channel_ids: channelIds },
body: { channel_uuids: channelUUIDs },
});
return response;
@ -2832,6 +2874,62 @@ export default class API {
}
}
static async generateApiKey({ user_id = null, name = '' } = {}) {
try {
const body = {};
if (user_id) body.user_id = user_id;
if (name) body.name = name;
const response = await request(
`${host}/api/accounts/api-keys/generate/`,
{
method: 'POST',
body,
}
);
// If the backend returned an updated user, refresh the users store
try {
if (response && response.user) {
useUsersStore.getState().updateUser(response.user);
}
} catch (e) {
// ignore store update errors
}
return response;
} catch (e) {
errorNotification('Failed to generate API key', e);
}
}
static async revokeApiKey({ user_id = null } = {}) {
try {
const body = {};
if (user_id) {
body.user_id = user_id;
}
const response = await request(`${host}/api/accounts/api-keys/revoke/`, {
method: 'POST',
body,
});
// If the backend returned an updated user, refresh the users store
try {
if (response && response.user) {
useUsersStore.getState().updateUser(response.user);
}
} catch (e) {
// ignore store update errors
}
return response;
} catch (e) {
errorNotification('Failed to revoke API key', e);
}
}
static async updateUser(id, body) {
try {
const response = await request(`${host}/api/accounts/users/${id}/`, {

View file

@ -19,6 +19,7 @@ import {
ChevronDown,
ChevronRight,
MonitorCog,
Blocks,
} from 'lucide-react';
import {
Avatar,
@ -30,7 +31,6 @@ import {
TextInput,
ActionIcon,
AppShellNavbar,
Menu,
ScrollArea,
} from '@mantine/core';
import logo from '../images/logo.png';
@ -183,8 +183,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{ label: 'Plugins', icon: <PlugZap size={20} />, path: '/plugins' },
{
label: 'Connect',
icon: <Webhook size={20} />,
label: 'Integrations',
icon: <Blocks size={20} />,
paths: [
{
label: 'Connections',

View file

@ -37,38 +37,45 @@ const PluginActionList = ({
runningActionId,
handlePluginRun,
}) => {
return plugin.actions.map((action) => (
<Group key={action.id} justify="space-between">
<div>
<Text>{action.label}</Text>
{action.description && (
<Text size="sm" c="dimmed">
{action.description}
</Text>
)}
<Text size="xs" style={{ paddingTop: 10 }}>
Event Triggers
</Text>
{action.events.map((event) => (
<Badge size="sm" variant="light" color="green">
{SUBSCRIPTION_EVENTS[event] || event}
</Badge>
))}
</div>
<Button
loading={runningActionId === action.id}
disabled={!enabled || runningActionId === action.id}
onClick={() => handlePluginRun(action)}
size="xs"
variant={action.button_variant || 'filled'}
color={action.button_color}
>
{runningActionId === action.id
? 'Running…'
: action.button_label || 'Run'}
</Button>
</Group>
));
return plugin.actions.map((action) => {
const events = Array.isArray(action?.events) ? action.events : [];
return (
<Group key={action.id} justify="space-between">
<div>
<Text>{action.label}</Text>
{action.description && (
<Text size="sm" c="dimmed">
{action.description}
</Text>
)}
{events.length > 0 && (
<>
<Text size="xs" style={{ paddingTop: 10 }}>
Event Triggers
</Text>
{events.map((event) => (
<Badge key={`${action.id}:${event}`} size="sm" variant="light" color="green">
{SUBSCRIPTION_EVENTS[event] || event}
</Badge>
))}
</>
)}
</div>
<Button
loading={runningActionId === action.id}
disabled={!enabled || runningActionId === action.id}
onClick={() => handlePluginRun(action)}
size="xs"
variant={action.button_variant || 'filled'}
color={action.button_color}
>
{runningActionId === action.id
? 'Running…'
: action.button_label || 'Run'}
</Button>
</Group>
);
});
};
const PluginActionStatus = ({ running, lastResult }) => {

View file

@ -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 (
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="md">
{apiError ? (
<Text c="red" size="sm">
{apiError}
</Text>
) : null}
<TextInput
label="Name"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<Select
{...form.getInputProps('type')}
key={form.key('type')}
label="Connection Type"
data={[
{ value: 'webhook', label: 'Webhook' },
{ value: 'script', label: 'Custom Script' },
]}
/>
{form.getValues().type === 'webhook' ? (
<TextInput
label="Webhook URL"
{...form.getInputProps('url')}
key={form.key('url')}
/>
) : (
<TextInput
label="Script Path"
{...form.getInputProps('script_path')}
key={form.key('script_path')}
/>
)}
<Tabs defaultValue="settings">
<Tabs.List>
<Tabs.Tab value="settings">Settings</Tabs.Tab>
<Tabs.Tab value="triggers">Event Triggers</Tabs.Tab>
{form.getValues().type === 'webhook' && (
<Tabs.Tab value="templates">Payload Templates</Tabs.Tab>
)}
</Tabs.List>
<Box>
<Text size="sm" weight={500} mb={5}>
Event Triggers
</Text>
<Stack gap="xs">
<SimpleGrid cols={3}>
{EVENT_OPTIONS.map((opt) => (
<Checkbox
key={opt.value}
label={opt.label}
checked={selectedEvents.includes(opt.value)}
onChange={() => toggleEvent(opt.value)}
/>
))}
</SimpleGrid>
<Tabs.Panel value="settings" style={{ paddingTop: 10 }}>
<Stack gap="md">
{apiError ? (
<Text c="red" size="sm">
{apiError}
</Text>
) : null}
<TextInput
label="Name"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<Select
{...form.getInputProps('type')}
key={form.key('type')}
label="Connection Type"
data={[
{ value: 'webhook', label: 'Webhook' },
{ value: 'script', label: 'Custom Script' },
]}
/>
{form.getValues().type === 'webhook' ? (
<TextInput
label="Webhook URL"
{...form.getInputProps('url')}
key={form.key('url')}
/>
) : (
<TextInput
label="Script Path"
{...form.getInputProps('script_path')}
key={form.key('script_path')}
/>
)}
{form.getValues().type === 'webhook' ? (
<Box>
<Text size="sm" weight={500} mb={5}>
Custom Headers (optional)
</Text>
<Stack spacing="xs">
{headers.map((h, idx) => (
<Group key={idx} align="flex-start">
<TextInput
placeholder="Header name"
value={h.key}
onChange={(e) => {
const next = [...headers];
next[idx] = { ...next[idx], key: e.target.value };
setHeaders(next);
}}
style={{ flex: 1 }}
/>
<TextInput
placeholder="Header value"
value={h.value}
onChange={(e) => {
const next = [...headers];
next[idx] = {
...next[idx],
value: e.target.value,
};
setHeaders(next);
}}
style={{ flex: 1 }}
/>
<Button
size="xs"
color="red"
onClick={() => {
const next = headers.filter((_, i) => i !== idx);
setHeaders(
next.length ? next : [{ key: '', value: '' }]
);
}}
>
Remove
</Button>
</Group>
))}
<Button
size="xs"
onClick={() =>
setHeaders([...headers, { key: '', value: '' }])
}
>
Add Header
</Button>
</Stack>
</Box>
) : null}
</Stack>
</Box>
</Tabs.Panel>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" loading={submitting}>
Save
</Button>
</Flex>
</Stack>
<Tabs.Panel value="triggers" style={{ paddingTop: 10 }}>
<SimpleGrid cols={3}>
{EVENT_OPTIONS.map((opt) => (
<Checkbox
key={opt.value}
label={opt.label}
checked={selectedEvents.includes(opt.value)}
onChange={() => toggleEvent(opt.value)}
/>
))}
</SimpleGrid>
</Tabs.Panel>
{form.getValues().type === 'webhook' && (
<Tabs.Panel value="templates" style={{ paddingTop: 10 }}>
<Stack gap="xs">
<Alert variant="default">
<Text size="sm">
Enable event triggers to set individual templates.
</Text>
</Alert>
<div
style={{
maxHeight: '60vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ overflow: 'auto', flex: 1, minHeight: 0 }}>
<Accordion
multiple={true}
styles={{
label: { padding: 2 },
}}
>
{EVENT_OPTIONS.map(
(opt) =>
selectedEvents.includes(opt.value) && (
<Accordion.Item key={opt.value} value={opt.value}>
<Accordion.Control
disabled={!selectedEvents.includes(opt.value)}
style={{ paddingTop: 4, paddingBottom: 4 }}
>
{opt.label}
</Accordion.Control>
<Accordion.Panel>
<Textarea
placeholder={
'Optional Jinja2 template (ex: {"content": "Channel {{ channel_name }} just started streaming!"} )'
}
minRows={3}
value={payloadTemplates[opt.value] || ''}
autosize
onChange={(e) =>
setPayloadTemplates({
...payloadTemplates,
[opt.value]: e.target.value,
})
}
/>
</Accordion.Panel>
</Accordion.Item>
)
)}
</Accordion>
</div>
</div>
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" loading={submitting}>
Save
</Button>
</Flex>
</form>
</Modal>
);

View file

@ -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);
}

View file

@ -15,12 +15,17 @@ 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';
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 +34,11 @@ 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 theme = useMantineTheme();
const form = useForm({
mode: 'uncontrolled',
@ -115,6 +125,7 @@ const User = ({ user = null, isOpen, onClose }) => {
}
form.reset();
setUserAPIKey(null);
onClose();
};
@ -139,6 +150,8 @@ const User = ({ user = null, isOpen, onClose }) => {
if (customProps.xc_password) {
setEnableXC(true);
}
setUserAPIKey(user.api_key || null);
} else {
form.reset();
}
@ -154,8 +167,65 @@ 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;
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);
}
};
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 (
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
@ -199,6 +269,7 @@ const User = ({ user = null, isOpen, onClose }) => {
key={form.key('user_level')}
/>
)}
</Stack>
<Stack gap="xs" style={{ flex: 1 }}>
@ -269,6 +340,61 @@ const User = ({ user = null, isOpen, onClose }) => {
</Tooltip>
</Box>
)}
{canGenerateKey && (
<Stack>
{userAPIKey && (
<TextInput
label="API Key"
disabled={true}
value={userAPIKey}
rightSection={
<ActionIcon
variant="transparent"
size="sm"
color="white"
onClick={() =>
copyToClipboard(userAPIKey, {
successTitle: 'API Key Copied!',
successMessage:
'The API Key has been copied to your clipboard.',
})
}
>
<Copy />
</ActionIcon>
}
/>
)}
<Group gap="xs" grow>
<Button
leftSection={<Key size={14} />}
size="xs"
onClick={onGenerateKey}
loading={generating}
variant="light"
fullWidth
>
{userAPIKey ? 'Regenerate API Key' : 'Generate API Key'}
</Button>
{userAPIKey && (
<Button
leftSection={<X size={14} />}
size="xs"
onClick={onRevokeKey}
loading={generating}
color={theme.colors.red[5]}
variant="light"
fullWidth
>
Revoke API Key
</Button>
)}
</Group>
</Stack>
)}
</Stack>
</Group>

View file

@ -94,7 +94,7 @@ const DraggableRow = ({ row, index }) => {
<Box
ref={setNodeRef}
key={row.id}
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'}`}
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'}${row.original.is_stale ? ' stale-stream-row' : ''}`}
style={{
...style,
display: 'flex',
@ -105,7 +105,6 @@ const DraggableRow = ({ row, index }) => {
}}
>
{row.getVisibleCells().map((cell) => {
const isStale = row.original.is_stale;
return (
<Box
className="td"
@ -116,9 +115,6 @@ const DraggableRow = ({ row, index }) => {
? cell.column.getSize()
: undefined,
minWidth: 0,
...(isStale && {
backgroundColor: 'rgba(239, 68, 68, 0.15)',
}),
}}
>
<Flex align="center" style={{ height: '100%' }}>
@ -614,7 +610,10 @@ const ChannelStreams = ({ channel, isExpanded }) => {
const rows = table.getRowModel().rows;
return (
<Box style={{ width: '100%', padding: 10, backgroundColor: '#163632' }}>
<Box
className="channel-streams-container"
style={{ width: '100%', padding: 10, backgroundColor: '#163632' }}
>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}

View file

@ -251,6 +251,9 @@ const ChannelsTable = ({ onReady }) => {
const tvgsById = useEPGsStore((s) => s.tvgsById);
const epgs = useEPGsStore((s) => s.epgs);
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
const hasUnassignedEPGChannels = useChannelsTableStore(
(s) => s.hasUnassignedEPGChannels
);
// Get channel logos for logo selection
const { ensureLogosLoaded } = useChannelLogoSelection();
@ -282,6 +285,7 @@ const ChannelsTable = ({ onReady }) => {
// store/channels
const channels = useChannelsStore((s) => s.channels);
const channelIds = useChannelsStore((s) => s.channelIds);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
@ -380,19 +384,15 @@ const ChannelsTable = ({ onReady }) => {
.map((group) => group.name)
.sort((a, b) => a.localeCompare(b));
let hasUnlinkedChannels = false;
const epgOptions = Object.values(epgs)
.filter((epg) => epg.is_active && epg.has_channels)
.map((epg) => epg.name)
.sort();
if (hasUnlinkedChannels) {
epgOptions.unshift('No EPG');
}
// Map for MultiSelect: value 'null' for 'No EPG', label for display
const epgSelectOptions = epgOptions.map((opt) =>
opt === 'No EPG'
? { value: 'null', label: 'No EPG' }
: { value: opt, label: opt }
);
.sort((a, b) => a.localeCompare(b));
// Only show 'No EPG' if there are channels without an EPG assigned
const epgSelectOptions = [
...(hasUnassignedEPGChannels ? [{ value: 'null', label: 'No EPG' }] : []),
...epgOptions.map((opt) => ({ value: opt, label: opt })),
];
const debouncedFilters = useDebounce(filters, 500, () => {
setPagination({
...pagination,
@ -1438,13 +1438,12 @@ const ChannelsTable = ({ onReady }) => {
{/* Table or ghost empty state inside Paper */}
<Box>
{channelsTableLength === 0 &&
Object.keys(channels).length === 0 && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
{channelsTableLength === 0 && channelIds.length === 0 && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
</Box>
{(channelsTableLength > 0 || Object.keys(channels).length > 0) && (
{(channelsTableLength > 0 || channelIds.length > 0) && (
<Box
style={{
display: 'flex',

View file

@ -1252,7 +1252,7 @@ const StreamsTable = ({ onReady }) => {
getRowStyles: (row) => {
if (row.original.is_stale) {
return {
backgroundColor: 'rgba(239, 68, 68, 0.15)',
className: 'stale-stream-row',
};
}
return {};

View file

@ -120,6 +120,26 @@ html {
/* Darker red on hover */
}
/* Style for stale stream rows */
.stale-stream-row {
background-color: rgba(239, 68, 68, 0.15) !important;
}
/* Special hover effect for stale stream rows */
.table-striped .tbody .tr.stale-stream-row:hover {
background-color: rgba(239, 68, 68, 0.3) !important;
}
/* Override stale color inside channel streams pre-blend against #27272a since
the teal container background would otherwise tint the transparent red */
.channel-streams-container .table-striped .tbody .tr.stale-stream-row {
background-color: color-mix(in srgb, rgb(239, 68, 68) 15%, #27272a) !important;
}
.channel-streams-container .table-striped .tbody .tr.stale-stream-row:hover {
background-color: color-mix(in srgb, rgb(239, 68, 68) 30%, #27272a) !important;
}
/* Prevent text selection when shift key is pressed */
.shift-key-active {
cursor: pointer !important;

View file

@ -9,7 +9,7 @@ import React, {
} from 'react';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
import useVideoStore from '../store/useVideoStore'; // NEW import
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import {
ActionIcon,
@ -232,7 +232,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
return () => {
cancelled = true;
};
}, [channelGroups, searchQuery, selectedGroupId, selectedProfileId]);
}, [
allowAllGroups,
channelGroups,
searchQuery,
selectedGroupId,
selectedProfileId,
]);
// Apply filters when search, group, or profile changes
const filteredChannels = useMemo(() => {
@ -609,14 +615,46 @@ export default function TVChannelGuide({ startDate, endDate }) {
});
}, []);
// Scroll to the nearest half-hour mark ONLY on initial load
useEffect(() => {
if (programs.length > 0 && !initialScrollComplete) {
syncScrollLeft(calculateScrollPosition(now, start));
// Holds the scroll position to restore after a filter-induced remount.
// null means "use the default scroll-to-now on first load".
const savedScrollLeftRef = useRef(null);
// When channels become empty (filter transition unmounts the list), save the
// current scroll position so we can restore it once new channels arrive.
// Only save if the initial scroll has already happened otherwise the saved
// position would be 0 (the DOM default) and we'd skip the scroll-to-now.
useEffect(() => {
if (filteredChannels.length === 0) {
if (initialScrollComplete) {
savedScrollLeftRef.current = guideScrollLeftRef.current;
}
setInitialScrollComplete(false);
}
}, [filteredChannels.length, initialScrollComplete]);
// Scroll on initial load, or restore saved position after a filter transition.
// Guard with guideRef.current the VariableSizeList outer div is null while
// unmounted, so we must wait until it remounts before calling syncScrollLeft.
useEffect(() => {
if (programs.length > 0 && !initialScrollComplete && guideRef.current) {
if (savedScrollLeftRef.current !== null) {
// Restore where the user was before the filter change
syncScrollLeft(savedScrollLeftRef.current);
savedScrollLeftRef.current = null;
} else {
// Genuine first load scroll to current time
syncScrollLeft(calculateScrollPosition(now, start));
}
setInitialScrollComplete(true);
}
}, [programs, start, now, initialScrollComplete, syncScrollLeft]);
}, [
programs,
start,
now,
initialScrollComplete,
syncScrollLeft,
filteredChannels.length,
]);
const findChannelByTvgId = useCallback(
(tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId),

View file

@ -109,28 +109,22 @@ const StatsPage = () => {
const [channels, setChannels] = useState({}); // id -> channel
const [channelsByUUID, setChannelsByUUID] = useState({}); // uuid -> id
// Use refs to hold latest values without triggering effects
const channelHistoryRef = useRef(channelHistory);
const channelsByUUIDRef = useRef(channelsByUUID);
// Update refs when values change
useEffect(() => {
channelHistoryRef.current = channelHistory;
}, [channelHistory]);
useEffect(() => {
channelsByUUIDRef.current = channelsByUUID;
}, [channelsByUUID]);
// Compute needed channel UUIDs from the current active channels
// Compute needed channel UUIDs from the current active channels.
// Stream previews use a non-UUID hash as channel_id filter those out.
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const neededUUIDs = useMemo(
() => Object.keys(channelHistory || {}),
() => Object.keys(channelHistory || {}).filter((id) => UUID_REGEX.test(id)),
[channelHistory]
);
console.log(channelHistory);
// Keep a ref so the programs poller always has the latest valid UUIDs
const neededUUIDsRef = useRef(neededUUIDs);
useEffect(() => {
neededUUIDsRef.current = neededUUIDs;
}, [neededUUIDs]);
// Fetch any missing channels by UUID when the needed set changes
// Fetch any missing channels by UUID when the needed set changes (for card name/logo)
useEffect(() => {
if (!neededUUIDs || neededUUIDs.length === 0) return;
const missing = neededUUIDs.filter((u) => channelsByUUID[u] === undefined);
@ -160,7 +154,7 @@ const StatsPage = () => {
return () => {
cancelled = true;
};
}, [neededUUIDs.join(','), channelsByUUID]);
}, [neededUUIDs.join(',')]);
// Use localStorage for stats refresh interval (in seconds)
const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage(
@ -301,11 +295,7 @@ const StatsPage = () => {
let timer = null;
const fetchPrograms = async () => {
// Use refs to get latest values without adding dependencies
const programs = await getCurrentPrograms(
channelHistoryRef.current,
channelsByUUIDRef.current
);
const programs = await getCurrentPrograms(neededUUIDsRef.current);
setCurrentPrograms(programs);
// Schedule next fetch based on nearest program end time
@ -341,7 +331,7 @@ const StatsPage = () => {
return () => {
if (timer) clearTimeout(timer);
};
}, [activeChannelIds]); // Only depend on activeChannelIds
}, [activeChannelIds]); // Only re-run when active channel set changes
// Combine active streams and VOD connections into a single mixed list
const combinedConnections = useMemo(() => {

View file

@ -4,6 +4,7 @@ const useChannelsTableStore = create((set, get) => ({
channels: [],
pageCount: 0,
totalCount: 0,
hasUnassignedEPGChannels: false,
sorting: [{ id: 'channel_number', desc: false }],
pagination: {
pageIndex: 0,
@ -14,14 +15,15 @@ const useChannelsTableStore = create((set, get) => ({
allQueryIds: [],
isUnlocked: false,
queryChannels: ({ results, count }, params) => {
set((state) => {
return {
channels: results,
totalCount: count,
pageCount: Math.ceil(count / params.get('page_size')),
};
});
queryChannels: ({ results, count, has_unassigned_epg_channels }, params) => {
set((state) => ({
channels: results,
totalCount: count,
pageCount: Math.ceil(count / params.get('page_size')),
...(has_unassigned_epg_channels !== undefined && {
hasUnassignedEPGChannels: has_unassigned_epg_channels,
}),
}));
},
setAllQueryIds: (allQueryIds) => {

View file

@ -22,9 +22,9 @@ const useEPGsStore = create((set) => ({
fetchEPGs: async () => {
set({ isLoading: true, error: null });
try {
const epgs = await api.getEPGs();
const sources = await api.getEPGs();
set({
epgs: epgs.reduce((acc, epg) => {
epgs: (sources ?? []).reduce((acc, epg) => {
acc[epg.id] = epg;
return acc;
}, {}),

View file

@ -20,32 +20,20 @@ export const getVODStats = async () => {
return await API.getVODStats();
};
export const getCurrentPrograms = async (channelHistory, channelsByUUID) => {
export const getCurrentPrograms = async (activeChannelUUIDs) => {
try {
// Get all active channel IDs that have actual channels (not just streams)
const activeChannelIds = Object.values(channelHistory)
.filter(
(ch) => ch.name && channelsByUUID && channelsByUUID[ch.channel_id]
)
.map((ch) => channelsByUUID[ch.channel_id])
.filter((id) => id !== undefined);
if (activeChannelIds.length === 0) {
if (!activeChannelUUIDs || activeChannelUUIDs.length === 0) {
return {};
}
const programs = await API.getCurrentPrograms(activeChannelIds);
const programs = await API.getCurrentPrograms(activeChannelUUIDs);
// Convert array to map keyed by channel UUID for easy lookup
const programsMap = {};
if (programs && Array.isArray(programs)) {
programs.forEach((program) => {
// Find the channel UUID from the channel ID
const channelEntry = Object.entries(channelsByUUID).find(
([uuid, id]) => id === program.channel_id
);
if (channelEntry) {
programsMap[channelEntry[0]] = program;
if (program.channel_uuid) {
programsMap[program.channel_uuid] = program;
}
});
}

View file

@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
__version__ = '0.19.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__version__ = '0.20.2' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process