Merge pull request #1028 from Dispatcharr/dev

Dispatcharr - v0.20.0
This commit is contained in:
SergeantPanda 2026-02-26 14:05:41 -06:00 committed by GitHub
commit 393bfd384c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 7315 additions and 2148 deletions

View file

@ -201,13 +201,15 @@ jobs:
echo "Creating multi-arch manifest for ${OWNER}/${REPO}"
# branch tag (e.g. latest or dev)
# ghcr.io: both the branch tag (e.g. dev) and the version+timestamp tag
# point to the same manifest by using multiple --tag flags in one call,
# which prevents orphaned untagged images on each new build
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \
--annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
@ -217,9 +219,10 @@ jobs:
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \
ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64
# version + timestamp tag
# Docker Hub: same single call with both tags
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
@ -234,40 +237,6 @@ jobs:
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \
ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-arm64
# also create Docker Hub manifests using the same username
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-arm64
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64

View file

@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs:
- **CVE-2025-13473** (low): Username enumeration via timing difference in mod_wsgi authentication handler.
- **CVE-2025-14550** (moderate): Potential denial-of-service via repeated headers on ASGI requests.
- **CVE-2026-1207** (high): Potential SQL injection via raster lookups on PostGIS.
- **CVE-2026-1285** (moderate): Potential denial-of-service in `django.utils.text.Truncator` HTML methods via inputs with large numbers of unmatched HTML end tags.
- **CVE-2026-1287** (high): Potential SQL injection in column aliases via control characters in `FilteredRelation`.
- **CVE-2026-1312** (high): Potential SQL injection via `QuerySet.order_by()` and `FilteredRelation` when using column aliases containing periods.
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high):
- Updated `ajv` 6.12.6 → 6.14.0, resolving a **moderate** ReDoS vulnerability when using the `$data` option ([GHSA-2g4f-4pwh-qvx6](https://github.com/advisories/GHSA-2g4f-4pwh-qvx6))
- Enforced `minimatch` ≥10.2.2 via npm overrides, resolving **high** ReDoS via repeated wildcards with non-matching literal patterns ([GHSA-3ppc-4f35-3m26](https://github.com/advisories/GHSA-3ppc-4f35-3m26)) affecting `minimatch`, `@eslint/config-array`, `@eslint/eslintrc`, and `eslint`
### 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 (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
- **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing
- **Next Available**: Auto-assign starting from 1, skipping all used channel numbers
Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433)
- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd)
- `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
- Dependency updates:
- `Django` 5.2.9 → 5.2.11 (security patch; see Security section)
- `celery` 5.6.0 → 5.6.2
- `psutil` 7.1.3 → 7.2.2
- `torch` 2.9.1+cpu → 2.10.0+cpu
- `sentence-transformers` 5.2.0 → 5.2.3
- `ajv` 6.12.6 → 6.14.0 (security patch; see Security section)
- `minimatch` enforced ≥10.2.2 via npm overrides (security patch; see Security section)
- `react` / `react-dom` 19.2.3 → 19.2.4
- `react-router-dom` / `react-router` 7.12.0 → 7.13.0
- `react-hook-form` 7.70.0 → 7.71.2
- `react-draggable` 4.4.6 → 4.5.0
- `@tanstack/react-table` 8.21.2 → 8.21.3
- `video.js` 8.23.4 → 8.23.7
- `vite` 7.3.0 → 7.3.1
- `zustand` 5.0.9 → 5.0.11
- `allotment` 1.20.4 → 1.20.5
- `prettier` 3.7.4 → 3.8.1
- `@swc/wasm` 1.15.7 → 1.15.11
- `@testing-library/react` 16.3.1 → 16.3.2
- `@types/react` 19.2.7 → 19.2.14
- `@vitejs/plugin-react-swc` 4.2.2 → 4.2.3
- Channel store optimization: Refactored frontend channel loading to only fetch channel IDs on initial login (matching the streams store pattern), instead of loading full channel objects upfront. Full channel data is fetched lazily as needed. This dramatically reduces login time and initial page load when large channel libraries are present.
- DVR scheduling: Channel selector now displays the channel number alongside the channel name when scheduling a recording.
- TV Guide performance improvements: Optimized the TV Guide with horizontal culling for off-screen program rows (only rendering visible programs), throttled now-line position updates, and improved scroll performance. Reduces unnecessary DOM work and improves responsiveness with large EPG datasets.
- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity.
- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible.
- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839)
- 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)
- Stream Profile form User-Agent not populating when editing: The User-Agent field was not correctly loaded from the existing profile when opening the edit modal. (Fixes #650)
- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique.
- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- 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
### Added
@ -80,6 +164,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

@ -1,4 +1,5 @@
from django.contrib.auth import authenticate, login, logout
import logging
from django.contrib.auth.models import Group, Permission
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
@ -8,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
@ -15,6 +17,8 @@ from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
logger = logging.getLogger(__name__)
class TokenObtainPairView(TokenObtainPairView):
def post(self, request, *args, **kwargs):
@ -25,6 +29,7 @@ class TokenObtainPairView(TokenObtainPairView):
username = request.data.get("username", 'unknown')
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}")
log_system_event(
event_type='login_failed',
user=username,
@ -43,6 +48,7 @@ class TokenObtainPairView(TokenObtainPairView):
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
try:
logger.debug(f"Attempting JWT login for user={username}")
response = super().post(request, *args, **kwargs)
# If login was successful, update last_login and log success
@ -61,6 +67,7 @@ class TokenObtainPairView(TokenObtainPairView):
client_ip=client_ip,
user_agent=user_agent,
)
logger.info(f"Login success: user={username} ip={client_ip}")
except User.DoesNotExist:
pass # User doesn't exist, but login somehow succeeded
else:
@ -72,6 +79,7 @@ class TokenObtainPairView(TokenObtainPairView):
user_agent=user_agent,
reason='Invalid credentials',
)
logger.info(f"Login failed: user={username} ip={client_ip}")
return response
@ -84,6 +92,7 @@ class TokenObtainPairView(TokenObtainPairView):
user_agent=user_agent,
reason=f'Authentication error: {str(e)[:100]}',
)
logger.error(f"Login error for user={username}: {e}")
raise # Re-raise the exception to maintain normal error flow
@ -95,6 +104,7 @@ class TokenRefreshView(TokenRefreshView):
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}")
log_system_event(
event_type='login_failed',
user='token_refresh',
@ -109,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":
@ -167,6 +177,7 @@ class AuthViewSet(viewsets.ViewSet):
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
logger.debug(f"Login attempt via session: user={username} ip={client_ip}")
if user:
login(request, user)
@ -182,6 +193,7 @@ class AuthViewSet(viewsets.ViewSet):
client_ip=client_ip,
user_agent=user_agent,
)
logger.info(f"Login success via session: user={username} ip={client_ip}")
return Response(
{
@ -203,6 +215,7 @@ class AuthViewSet(viewsets.ViewSet):
user_agent=user_agent,
reason='Invalid credentials',
)
logger.info(f"Login failed via session: user={username} ip={client_ip}")
return Response({"error": "Invalid credentials"}, status=400)
@extend_schema(
@ -222,6 +235,7 @@ class AuthViewSet(viewsets.ViewSet):
client_ip=client_ip,
user_agent=user_agent,
)
logger.info(f"Logout: user={username} ip={client_ip}")
logout(request)
return Response({"message": "Logout successful"})
@ -305,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

@ -13,6 +13,7 @@ urlpatterns = [
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),

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

@ -1,9 +1,13 @@
import json
import logging
from django_celery_beat.models import PeriodicTask, CrontabSchedule
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings
from core.scheduling import (
create_or_update_periodic_task,
delete_periodic_task,
)
logger = logging.getLogger(__name__)
@ -105,98 +109,25 @@ def _sync_periodic_task() -> None:
settings = get_schedule_settings()
if not settings["enabled"]:
# Delete the task if it exists
task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first()
if task:
old_crontab = task.crontab
task.delete()
_cleanup_orphaned_crontab(old_crontab)
delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME)
logger.info("Backup schedule disabled, removed periodic task")
return
# Get old crontab before creating new one
old_crontab = None
try:
old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME)
old_crontab = old_task.crontab
except PeriodicTask.DoesNotExist:
pass
# Check if using cron expression (advanced mode)
if settings["cron_expression"]:
# Parse cron expression: "minute hour day month weekday"
try:
parts = settings["cron_expression"].split()
if len(parts) != 5:
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
minute, hour, day_of_month, month_of_year, day_of_week = parts
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=day_of_week,
day_of_month=day_of_month,
month_of_year=month_of_year,
timezone=CoreSettings.get_system_time_zone(),
)
except Exception as e:
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
raise ValueError(f"Invalid cron expression: {e}")
cron_expr = settings["cron_expression"]
else:
# Use simple frequency-based scheduling
# Parse time
# Build a cron expression from simple frequency settings
hour, minute = settings["time"].split(":")
# Build crontab based on frequency
system_tz = CoreSettings.get_system_time_zone()
if settings["frequency"] == "daily":
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week="*",
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
cron_expr = f"{minute} {hour} * * *"
else: # weekly
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=str(settings["day_of_week"]),
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
cron_expr = f"{minute} {hour} * * {settings['day_of_week']}"
# Create or update the periodic task
task, created = PeriodicTask.objects.update_or_create(
name=BACKUP_SCHEDULE_TASK_NAME,
defaults={
"task": "apps.backups.tasks.scheduled_backup_task",
"crontab": crontab,
"enabled": True,
"kwargs": json.dumps({"retention_count": settings["retention_count"]}),
},
create_or_update_periodic_task(
task_name=BACKUP_SCHEDULE_TASK_NAME,
celery_task_path="apps.backups.tasks.scheduled_backup_task",
kwargs={"retention_count": settings["retention_count"]},
cron_expression=cron_expr,
enabled=True,
)
# Clean up old crontab if it changed and is orphaned
if old_crontab and old_crontab.id != crontab.id:
_cleanup_orphaned_crontab(old_crontab)
action = "Created" if created else "Updated"
logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}")
def _cleanup_orphaned_crontab(crontab_schedule):
"""Delete old CrontabSchedule if no other tasks are using it."""
if crontab_schedule is None:
return
# Check if any other tasks are using this crontab
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting")
return
logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}")
crontab_schedule.delete()

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

@ -724,6 +724,104 @@ class ChannelViewSet(viewsets.ModelViewSet):
"channels": serialized_channels
})
@extend_schema(
methods=["POST"],
description=(
"Bulk rename channel names using a regex find/replace executed server-side. "
"Accepts JavaScript-style named groups (e.g., (?<name>...)) and converts them to Python syntax. "
"Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $<name> are translated to Python."
),
request=inline_serializer(
name="BulkRegexRenameRequest",
fields={
"channel_ids": serializers.ListField(child=serializers.IntegerField()),
"find": serializers.CharField(),
"replace": serializers.CharField(required=False, allow_blank=True),
"flags": serializers.CharField(required=False, allow_blank=True),
},
),
)
@action(detail=False, methods=["post"], url_path="edit/bulk-regex")
def bulk_regex_rename(self, request):
"""
Efficiently apply a regex find/replace to the `name` field of multiple channels.
"""
import regex as re
channel_ids = request.data.get("channel_ids", [])
pattern = request.data.get("find", "")
replace = request.data.get("replace", "")
flags_str = request.data.get("flags", "") or ""
if not isinstance(channel_ids, list) or len(channel_ids) == 0:
return Response({"error": "channel_ids must be a non-empty list"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(pattern, str) or pattern.strip() == "":
return Response({"error": "find (regex pattern) is required"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(replace, str):
return Response({"error": "replace must be a string"}, status=status.HTTP_400_BAD_REQUEST)
# Convert JS-style named groups to Python (?<name>...) -> (?P<name>...)
try:
converted_pattern = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", pattern)
except Exception as e:
return Response({"error": f"Failed to normalize pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST)
# Compile flags
re_flags = 0
if "i" in flags_str:
re_flags |= re.IGNORECASE
# Note: 'g' (global) is the default behavior of re.sub; no action needed.
# Translate common JS replacement tokens to Python
def translate_js_replacement(rep: str) -> str:
# $$ -> $
rep = rep.replace("$$", "$")
# $& -> \g<0>
rep = rep.replace("$&", r"\g<0>")
# $<name> -> \g<name>
rep = re.sub(r"\$<([A-Za-z_][A-Za-z0-9_]*)>", r"\\g<\1>", rep)
# $1 -> \g<1>, $2 -> \g<2>, etc.
rep = re.sub(r"\$(\d+)", r"\\g<\1>", rep)
return rep
try:
replacement_py = translate_js_replacement(replace)
compiled = re.compile(converted_pattern, flags=re_flags)
except Exception as e:
return Response({"error": f"Invalid regex pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST)
# Fetch channels in one query
channels = list(Channel.objects.filter(id__in=channel_ids))
if not channels:
return Response({"error": "No matching channels found for provided IDs"}, status=status.HTTP_404_NOT_FOUND)
changed = []
for ch in channels:
current = ch.name or ""
try:
new_name = compiled.sub(replacement_py, current)
except Exception as e:
# Skip problematic replacements but continue processing others
logger.warning(f"Regex replacement failed for channel {ch.id}: {e}")
continue
# Only update if name actually changes and remains non-empty
if new_name != current and new_name.strip():
ch.name = new_name
changed.append(ch)
# Apply updates in bulk
updated_count = 0
if changed:
with transaction.atomic():
Channel.objects.bulk_update(changed, fields=["name"], batch_size=100)
updated_count = len(changed)
return Response({
"success": True,
"updated_count": updated_count,
}, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="set-names-from-epg")
def set_names_from_epg(self, request):
"""
@ -831,6 +929,50 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Return the response with the list of IDs
return Response(list(channel_ids))
@action(detail=False, methods=["get"], url_path="summary")
def summary(self, request, *args, **kwargs):
"""Return a lightweight list of channels with only the fields needed by the TV Guide."""
queryset = self.filter_queryset(self.get_queryset())
data = list(
queryset.values(
"id",
"name",
"logo_id",
"channel_number",
"uuid",
"epg_data_id",
"channel_group_id",
)
)
return Response(data)
@extend_schema(
methods=["POST"],
description="Retrieve channels by a list of UUIDs using POST to avoid URL length limitations",
request=inline_serializer(
name="ChannelByUUIDsRequest",
fields={
"uuids": serializers.ListField(
child=serializers.CharField(),
help_text="List of channel UUIDs to retrieve",
)
},
),
responses={200: ChannelSerializer(many=True)},
)
@action(detail=False, methods=["post"], url_path="by-uuids")
def get_by_uuids(self, request, *args, **kwargs):
uuids = request.data.get("uuids", [])
if not isinstance(uuids, list):
return Response(
{"error": "uuids must be a list of strings"},
status=status.HTTP_400_BAD_REQUEST,
)
channels = Channel.objects.filter(uuid__in=uuids)
serializer = self.get_serializer(channels, many=True)
return Response(serializer.data)
@extend_schema(
methods=["POST"],
description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",

View file

@ -85,12 +85,23 @@ def create_profile_memberships(sender, instance, created, **kwargs):
for channel in channels
])
def schedule_recording_task(instance):
eta = instance.start_time
def schedule_recording_task(instance, eta=None):
# Use the explicitly-passed (and timezone-aware) eta if provided;
# fall back to instance.start_time only as a last resort.
if eta is None:
eta = instance.start_time
# Ensure eta is timezone-aware before comparing against now()
if eta is not None and not is_aware(eta):
eta = make_aware(eta)
# countdown=0 fires immediately (in-progress programs whose start_time was
# clamped to now by the serializer), countdown>0 delays until start_time
# (future programs). Using an integer countdown avoids any timezone
# serialization ambiguity that can occur with an absolute eta datetime.
countdown = max(0, int((eta - now()).total_seconds()))
# Pass recording_id first so task can persist metadata to the correct row
task = run_recording.apply_async(
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
eta=eta
countdown=countdown,
)
return task.id
@ -133,7 +144,10 @@ def schedule_task_on_save(sender, instance, created, **kwargs):
# Optionally allow slight fudge factor (1 second) to ensure scheduling happens
if start_time > current_time - timedelta(seconds=1):
print("Scheduling recording task!")
task_id = schedule_recording_task(instance)
# Pass the corrected, timezone-aware start_time explicitly so
# schedule_recording_task uses it as the Celery ETA rather than
# re-reading instance.start_time which may still be naive.
task_id = schedule_recording_task(instance, eta=start_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
else:

0
apps/connect/__init__.py Normal file
View file

17
apps/connect/api_urls.py Normal file
View file

@ -0,0 +1,17 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .api_views import (
IntegrationViewSet,
EventSubscriptionViewSet,
DeliveryLogViewSet,
)
app_name = 'connect'
router = DefaultRouter()
router.register(r'integrations', IntegrationViewSet, basename='integration')
router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription')
router.register(r'logs', DeliveryLogViewSet, basename='delivery-log')
urlpatterns = []
urlpatterns += router.urls

198
apps/connect/api_views.py Normal file
View file

@ -0,0 +1,198 @@
from rest_framework import viewsets, status
from rest_framework.pagination import PageNumberPagination
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.response import Response
from rest_framework.decorators import action
from django.utils import timezone
from .models import Integration, EventSubscription, DeliveryLog
from .serializers import (
IntegrationSerializer,
EventSubscriptionSerializer,
DeliveryLogSerializer,
)
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
IsAdmin,
)
from .handlers.webhook import WebhookHandler
from .handlers.script import ScriptHandler
class IntegrationViewSet(viewsets.ModelViewSet):
queryset = Integration.objects.all()
serializer_class = IntegrationSerializer
def get_permissions(self):
try:
perms = permission_classes_by_action[self.action]
except KeyError:
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
perms = getattr(self, "permission_classes", [Authenticated])
return [perm() for perm in perms]
@action(detail=True, methods=["get"], url_path="subscriptions")
def list_subscriptions(self, request, pk=None):
qs = EventSubscription.objects.filter(integration_id=pk)
serializer = EventSubscriptionSerializer(qs, many=True)
return Response(serializer.data)
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
def set_subscriptions(self, request, pk=None):
"""
Replace the integration's subscriptions with the provided list.
Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...]
Any existing subscriptions not in the list will be deleted; missing ones will be created/updated.
"""
try:
integration = Integration.objects.get(pk=pk)
except Integration.DoesNotExist:
return Response(
{"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND
)
data = request.data
if not isinstance(data, list):
return Response(
{"detail": "Expected a list of subscriptions"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate incoming items using serializer (without integration field)
# We'll attach the integration explicitly
valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES)
incoming = []
for item in data:
if not isinstance(item, dict):
return Response(
{"detail": "Each subscription must be an object"},
status=status.HTTP_400_BAD_REQUEST,
)
event = item.get("event")
if event not in valid_events:
return Response(
{"detail": f"Invalid event: {event}"},
status=status.HTTP_400_BAD_REQUEST,
)
# 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": payload_template,
}
)
incoming_events = {s["event"] for s in incoming}
# Delete subscriptions that are no longer present
EventSubscription.objects.filter(integration=integration).exclude(
event__in=incoming_events
).delete()
# Upsert incoming subscriptions
updated = []
for sub in incoming:
obj, _created = EventSubscription.objects.update_or_create(
integration=integration,
event=sub["event"],
defaults={
"enabled": sub["enabled"],
"payload_template": sub.get("payload_template"),
},
)
updated.append(obj)
serializer = EventSubscriptionSerializer(updated, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin])
def test(self, request, pk=None):
"""
Execute a saved integration (connect) with a dummy payload to verify configuration.
"""
try:
integration = Integration.objects.get(pk=pk)
except Integration.DoesNotExist:
return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND)
# Build a dummy payload similar to system events
now = timezone.now().isoformat()
dummy_payload = {
"event": "test",
"timestamp": now,
"channel_name": "Test Channel",
"stream_name": "Test Stream",
"stream_url": "http://example.com/stream.m3u8",
"channel_url": "http://example.com/stream.m3u8",
"provider_name": "Test Provider",
"profile_used": "Default",
"test": True,
}
# Choose handler based on saved type
if integration.type == "webhook":
handler = WebhookHandler(integration, None, dummy_payload)
elif integration.type == "script":
handler = ScriptHandler(integration, None, dummy_payload)
else:
return Response(
{"success": False, "error": f"Unsupported integration type: {integration.type}"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = handler.execute()
return Response(
{
"success": bool(result.get("success")),
"type": integration.type,
"request_payload": dummy_payload,
"result": result,
},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{
"success": False,
"type": integration.type,
"request_payload": dummy_payload,
"error": str(e),
},
status=status.HTTP_502_BAD_GATEWAY,
)
class EventSubscriptionViewSet(viewsets.ModelViewSet):
queryset = EventSubscription.objects.all()
serializer_class = EventSubscriptionSerializer
class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet):
queryset = DeliveryLog.objects.all().order_by("-created_at")
serializer_class = DeliveryLogSerializer
filter_backends = [DjangoFilterBackend]
# Support server-side pagination with page_size query param
class ConnectLogsPagination(PageNumberPagination):
page_size = 50
page_size_query_param = "page_size"
max_page_size = 250
pagination_class = ConnectLogsPagination
def get_queryset(self):
qs = super().get_queryset()
# Optional filters: integration id and type
integration_id = self.request.query_params.get("integration")
if integration_id:
qs = qs.filter(subscription__integration_id=integration_id)
integration_type = self.request.query_params.get("type")
if integration_type:
qs = qs.filter(subscription__integration__type=integration_type)
return qs

8
apps/connect/apps.py Normal file
View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ConnectConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.connect'
verbose_name = "Connect Integrations"
label = 'dispatcharr_connect'

View file

View file

View file

@ -0,0 +1,12 @@
# connect/handlers/base.py
import abc
class IntegrationHandler(abc.ABC):
def __init__(self, integration, subscription, payload):
self.integration = integration
self.subscription = subscription
self.payload = payload
@abc.abstractmethod
def execute(self):
pass

View file

@ -0,0 +1,81 @@
# connect/handlers/script.py
import os
import stat
import subprocess
from django.conf import settings
from .base import IntegrationHandler
def _is_path_allowed(real_path: str) -> bool:
# Ensure path is within one of the allowed directories
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
base_abs = os.path.abspath(base) + os.sep
if real_path.startswith(base_abs):
return True
return False
class ScriptHandler(IntegrationHandler):
def execute(self):
raw_path = self.integration.config.get("path")
if not raw_path:
raise ValueError("Missing 'path' in integration config")
# Resolve and validate path
real_path = os.path.abspath(os.path.realpath(raw_path))
if not os.path.exists(real_path):
raise FileNotFoundError(f"Script not found: {real_path}")
if not _is_path_allowed(real_path):
raise PermissionError(
f"Script path '{real_path}' not within allowed directories: "
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
)
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
if not os.access(real_path, os.X_OK):
raise PermissionError(f"Script is not executable: {real_path}")
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
st = os.stat(real_path)
if st.st_mode & stat.S_IWOTH:
raise PermissionError(
f"Refusing to execute world-writable script: {real_path}"
)
# Build a sanitized minimal environment; avoid inheriting secrets
env = {
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
for key, value in (self.payload or {}).items():
env_key = f"DISPATCHARR_{str(key).upper()}"
env[env_key] = "" if value is None else str(value)
# Run with a timeout to prevent hanging scripts
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
result = subprocess.run(
[real_path],
capture_output=True,
text=True,
env=env,
timeout=timeout,
cwd=os.path.dirname(real_path) or None,
)
# Truncate outputs to avoid excessive memory/logging
stdout = result.stdout or ""
stderr = result.stderr or ""
if len(stdout) > max_out:
stdout = stdout[:max_out] + "... [truncated]"
if len(stderr) > max_out:
stderr = stderr[:max_out] + "... [truncated]"
return {
"exit_code": result.returncode,
"stdout": stdout,
"stderr": stderr,
"success": result.returncode == 0,
}

View file

@ -0,0 +1,21 @@
# connect/handlers/webhook.py
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", {})
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,52 @@
# Generated by Django 5.2.9 on 2026-01-27 21:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='EventSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)),
('enabled', models.BooleanField(default=True)),
('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)),
],
),
migrations.CreateModel(
name='Integration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)),
('config', models.JSONField(default=dict)),
('enabled', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='DeliveryLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)),
('request_payload', models.JSONField(blank=True, default=dict)),
('response_payload', models.JSONField(blank=True, default=dict)),
('error_message', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')),
],
),
migrations.AddField(
model_name='eventsubscription',
name='integration',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'),
),
]

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

47
apps/connect/models.py Normal file
View file

@ -0,0 +1,47 @@
from django.db import models
SUPPORTED_EVENTS = {
"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",
}
class Integration(models.Model):
TYPE_CHOICES = [
("webhook", "Webhook"),
("api", "API"),
("script", "Custom Script"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
config = models.JSONField(default=dict)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class EventSubscription(models.Model):
EVENT_CHOICES = list(SUPPORTED_EVENTS.items())
event = models.CharField(max_length=100, choices=EVENT_CHOICES)
integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions")
enabled = models.BooleanField(default=True)
payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload")
class DeliveryLog(models.Model):
subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs")
status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")])
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)

View file

@ -0,0 +1,68 @@
from rest_framework import serializers
from .models import Integration, EventSubscription, DeliveryLog
import os
class EventSubscriptionSerializer(serializers.ModelSerializer):
class Meta:
model = EventSubscription
fields = [
"id",
"event",
"enabled",
"payload_template",
"integration",
]
class IntegrationSerializer(serializers.ModelSerializer):
subscriptions = EventSubscriptionSerializer(many=True, read_only=True)
class Meta:
model = Integration
fields = [
"id",
"name",
"type",
"config",
"enabled",
"created_at",
"subscriptions",
]
def validate(self, attrs):
type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None)
config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {})
if type == "script":
path = (config or {}).get("path")
if not path or not isinstance(path, str):
raise serializers.ValidationError({"config": "Script config must include a 'path' string"})
real_path = os.path.abspath(os.path.realpath(path))
if not os.path.exists(real_path):
raise serializers.ValidationError({"config": f"Script path does not exist: {path}"})
elif type == "webhook":
url = (config or {}).get("url")
if not url or not isinstance(url, str):
raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"})
else:
raise serializers.ValidationError({"type": "Unsupported integration type"})
return attrs
class DeliveryLogSerializer(serializers.ModelSerializer):
subscription = EventSubscriptionSerializer(read_only=True)
class Meta:
model = DeliveryLog
fields = [
"id",
"subscription",
"status",
"request_payload",
"response_payload",
"error_message",
"created_at",
]

116
apps/connect/utils.py Normal file
View file

@ -0,0 +1,116 @@
# connect/utils.py
import logging, json
from django.template import Template, Context
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
from .handlers.webhook import WebhookHandler
from .handlers.script import ScriptHandler
from apps.plugins.loader import PluginManager
logger = logging.getLogger(__name__)
HANDLERS = {
"webhook": WebhookHandler,
"script": ScriptHandler,
}
def trigger_event(event_name, payload):
if event_name not in SUPPORTED_EVENTS:
logger.debug(f"Unsupported event '{event_name}' - skipping")
return
logger.debug(
f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}"
)
subscriptions = EventSubscription.objects.filter(
event=event_name, enabled=True
).select_related("integration")
count = subscriptions.count()
logger.info(f"Found {count} connect subscription(s) for event '{event_name}'")
# First, fetch all subscriptions and trigger
for sub in subscriptions:
integration = sub.integration
if not integration.enabled:
logger.debug(
f"Skipping disabled integration id={integration.id} name={integration.name}"
)
continue
# apply optional payload template (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 integration.type == 'webhook' and sub.payload_template:
try:
template = Template(sub.payload_template)
final_payload = template.render(Context(payload)).strip()
except Exception as e:
logger.error(
f"Payload template render failed for subscription id={sub.id}: {e}"
)
final_payload = payload
handler_cls = HANDLERS.get(integration.type)
if not handler_cls:
DeliveryLog.objects.create(
subscription=sub,
status="failed",
request_payload=final_payload,
error_message=f"No handler for integration type '{integration.type}'",
)
logger.error(
f"No handler for integration type '{integration.type}' (integration id={integration.id})"
)
continue
handler = handler_cls(integration, sub, final_payload)
logger.debug(
f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}"
)
try:
result = handler.execute()
DeliveryLog.objects.create(
subscription=sub,
status="success" if result.get("success") else "failed",
request_payload=final_payload,
response_payload=result,
)
logger.info(
f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'"
)
except Exception as e:
DeliveryLog.objects.create(
subscription=sub,
status="failed",
request_payload=final_payload,
error_message=str(e),
)
logger.error(
f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}"
)
pm = PluginManager.get()
pm.discover_plugins(sync_db=False, use_cache=True)
plugins = pm.list_plugins()
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
for plugin in plugins:
if not plugin["enabled"]:
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
continue
logger.debug(json.dumps(plugin))
for action in plugin["actions"]:
if "events" in action and event_name in action["events"]:
key = plugin["key"]
params = {"event": event_name, "payload": payload}
action_name = action.get("label") or action.get("id")
action_id = action.get("id")
logger.debug(
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
)
if action_id:
pm.run_action(key, action_id, params)

View file

@ -31,7 +31,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
API endpoint that allows EPG sources to be viewed or edited.
"""
queryset = EPGSource.objects.all()
queryset = EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).all()
serializer_class = EPGSourceSerializer
def get_permissions(self):
@ -439,43 +441,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')
@ -495,9 +486,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

@ -12,6 +12,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
allow_null=True,
validators=[validate_flexible_url]
)
cron_expression = serializers.CharField(required=False, allow_blank=True, default='')
class Meta:
model = EPGSource
@ -24,6 +25,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
'is_active',
'file_path',
'refresh_interval',
'cron_expression',
'priority',
'status',
'last_message',
@ -37,6 +39,42 @@ class EPGSourceSerializer(serializers.ModelSerializer):
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
return obj.epgs.count()
def to_representation(self, instance):
data = super().to_representation(instance)
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
# But first check if we have a transient _cron_expression (from create/update before signal runs)
cron_expr = ''
if hasattr(instance, '_cron_expression'):
cron_expr = instance._cron_expression
elif 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}'
data['cron_expression'] = cron_expr
return data
def update(self, instance, validated_data):
# 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)
instance.save()
return instance
def create(self, validated_data):
cron_expr = validated_data.pop('cron_expression', '')
instance = EPGSource(**validated_data)
instance._cron_expression = cron_expr
instance.save()
return instance
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
model = ProgramData

View file

@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import EPGSource, EPGData
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.utils import is_protected_path, send_websocket_update
import json
import logging
@ -70,10 +70,11 @@ 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.
Supports both interval-based and cron-based scheduling via the shared utility.
"""
# Skip task creation for dummy EPGs
if instance.source_type == 'dummy':
@ -83,39 +84,48 @@ 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}"
interval, _ = IntervalSchedule.objects.get_or_create(
every=int(instance.refresh_interval),
period=IntervalSchedule.HOURS
should_be_enabled = instance.is_active
# 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,
celery_task_path="apps.epg.tasks.refresh_epg_data",
kwargs={"source_id": instance.id},
interval_hours=int(instance.refresh_interval),
cron_expression=cron_expr,
enabled=should_be_enabled,
)
task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={
"interval": interval,
"task": "apps.epg.tasks.refresh_epg_data",
"kwargs": json.dumps({"source_id": instance.id}),
"enabled": instance.refresh_interval != 0 and instance.is_active,
})
update_fields = []
if created:
task.interval = interval
if task.interval != interval:
task.interval = interval
update_fields.append("interval")
# Check both refresh_interval and is_active to determine if task should be enabled
should_be_enabled = instance.refresh_interval != 0 and instance.is_active
if task.enabled != should_be_enabled:
task.enabled = should_be_enabled
update_fields.append("enabled")
if update_fields:
task.save(update_fields=update_fields)
if instance.refresh_task != task:
instance.refresh_task = task
instance.save(update_fields=["refresh_task"]) # Fixed field name
instance.save(update_fields=["refresh_task"])
@receiver(post_delete, sender=EPGSource)
def delete_refresh_task(sender, instance, **kwargs):

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

@ -37,7 +37,9 @@ import json
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
queryset = M3UAccount.objects.prefetch_related("channel_group")
queryset = M3UAccount.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).prefetch_related("channel_group")
serializer_class = M3UAccountSerializer
def get_permissions(self):

View file

@ -139,6 +139,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
cron_expression = serializers.CharField(required=False, allow_blank=True, default="")
class Meta:
model = M3UAccount
@ -158,6 +159,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"locked",
"channel_groups",
"refresh_interval",
"cron_expression",
"custom_properties",
"account_type",
"username",
@ -188,9 +190,30 @@ class M3UAccountSerializer(serializers.ModelSerializer):
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
# But first check if we have a transient _cron_expression (from create/update before signal runs)
cron_expr = ""
if hasattr(instance, '_cron_expression'):
cron_expr = instance._cron_expression
elif 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}"
data["cron_expression"] = cron_expr
return data
def update(self, instance, validated_data):
# 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
# Handle enable_vod preference and auto_enable_new_groups settings
enable_vod = validated_data.pop("enable_vod", None)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
@ -244,6 +267,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
return instance
def create(self, validated_data):
# Pop cron_expression — it's not a model field
cron_expr = validated_data.pop("cron_expression", "")
# Handle enable_vod preference and auto_enable_new_groups settings during creation
enable_vod = validated_data.pop("enable_vod", False)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
@ -260,7 +286,11 @@ class M3UAccountSerializer(serializers.ModelSerializer):
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
validated_data["custom_properties"] = custom_props
return super().create(validated_data)
# Build instance manually so we can attach transient attr before save triggers signal
instance = M3UAccount(**validated_data)
instance._cron_expression = cron_expr
instance.save()
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")

View file

@ -3,7 +3,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import M3UAccount
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
import json
import logging
@ -20,51 +20,53 @@ 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.
"""
task_name = f"m3u_account-refresh-{instance.id}"
# 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
interval, _ = IntervalSchedule.objects.get_or_create(
every=int(instance.refresh_interval),
period=IntervalSchedule.HOURS
task_name = f"m3u_account-refresh-{instance.id}"
should_be_enabled = instance.is_active
# 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,
celery_task_path="apps.m3u.tasks.refresh_single_m3u_account",
kwargs={"account_id": instance.id},
interval_hours=int(instance.refresh_interval),
cron_expression=cron_expr,
enabled=should_be_enabled,
)
# Task should be enabled only if refresh_interval != 0 AND account is active
should_be_enabled = (instance.refresh_interval != 0) and instance.is_active
# First check if the task already exists to avoid validation errors
try:
task = PeriodicTask.objects.get(name=task_name)
# Task exists, just update it
updated_fields = []
if task.enabled != should_be_enabled:
task.enabled = should_be_enabled
updated_fields.append("enabled")
if task.interval != interval:
task.interval = interval
updated_fields.append("interval")
if updated_fields:
task.save(update_fields=updated_fields)
# Ensure instance has the task
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
except PeriodicTask.DoesNotExist:
# Create new task if it doesn't exist
refresh_task = PeriodicTask.objects.create(
name=task_name,
interval=interval,
task="apps.m3u.tasks.refresh_single_m3u_account",
kwargs=json.dumps({"account_id": instance.id}),
enabled=should_be_enabled,
)
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
# Ensure instance has the task linked
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
@receiver(post_delete, sender=M3UAccount)
def delete_refresh_task(sender, instance, **kwargs):

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:
@ -1648,6 +1685,13 @@ def sync_auto_channels(account_id, scan_start_time=None):
channels_updated = 0
channels_deleted = 0
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
used_numbers = set(
Channel.objects.exclude(
auto_created=True, auto_created_by=account
).values_list("channel_number", flat=True)
)
for group_relation in auto_sync_groups:
channel_group = group_relation.channel_group
start_number = group_relation.auto_sync_channel_start or 1.0
@ -1665,6 +1709,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
stream_profile_id = None
custom_logo_id = None
custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg)
channel_numbering_mode = "fixed" # Default mode
channel_numbering_fallback = 1 # Default fallback for provider mode
if group_relation.custom_properties:
group_custom_props = group_relation.custom_properties
force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
@ -1682,6 +1728,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
)
stream_profile_id = group_custom_props.get("stream_profile_id")
custom_logo_id = group_custom_props.get("custom_logo_id")
channel_numbering_mode = group_custom_props.get("channel_numbering_mode", "fixed")
channel_numbering_fallback = group_custom_props.get("channel_numbering_fallback", 1)
# Determine which group to use for created channels
target_group = channel_group
@ -1697,7 +1745,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
)
logger.info(
f"Processing auto sync for group: {channel_group.name} (start: {start_number})"
f"Processing auto sync for group: {channel_group.name} (mode: {channel_numbering_mode}, start: {start_number})"
)
# Get all current streams in this group for this M3U account, filter out stale streams
@ -1837,21 +1885,35 @@ def sync_auto_channels(account_id, scan_start_time=None):
channels_to_renumber = []
temp_channel_number = start_number
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
used_numbers = set(
Channel.objects.exclude(
auto_created=True, auto_created_by=account
).values_list("channel_number", flat=True)
)
for stream in current_streams:
if stream.id in existing_channel_map:
channel = existing_channel_map[stream.id]
# Find next available number starting from temp_channel_number
target_number = temp_channel_number
while target_number in used_numbers:
target_number += 1
# Determine target number based on numbering mode
if channel_numbering_mode == "provider":
# Use provider number if available, otherwise use fallback with next available logic
if stream.stream_chno is not None:
target_number = stream.stream_chno
# If provider number is already used, find next available
if target_number in used_numbers:
target_number = channel_numbering_fallback
while target_number in used_numbers:
target_number += 1
else:
# No provider number, use fallback and find next available
target_number = channel_numbering_fallback
while target_number in used_numbers:
target_number += 1
elif channel_numbering_mode == "next_available":
# Find next available starting from 1
target_number = 1
while target_number in used_numbers:
target_number += 1
else: # fixed mode (default)
# Find next available number starting from temp_channel_number
target_number = temp_channel_number
while target_number in used_numbers:
target_number += 1
# Add this number to used_numbers so we don't reuse it in this batch
used_numbers.add(target_number)
@ -1863,9 +1925,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
f"Will renumber channel '{channel.name}' to {target_number}"
)
temp_channel_number += 1.0
if temp_channel_number % 1 != 0: # Has decimal
temp_channel_number = int(temp_channel_number) + 1.0
# Only increment temp_channel_number in fixed mode
if channel_numbering_mode == "fixed":
temp_channel_number += 1.0
if temp_channel_number % 1 != 0: # Has decimal
temp_channel_number = int(temp_channel_number) + 1.0
# Bulk update channel numbers if any need renumbering
if channels_to_renumber:
@ -2060,10 +2124,31 @@ def sync_auto_channels(account_id, scan_start_time=None):
else:
# Create new channel
# Find next available channel number
target_number = current_channel_number
while target_number in used_numbers:
target_number += 1
# Determine channel number based on numbering mode
if channel_numbering_mode == "provider":
# Use provider number if available, otherwise use fallback with next available logic
if stream.stream_chno is not None:
target_number = stream.stream_chno
# If provider number is already used, find next available from fallback
if target_number in used_numbers:
target_number = channel_numbering_fallback
while target_number in used_numbers:
target_number += 1
else:
# No provider number, use fallback and find next available
target_number = channel_numbering_fallback
while target_number in used_numbers:
target_number += 1
elif channel_numbering_mode == "next_available":
# Find next available starting from 1
target_number = 1
while target_number in used_numbers:
target_number += 1
else: # fixed mode (default)
# Find next available channel number starting from current_channel_number
target_number = current_channel_number
while target_number in used_numbers:
target_number += 1
# Add this number to used_numbers
used_numbers.add(target_number)
@ -2190,10 +2275,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
f"Created auto channel: {channel.channel_number} - {channel.name}"
)
# Increment channel number for next iteration
current_channel_number += 1.0
if current_channel_number % 1 != 0: # Has decimal
current_channel_number = int(current_channel_number) + 1.0
# Increment channel number for next iteration (only in fixed mode)
if channel_numbering_mode == "fixed":
current_channel_number += 1.0
if current_channel_number % 1 != 0: # Has decimal
current_channel_number = int(current_channel_number) + 1.0
except Exception as e:
logger.error(
@ -2305,10 +2391,12 @@ def get_transformed_credentials(account, profile=None):
parsed_url = urllib.parse.urlparse(transformed_complete_url)
path_parts = [part for part in parsed_url.path.split('/') if part]
if len(path_parts) >= 2:
# Extract username and password from path
transformed_username = path_parts[1]
transformed_password = path_parts[2]
if len(path_parts) >= 4 and path_parts[-1] == '1234.ts':
# Extract username and password from the known structure:
# .../{live}/{username}/{password}/1234.ts
# Using negative indices so sub-paths in the server URL don't shift extraction
transformed_username = path_parts[-3]
transformed_password = path_parts[-2]
# Rebuild server URL without the username/password path
transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
@ -2542,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
@ -2559,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
@ -2588,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"
@ -2638,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"
@ -2671,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"
@ -2694,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"
@ -3004,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

@ -183,8 +183,9 @@ def generate_m3u(request, profile_name=None, user=None):
# Check if this is an XC API request (has username/password in GET params and user is authenticated)
xc_username = request.GET.get('username')
xc_password = request.GET.get('password')
is_xc_request = user is not None and xc_username and xc_password
if user is not None and xc_username and xc_password:
if is_xc_request:
# This is an XC API request - use XC-style EPG URL
base_url = build_absolute_uri_with_port(request, '')
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
@ -254,8 +255,12 @@ def generate_m3u(request, profile_name=None, user=None):
f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n'
)
# Determine the stream URL based on the direct parameter
if use_direct_urls:
# Determine the stream URL based on request type
if is_xc_request:
# XC API request - use XC-style stream URL format
base_url = build_absolute_uri_with_port(request, '')
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}"
elif use_direct_urls:
# Try to get the first stream's direct URL
first_stream = channel.streams.order_by('channelstream__order').first()
if first_stream and first_stream.url:
@ -506,6 +511,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone
program_duration = custom_properties.get('program_duration', 180) # Minutes
title_template = custom_properties.get('title_template', '')
subtitle_template = custom_properties.get('subtitle_template', '')
description_template = custom_properties.get('description_template', '')
# Templates for upcoming/ended programs
@ -911,6 +917,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
title_parts.append(all_groups['title'])
main_event_title = ' - '.join(title_parts) if title_parts else channel_name
if subtitle_template:
main_event_subtitle = format_template(subtitle_template, all_groups)
else:
main_event_subtitle = None
if description_template:
main_event_description = format_template(description_template, all_groups)
else:
@ -961,6 +972,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
"start_time": program_start_utc,
"end_time": program_end_utc,
"title": upcoming_title,
"sub_title": None, # No subtitle for filler programs
"description": upcoming_description,
"custom_properties": program_custom_properties,
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
@ -1000,6 +1012,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
"start_time": event_start_utc,
"end_time": event_end_utc,
"title": main_event_title,
"sub_title": main_event_subtitle,
"description": main_event_description,
"custom_properties": main_event_custom_properties,
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
@ -1044,6 +1057,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
"start_time": program_start_utc,
"end_time": program_end_utc,
"title": ended_title,
"sub_title": None, # No subtitle for filler programs
"description": ended_description,
"custom_properties": program_custom_properties,
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
@ -1104,6 +1118,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
"start_time": program_start_utc,
"end_time": program_end_utc,
"title": program_title,
"sub_title": None, # No subtitle for filler programs
"description": program_description,
"custom_properties": program_custom_properties,
"channel_logo_url": channel_logo_url,
@ -1131,6 +1146,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
title_parts.append(all_groups['title'])
title = ' - '.join(title_parts) if title_parts else channel_name
if subtitle_template:
subtitle = format_template(subtitle_template, all_groups)
else:
subtitle = None
if description_template:
description = format_template(description_template, all_groups)
else:
@ -1167,6 +1187,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
"start_time": program_start_utc,
"end_time": program_end_utc,
"title": title,
"sub_title": subtitle,
"description": description,
"custom_properties": program_custom_properties,
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
@ -1206,6 +1227,11 @@ def generate_dummy_epg(
f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(program["channel_id"])}">'
)
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
# Add subtitle if available
if program.get('sub_title'):
xml_lines.append(f" <sub-title>{html.escape(program['sub_title'])}</sub-title>")
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")
# Add custom_properties if present
@ -1525,6 +1551,11 @@ def generate_epg(request, profile_name=None, user=None):
# Create program entry with escaped channel name
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
yield f" <title>{html.escape(program['title'])}</title>\n"
# Add subtitle if available
if program.get('sub_title'):
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
yield f" <desc>{html.escape(program['description'])}</desc>\n"
# Add custom_properties if present
@ -1574,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None):
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
yield f" <title>{html.escape(program['title'])}</title>\n"
# Add subtitle if available
if program.get('sub_title'):
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
yield f" <desc>{html.escape(program['description'])}</desc>\n"
# Add custom_properties if present
@ -1909,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:
@ -2208,7 +2245,7 @@ def xc_get_live_streams(request, user, category_id=None):
)
),
"epg_channel_id": str(channel_num_int),
"added": int(channel.created_at.timestamp()),
"added": str(int(channel.created_at.timestamp())),
"is_adult": int(channel.is_adult),
"category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id),
"category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id],
@ -2523,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
@ -2870,7 +2909,7 @@ def xc_get_vod_info(request, user, vod_id):
"movie_data": {
"stream_id": movie.id,
"name": movie.name,
"added": int(movie_relation.created_at.timestamp()),
"added": str(int(movie_relation.created_at.timestamp())),
"category_id": str(movie_relation.category.id) if movie_relation.category else "0",
"category_ids": [int(movie_relation.category.id)] if movie_relation.category else [],
"container_extension": movie_relation.container_extension or "mp4",

View file

@ -9,6 +9,9 @@ class PluginActionSerializer(serializers.Serializer):
button_label = serializers.CharField(required=False, allow_blank=True)
button_variant = serializers.CharField(required=False, allow_blank=True)
button_color = serializers.CharField(required=False, allow_blank=True)
events = serializers.ListField(
child=serializers.CharField(), required=False, allow_empty=True
)
class PluginFieldOptionSerializer(serializers.Serializer):

View file

@ -459,13 +459,12 @@ class VODConnectionManager:
return False
try:
# Check profile connection limits using standardized key
if not self._check_profile_limits(m3u_profile):
# Atomically check and reserve a profile connection slot (INCR-first)
if not self._check_and_reserve_profile_slot(m3u_profile):
logger.warning(f"Profile {m3u_profile.name} connection limit exceeded")
return False
connection_key = self._get_connection_key(content_type, content_uuid, client_id)
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
content_connections_key = self._get_content_connections_key(content_type, content_uuid)
# Check if connection already exists to prevent duplicate counting
@ -473,6 +472,9 @@ class VODConnectionManager:
logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}")
# Update activity but don't increment profile counter
self.redis_client.hset(connection_key, "last_activity", str(time.time()))
# Roll back the reservation — connection already counted
if m3u_profile.max_streams > 0:
self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id))
return True
# Connection data
@ -499,8 +501,7 @@ class VODConnectionManager:
pipe.hset(connection_key, mapping=connection_data)
pipe.expire(connection_key, self.connection_ttl)
# Increment profile connections using standardized method
pipe.incr(profile_connections_key)
# Profile counter already incremented atomically above — no pipe.incr needed
# Add to content connections set
pipe.sadd(content_connections_key, client_id)
@ -513,6 +514,9 @@ class VODConnectionManager:
return True
except Exception as e:
# Roll back the profile reservation on failure
if m3u_profile.max_streams > 0:
self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id))
logger.error(f"Error creating VOD connection: {e}")
return False
@ -531,6 +535,41 @@ class VODConnectionManager:
logger.error(f"Error checking profile limits: {e}")
return False
def _check_and_reserve_profile_slot(self, m3u_profile: M3UAccountProfile) -> bool:
"""
Atomically check and reserve a connection slot for the given profile.
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
condition where separate GET > check > INCR operations could allow
concurrent requests to both pass the capacity check.
For profiles with max_streams=0 (unlimited), no reservation is needed.
Returns:
bool: True if slot was reserved (or unlimited), False if at capacity
"""
if m3u_profile.max_streams == 0: # Unlimited
return True
try:
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
# Atomically increment first — single Redis command eliminates race window
new_count = self.redis_client.incr(profile_connections_key)
if new_count <= m3u_profile.max_streams:
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
return True
# Over capacity — roll back the increment
self.redis_client.decr(profile_connections_key)
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
return False
except Exception as e:
logger.error(f"Error reserving profile slot: {e}")
return False
def update_connection_activity(self, content_type: str, content_uuid: str,
client_id: str, bytes_sent: int = 0,
position_seconds: int = 0) -> bool:

View file

@ -416,8 +416,22 @@ class RedisBackedVODConnection:
logger.info(f"[{self.session_id}] Updated connection state: length={state.content_length}, type={state.content_type}")
# Save updated state
self._save_connection_state(state)
# Save updated state under lock to avoid overwriting concurrent
# active_streams changes (e.g., another stream's GeneratorExit decrement)
if self._acquire_lock():
try:
current = self._get_connection_state()
if current:
# Preserve the current active_streams value — it may have been
# modified by concurrent increment/decrement operations while
# waiting for the upstream HTTP response.
state.active_streams = current.active_streams
self._save_connection_state(state)
finally:
self._release_lock()
else:
# Fallback: save without lock but skip active_streams to avoid overwrite
logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save")
self.local_response = response
return response
@ -466,35 +480,44 @@ class RedisBackedVODConnection:
return range_header
def increment_active_streams(self):
"""Increment active streams count in Redis"""
"""Increment active streams count in Redis. Returns new active_streams count, or 0 on failure."""
if not self._acquire_lock():
return False
logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock")
return 0
try:
state = self._get_connection_state()
if state:
old = state.active_streams
state.active_streams += 1
state.last_activity = time.time()
self._save_connection_state(state)
logger.debug(f"[{self.session_id}] Active streams incremented to {state.active_streams}")
return True
return False
logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}")
return state.active_streams
logger.warning(f"[{self.session_id}] INCR-AS failed: no state")
return 0
finally:
self._release_lock()
def decrement_active_streams(self):
"""Decrement active streams count in Redis"""
if not self._acquire_lock():
logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock")
return False
try:
state = self._get_connection_state()
if state and state.active_streams > 0:
old = state.active_streams
state.active_streams -= 1
state.last_activity = time.time()
self._save_connection_state(state)
logger.debug(f"[{self.session_id}] Active streams decremented to {state.active_streams}")
logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}")
return True
if not state:
logger.warning(f"[{self.session_id}] DECR-AS failed: no state")
else:
logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}")
return False
finally:
self._release_lock()
@ -674,6 +697,41 @@ class MultiWorkerVODConnectionManager:
logger.error(f"Error checking profile limits: {e}")
return False
def _check_and_reserve_profile_slot(self, m3u_profile) -> bool:
"""
Atomically check and reserve a connection slot for the given profile.
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
condition where separate GET > check > INCR operations could allow
concurrent requests to both pass the capacity check.
For profiles with max_streams=0 (unlimited), no reservation is needed.
Returns:
bool: True if slot was reserved (or unlimited), False if at capacity
"""
if m3u_profile.max_streams == 0: # Unlimited
return True
try:
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
# Atomically increment first — single Redis command eliminates race window
new_count = self.redis_client.incr(profile_connections_key)
if new_count <= m3u_profile.max_streams:
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
return True
# Over capacity — roll back the increment
self.redis_client.decr(profile_connections_key)
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
return False
except Exception as e:
logger.error(f"Error reserving profile slot: {e}")
return False
def _increment_profile_connections(self, m3u_profile):
"""Increment profile connection count"""
try:
@ -756,10 +814,11 @@ class MultiWorkerVODConnectionManager:
if not existing_state:
logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection")
# Check profile limits before creating new connection
if not self._check_profile_limits(m3u_profile):
# Atomically check and reserve a profile connection slot (INCR-first)
if not self._check_and_reserve_profile_slot(m3u_profile):
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded")
return HttpResponse("Connection limit exceeded for profile", status=429)
profile_connections_incremented = True
# Apply timeshift parameters
modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset)
@ -802,16 +861,43 @@ class MultiWorkerVODConnectionManager:
worker_id=self.worker_id
):
logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection")
# Roll back the profile slot reservation since connection failed
self._decrement_profile_connections(m3u_profile.id)
profile_connections_incremented = False
return HttpResponse("Failed to create connection", status=500)
# Increment profile connections after successful connection creation
self._increment_profile_connections(m3u_profile)
profile_connections_incremented = True
logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata")
else:
logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection")
# Immediately increment active_streams to prevent cleanup race condition.
# Without this, stream's GeneratorExit can see active_streams=0
# and DECR the profile counter before the new generator starts.
if matching_session_id:
# Idle session reuse: active_streams already incremented at line 776
# Always need to re-reserve profile slot (GeneratorExit DECRed it)
if not self._check_and_reserve_profile_slot(m3u_profile):
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse")
redis_connection.decrement_active_streams()
return HttpResponse("Connection limit exceeded for profile", status=429)
profile_connections_incremented = True
else:
# Concurrent/reconnect: increment active_streams now (not in generator)
new_count = redis_connection.increment_active_streams()
if new_count == 1:
# 0→1 transition: previous stream's GeneratorExit already DECRed
# the profile counter, need to re-reserve the slot
if not self._check_and_reserve_profile_slot(m3u_profile):
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect")
redis_connection.decrement_active_streams()
return HttpResponse("Connection limit exceeded for profile", status=429)
profile_connections_incremented = True
elif new_count == 0:
logger.error(f"[{client_id}] Failed to increment active streams")
return HttpResponse("Failed to reserve stream", status=500)
# else: new_count > 1, another stream is already active and profile
# counter already reflects it — no INCR needed
# Transfer ownership to current worker and update session activity
if redis_connection._acquire_lock():
try:
@ -834,6 +920,12 @@ class MultiWorkerVODConnectionManager:
if upstream_response is None:
logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable")
if existing_state:
# Roll back the active_streams increment from the else branch
redis_connection.decrement_active_streams()
if profile_connections_incremented:
self._decrement_profile_connections(m3u_profile.id)
profile_connections_incremented = False
return HttpResponse("Requested Range Not Satisfiable", status=416)
# Get connection headers
@ -846,13 +938,14 @@ class MultiWorkerVODConnectionManager:
try:
logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream")
# Increment active streams (unless we already did it for session reuse)
if not matching_session_id:
# New session - increment active streams
# Increment active streams only for brand-new connections.
# For existing connections (session reuse or concurrent requests),
# active_streams was already incremented in the else branch above
# to prevent cleanup race conditions with GeneratorExit.
if not existing_state:
redis_connection.increment_active_streams()
else:
# Reused session - we already incremented when reserving the session
logger.debug(f"[{client_id}] Using pre-reserved session - active streams already incremented")
logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path")
bytes_sent = 0
chunk_count = 0
@ -898,11 +991,19 @@ class MultiWorkerVODConnectionManager:
# Schedule smart cleanup if no active streams after normal completion
if not redis_connection.has_active_streams():
# Decrement profile counter immediately — don't defer to daemon thread
state = redis_connection._get_connection_state()
profile_id = state.m3u_profile_id if state else m3u_profile.id
if profile_id:
self._decrement_profile_connections(profile_id)
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on normal completion")
def delayed_cleanup():
time.sleep(1) # Wait 1 second
# Smart cleanup: check active streams and ownership
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion")
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
# No connection_manager — profile already decremented above
redis_connection.cleanup(current_worker_id=self.worker_id)
import threading
cleanup_thread = threading.Thread(target=delayed_cleanup)
@ -917,11 +1018,19 @@ class MultiWorkerVODConnectionManager:
# Schedule smart cleanup if no active streams
if not redis_connection.has_active_streams():
# Decrement profile counter immediately — don't defer to daemon thread
state = redis_connection._get_connection_state()
profile_id = state.m3u_profile_id if state else m3u_profile.id
if profile_id:
self._decrement_profile_connections(profile_id)
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on client disconnect")
def delayed_cleanup():
time.sleep(1) # Wait 1 second
# Smart cleanup: check active streams and ownership
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect")
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
# No connection_manager — profile already decremented above
redis_connection.cleanup(current_worker_id=self.worker_id)
import threading
cleanup_thread = threading.Thread(target=delayed_cleanup)
@ -933,8 +1042,17 @@ class MultiWorkerVODConnectionManager:
if not decremented:
redis_connection.decrement_active_streams()
decremented = True
# Smart cleanup on error - immediate cleanup since we're in error state
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
# Decrement profile counter immediately if no other active streams
if not redis_connection.has_active_streams():
state = redis_connection._get_connection_state()
profile_id = state.m3u_profile_id if state else m3u_profile.id
if profile_id:
self._decrement_profile_connections(profile_id)
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on stream error")
# Smart cleanup on error - immediate cleanup since we're in error state
# No connection_manager — profile already decremented above
redis_connection.cleanup(current_worker_id=self.worker_id)
yield b"Error: Stream interrupted"
finally:

View file

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

203
core/scheduling.py Normal file
View file

@ -0,0 +1,203 @@
"""
Reusable scheduling utilities for creating/updating/deleting
Celery Beat periodic tasks with interval or cron-based schedules.
"""
import json
import logging
from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask
from core.models import CoreSettings
logger = logging.getLogger(__name__)
def parse_cron_expression(cron_expression):
"""
Parse a 5-part cron expression into its components.
Args:
cron_expression: A string like "0 3 * * *"
Returns:
dict with keys: minute, hour, day_of_month, month_of_year, day_of_week
Raises:
ValueError: If the expression is not valid 5-part cron.
"""
parts = cron_expression.strip().split()
if len(parts) != 5:
raise ValueError(
"Cron expression must have 5 parts: minute hour day month weekday"
)
return {
"minute": parts[0],
"hour": parts[1],
"day_of_month": parts[2],
"month_of_year": parts[3],
"day_of_week": parts[4],
}
def create_or_update_periodic_task(
task_name,
celery_task_path,
kwargs=None,
interval_hours=0,
cron_expression="",
enabled=True,
):
"""
Create or update a Celery Beat PeriodicTask. Supports both interval
(hours) and cron-based scheduling.
When *cron_expression* is provided and non-empty it takes precedence
over *interval_hours*. An interval_hours of 0 (with no cron) means
the task is disabled.
Args:
task_name: Unique PeriodicTask name.
celery_task_path: Dotted path to the Celery task function.
kwargs: dict of keyword arguments passed to the task.
interval_hours: Interval in hours (0 = disabled when no cron).
cron_expression: 5-part cron string (empty = use interval).
enabled: Whether the task should be enabled.
Returns:
The PeriodicTask instance (created or updated).
"""
task_kwargs = json.dumps(kwargs or {})
# Determine effective enabled state
use_cron = bool(cron_expression and cron_expression.strip())
should_be_enabled = enabled and (use_cron or interval_hours > 0)
# Retrieve existing task (if any) to track old schedule objects
old_interval = None
old_crontab = None
try:
existing = PeriodicTask.objects.get(name=task_name)
old_interval = existing.interval
old_crontab = existing.crontab
except PeriodicTask.DoesNotExist:
existing = None
if use_cron:
# ---- Cron-based schedule ----
cron_parts = parse_cron_expression(cron_expression)
system_tz = CoreSettings.get_system_time_zone()
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=cron_parts["minute"],
hour=cron_parts["hour"],
day_of_week=cron_parts["day_of_week"],
day_of_month=cron_parts["day_of_month"],
month_of_year=cron_parts["month_of_year"],
timezone=system_tz,
)
defaults = {
"task": celery_task_path,
"crontab": crontab,
"interval": None,
"enabled": should_be_enabled,
"kwargs": task_kwargs,
}
task, created = PeriodicTask.objects.update_or_create(
name=task_name, defaults=defaults
)
# Clean up old interval if we switched from interval → cron
if old_interval:
_cleanup_orphaned_interval(old_interval)
# Clean up old crontab if it changed
if old_crontab and old_crontab.id != crontab.id:
_cleanup_orphaned_crontab(old_crontab)
else:
# ---- Interval-based schedule ----
interval, _ = IntervalSchedule.objects.get_or_create(
every=max(int(interval_hours), 1) if interval_hours else 1,
period=IntervalSchedule.HOURS,
)
defaults = {
"task": celery_task_path,
"interval": interval,
"crontab": None,
"enabled": should_be_enabled,
"kwargs": task_kwargs,
}
task, created = PeriodicTask.objects.update_or_create(
name=task_name, defaults=defaults
)
# Clean up old crontab if we switched from cron → interval
if old_crontab:
_cleanup_orphaned_crontab(old_crontab)
# Clean up old interval if it changed
if old_interval and old_interval.id != interval.id:
_cleanup_orphaned_interval(old_interval)
action = "Created" if created else "Updated"
mode = "cron" if use_cron else "interval"
logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})")
return task
def delete_periodic_task(task_name):
"""
Delete a PeriodicTask by name and clean up orphaned schedules.
Args:
task_name: The unique name of the PeriodicTask.
Returns:
True if a task was found and deleted, False otherwise.
"""
try:
task = PeriodicTask.objects.get(name=task_name)
except PeriodicTask.DoesNotExist:
logger.warning(f"No PeriodicTask found with name '{task_name}'")
return False
old_interval = task.interval
old_crontab = task.crontab
task_id = task.id
task.delete()
logger.info(f"Deleted periodic task '{task_name}' (id={task_id})")
if old_interval:
_cleanup_orphaned_interval(old_interval)
if old_crontab:
_cleanup_orphaned_crontab(old_crontab)
return True
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _cleanup_orphaned_interval(interval_schedule):
"""Delete an IntervalSchedule if no PeriodicTasks reference it."""
if interval_schedule is None:
return
if PeriodicTask.objects.filter(interval=interval_schedule).exists():
return
logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}")
interval_schedule.delete()
def _cleanup_orphaned_crontab(crontab_schedule):
"""Delete a CrontabSchedule if no PeriodicTasks reference it."""
if crontab_schedule is None:
return
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
return
logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}")
crontab_schedule.delete()

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.
@ -397,6 +466,83 @@ def validate_flexible_url(value):
# If it doesn't match our flexible patterns, raise the original error
raise ValidationError("Enter a valid URL.")
def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details):
try:
from apps.connect.utils import trigger_event
from apps.channels.models import Channel, Stream
from core.models import StreamProfile
from core.utils import RedisClient
payload = dict(details)
channel_obj = None
if channel_id:
try:
channel_obj = Channel.objects.get(uuid=channel_id)
payload["channel_name"] = channel_obj.name
except Exception:
payload["channel_name"] = channel_name or None
else:
payload["channel_name"] = channel_name or None
# Resolve current stream info
stream_id = details.get("stream_id")
stream_obj = None
if not stream_id and channel_obj:
try:
redis = RedisClient.get_client()
sid = redis.get(f"channel_stream:{channel_obj.id}")
if sid:
stream_id = int(sid)
except Exception:
stream_id = None
if stream_id:
try:
stream_obj = Stream.objects.get(id=stream_id)
except Exception:
stream_obj = None
# Populate stream details
payload["stream_name"] = getattr(stream_obj, "name", None)
payload["stream_url"] = getattr(stream_obj, "url", None)
# Channel URL: use stream URL as best-effort
payload["channel_url"] = payload.get("stream_url")
# Provider name from M3U account
provider_name = None
try:
if stream_obj and stream_obj.m3u_account:
provider_name = stream_obj.m3u_account.name
except Exception:
provider_name = None
payload["provider_name"] = provider_name
# Profile used
profile_used = None
try:
if stream_id:
redis = RedisClient.get_client()
pid = redis.get(f"stream_profile:{stream_id}")
if pid:
profile = StreamProfile.objects.filter(id=int(pid)).first()
profile_used = profile.name if profile else None
except Exception:
profile_used = None
payload["profile_used"] = profile_used
# 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:
# Don't fail main path if connect dispatch fails
pass
def log_system_event(event_type, channel_id=None, channel_name=None, **details):
"""
@ -423,6 +569,9 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
details=details
)
# Trigger connect integrations for specific events
dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details)
# Get max events from settings (default 100)
try:
from .models import CoreSettings
@ -517,4 +666,3 @@ def send_notification_dismissed(notification_key):
)
except Exception as e:
logger.error(f"Failed to send notification dismissed event: {e}")

View file

@ -34,6 +34,7 @@ INSTALLED_APPS = [
"apps.proxy.apps.ProxyConfig",
"apps.proxy.ts_proxy",
"apps.vod.apps.VODConfig",
"apps.connect.apps.ConnectConfig",
"core",
"daphne",
"drf_spectacular",
@ -167,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"],
}
@ -411,3 +413,18 @@ LOGGING = {
"level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO'
},
}
# Connect script execution safety settings
# Allowed base directories for custom scripts; real paths must be inside
_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/scripts")
CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p]
# Max execution time (seconds) for scripts
CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10"))
# Truncate stdout/stderr to this many characters to avoid large outputs
CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536"))
# Require executable bit and disallow world-writable files
CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True
CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True

View file

@ -119,6 +119,11 @@ services:
# Process Priority Configuration (Optional)
#- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19)
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
# that lack support for newer baseline CPU features:
#- USE_LEGACY_NUMPY=true
# Django Configuration
- DJANGO_SETTINGS_MODULE=dispatcharr.settings
- PYTHONUNBUFFERED=1

View file

@ -4,15 +4,32 @@ set -e
cd /app
source /dispatcharrpy/bin/activate
# Function to echo with timestamp
echo_with_timestamp() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# Wait for Django secret key
echo 'Waiting for Django secret key...'
while [ ! -f /data/jwt ]; do sleep 1; done
export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)"
# --- NumPy version switching for legacy hardware ---
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
# Check if NumPy was compiled with baseline support
if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
echo_with_timestamp "✅ Legacy NumPy installed"
else
echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
fi
fi
# Wait for migrations to complete (check that NO unapplied migrations remain)
echo 'Waiting for migrations to complete...'
until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do
echo 'Migrations not ready yet, waiting...'
echo_with_timestamp 'Migrations not ready yet, waiting...'
sleep 2
done

View file

@ -163,20 +163,9 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
pids+=("$postgres_pid")
else
echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}"
# Wait for external PostgreSQL to be ready using Python (no pg_isready needed)
# Wait for external PostgreSQL to be ready using pg_isready (checks actual protocol readiness)
echo_with_timestamp "Waiting for external PostgreSQL to be ready..."
until python3 -c "
import socket
import sys
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)
s.connect(('${POSTGRES_HOST}', ${POSTGRES_PORT}))
s.close()
sys.exit(0)
except Exception:
sys.exit(1)
" 2>/dev/null; do
until $PG_BINDIR/pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -q >/dev/null 2>&1; do
echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..."
sleep 1
done
@ -191,12 +180,9 @@ if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}"
echo_with_timestamp "Waiting for external Redis to be ready..."
until python3 -c "
import socket
import sys
import socket, sys
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)
s.connect(('${REDIS_HOST}', ${REDIS_PORT}))
s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2)
s.close()
sys.exit(0)
except Exception:

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

File diff suppressed because it is too large Load diff

View file

@ -70,6 +70,7 @@
"react-dom": "19.1.0"
},
"overrides": {
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"minimatch": "^10.2.1"
}
}

View file

@ -14,6 +14,8 @@ import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import PluginsPage from './pages/Plugins';
import ConnectPage from './pages/Connect';
import ConnectLogsPage from './pages/ConnectLogs';
import Users from './pages/Users';
import LogosPage from './pages/Logos';
import VODsPage from './pages/VODs';
@ -61,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) {
@ -152,6 +154,11 @@ const App = () => {
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/plugins" element={<PluginsPage />} />
<Route path="/connect" element={<ConnectPage />} />
<Route
path="/connect/logs"
element={<ConnectLogsPage />}
/>
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />

View file

@ -424,7 +424,7 @@ export const WebsocketProvider = ({ children }) => {
// Refresh channels data and logos
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannels();
await useChannelsStore.getState().fetchChannelIds();
// Get updated channel data and extract logo IDs to load
const channels = useChannelsStore.getState().channels;
@ -489,7 +489,7 @@ export const WebsocketProvider = ({ children }) => {
// Refresh channels data
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannels();
await useChannelsStore.getState().fetchChannelIds();
} catch (e) {
console.warn(
'Failed to refresh channels after name setting:',
@ -704,7 +704,7 @@ export const WebsocketProvider = ({ children }) => {
try {
await API.requeryChannels();
await API.requeryStreams();
await useChannelsStore.getState().fetchChannels();
await useChannelsStore.getState().fetchChannelIds();
} catch (error) {
console.error(
'Error refreshing channels/streams after rehash:',
@ -767,7 +767,7 @@ export const WebsocketProvider = ({ children }) => {
try {
await API.requeryChannels();
await API.requeryStreams();
await useChannelsStore.getState().fetchChannels();
useChannelsStore.getState().fetchChannelIds();
await fetchChannelProfiles();
console.log('Channels refreshed after bulk creation');
} catch (error) {

View file

@ -12,6 +12,8 @@ import { notifications } from '@mantine/notifications';
import useChannelsTableStore from './store/channelsTable';
import useStreamsTableStore from './store/streamsTable';
import useUsersStore from './store/users';
import useConnectStore from './store/connect';
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
@ -104,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/`, {
@ -178,14 +215,62 @@ export default class API {
static async getChannels() {
try {
const response = await request(`${host}/api/channels/channels/`);
// Paginate through channels to avoid heavy single response
const pageSize = 200;
const allChannels = [];
return response;
// 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)) {
return data;
}
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);
}
}
/**
* Retrieve a lightweight summary of channels (id, name, logo_id,
* channel_number, uuid, epg_data_id, channel_group_id).
* Designed for the TV Guide where full channel data is not needed.
*/
static async getChannelsSummary(params = new URLSearchParams()) {
try {
const url = `${host}/api/channels/channels/summary/?${params.toString()}`;
const data = await request(url);
return Array.isArray(data) ? data : [];
} catch (e) {
errorNotification('Failed to retrieve channel summary', e);
return [];
}
}
static async queryChannels(params) {
try {
API.lastQueryParams = params;
@ -227,6 +312,44 @@ export default class API {
}
}
/**
* Retrieve channels matching the provided query params, paging until complete.
* Does NOT touch any table/store state; returns a plain array.
*/
static async getChannelsForParams(params) {
try {
const pageSize = 200;
const query = new URLSearchParams(params);
let page = 1;
let all = [];
while (true) {
query.set('page', String(page));
query.set('page_size', String(pageSize));
const url = `${host}/api/channels/channels/?${query.toString()}`;
const data = await request(url);
if (Array.isArray(data)) {
// Legacy array response
all = data;
break;
}
const results = Array.isArray(data?.results) ? data.results : [];
all = all.concat(results);
const hasMore = Boolean(data?.next);
if (!hasMore || results.length === 0) break;
page += 1;
}
return all;
} catch (e) {
errorNotification('Failed to retrieve channels for query', e);
throw e;
}
}
static async requeryChannels() {
try {
const [response, ids] = await Promise.all([
@ -276,7 +399,7 @@ export default class API {
}
}
static async getAllChannelIds(params) {
static async getAllChannelIds(params = new URLSearchParams()) {
try {
const response = await request(
`${host}/api/channels/channels/ids/?${params.toString()}`
@ -565,6 +688,43 @@ export default class API {
}
}
// Server-side regex rename of channel names for selected IDs
static async bulkRegexRenameChannels(
channelIds,
find,
replace = '',
flags = 'g'
) {
try {
const response = await request(
`${host}/api/channels/channels/edit/bulk-regex/`,
{
method: 'POST',
body: {
channel_ids: channelIds,
find,
replace,
flags,
},
}
);
// Optional success notification
if (response?.success) {
notifications.show({
title: 'Channel Names Updated',
message: `Renamed ${response.updated_count} channel(s) via regex`,
color: 'green',
autoClose: 4000,
});
}
return response;
} catch (e) {
errorNotification('Failed to apply regex renames', e);
}
}
static async reorderChannel(channelId, insertAfterId) {
try {
const response = await request(
@ -1095,9 +1255,6 @@ export default class API {
});
usePlaylistsStore.getState().removePlaylists([id]);
// @TODO: MIGHT need to optimize this later if someone has thousands of channels
// but I'm feeling laze right now
// useChannelsStore.getState().fetchChannels();
} catch (e) {
errorNotification(`Failed to delete playlist ${id}`, e);
}
@ -1174,11 +1331,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;
@ -2668,8 +2825,6 @@ export default class API {
color: 'blue',
});
// First fetch the complete channel data
await useChannelsStore.getState().fetchChannels();
// Then refresh the current table view
this.requeryChannels();
}
@ -2720,6 +2875,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}/`, {
@ -2783,6 +2994,23 @@ export default class API {
}
}
static async getChannelsByUUIDs(uuids) {
try {
// Use POST for large lists
const response = await request(
`${host}/api/channels/channels/by-uuids/`,
{
method: 'POST',
body: { uuids },
}
);
return response;
} catch (e) {
errorNotification('Failed to retrieve channels by UUIDs', e);
throw e;
}
}
// VOD Methods
static async getMovies(params = new URLSearchParams()) {
try {
@ -3046,4 +3274,124 @@ export default class API {
errorNotification('Failed to dismiss all notifications', e);
}
}
static async getConnectIntegrations() {
try {
return await request(`${host}/api/connect/integrations/`);
} catch (e) {
errorNotification('Failed to fetch connect integrations', e);
}
}
static async createConnectIntegration(values) {
try {
const response = await request(`${host}/api/connect/integrations/`, {
method: 'POST',
body: values,
});
useConnectStore.getState().addIntegration(response);
return response;
} catch (e) {
errorNotification('Failed to create integration', e);
}
}
static async updateConnectIntegration(id, values) {
try {
const response = await request(
`${host}/api/connect/integrations/${id}/`,
{
method: 'PUT',
body: values,
}
);
if (response.id) {
useConnectStore.getState().updateIntegration(response);
}
return response;
} catch (e) {
errorNotification('Failed to update integration', e);
}
}
static async deleteConnectIntegration(id) {
try {
await request(`${host}/api/connect/integrations/${id}/`, {
method: 'DELETE',
});
useConnectStore.getState().removeIntegration(id);
return true;
} catch (e) {
errorNotification('Failed to delete integration', e);
throw e;
}
}
static async createConnectSubscription(values) {
try {
await request(`${host}/api/connect/subscriptions/`, {
method: 'POST',
body: values,
});
return true;
} catch (e) {
errorNotification('Failed to create subscription', e);
}
}
static async listConnectSubscriptions(integrationId) {
try {
return await request(
`${host}/api/connect/integrations/${integrationId}/subscriptions/`
);
} catch (e) {
errorNotification('Failed to fetch subscriptions', e);
}
}
static async setConnectSubscriptions(integrationId, subscriptions) {
// subscriptions: [{ event, enabled, payload_template }]
console.log(subscriptions);
try {
const response = await request(
`${host}/api/connect/integrations/${integrationId}/subscriptions/set/`,
{
method: 'PUT',
body: subscriptions,
}
);
useConnectStore
.getState()
.updateIntegrationSubscriptions(integrationId, response);
return true;
} catch (e) {
errorNotification('Failed to set subscriptions', e);
throw e;
}
}
static async getConnectLogs(params = {}) {
try {
const search = new URLSearchParams();
if (params.page) search.set('page', params.page);
if (params.page_size) search.set('page_size', params.page_size);
if (params.type) search.set('type', params.type);
if (params.integration) search.set('integration', params.integration);
return await request(
`${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}`
);
} catch (e) {
errorNotification('Failed to fetch connect logs', e);
}
}
}

View file

@ -1,14 +1,20 @@
import React from 'react';
import React, { useState } from 'react';
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
MINUTE_BLOCK_WIDTH,
MINUTE_INCREMENT,
PROGRAM_HEIGHT,
} from '../pages/guideUtils.js';
import { Box, Flex, Text } from '@mantine/core';
import { Play } from 'lucide-react';
import logo from '../images/logo.png';
// Buffer in pixels beyond the viewport edges to render programs.
// This prevents pop-in when scrolling horizontally.
const H_BUFFER = 600;
const GuideRow = React.memo(({ index, style, data }) => {
const {
filteredChannels,
@ -16,13 +22,16 @@ const GuideRow = React.memo(({ index, style, data }) => {
expandedProgramId,
rowHeights,
logos,
hoveredChannelId,
setHoveredChannelId,
renderProgram,
handleLogoClick,
contentWidth,
guideScrollLeftRef,
viewportWidth,
timelineStartMs,
} = data;
const [hovered, setHovered] = useState(false);
const channel = filteredChannels[index];
if (!channel) {
return null;
@ -35,30 +44,53 @@ const GuideRow = React.memo(({ index, style, data }) => {
? EXPANDED_PROGRAM_HEIGHT
: PROGRAM_HEIGHT);
// Horizontal viewport culling only render programs whose pixel range
// overlaps the visible scroll window (plus a buffer to avoid pop-in).
const scrollLeft = guideScrollLeftRef.current;
const vpLeft = scrollLeft - H_BUFFER;
const vpRight = scrollLeft + viewportWidth + H_BUFFER;
const visiblePrograms = channelPrograms.filter((program) => {
const leftPx =
((program.startMs - timelineStartMs) / 60000 / MINUTE_INCREMENT) *
MINUTE_BLOCK_WIDTH;
const durationMin = (program.endMs - program.startMs) / 60000;
const widthPx = (durationMin / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
// Program is visible if its right edge is past vpLeft AND left edge is before vpRight
return leftPx + widthPx > vpLeft && leftPx < vpRight;
});
const PlaceholderProgram = () => {
// Only render placeholder blocks that overlap the viewport
const totalPlaceholders = Math.ceil(24 / 2);
const blockWidth = HOUR_WIDTH * 2;
return (
<>
{Array.from({ length: Math.ceil(24 / 2) }).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos="absolute"
left={placeholderIndex * (HOUR_WIDTH * 2)}
top={0}
w={HOUR_WIDTH * 2}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c="#4A5568"
>
<Text size="sm">No program data</Text>
</Box>
)
{Array.from({ length: totalPlaceholders }).map(
(_, placeholderIndex) => {
const left = placeholderIndex * blockWidth;
if (left + blockWidth < vpLeft || left > vpRight) return null;
return (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos="absolute"
left={left}
top={0}
w={blockWidth}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c="#4A5568"
>
<Text size="sm">No program data</Text>
</Box>
);
}
)}
</>
);
@ -100,10 +132,10 @@ const GuideRow = React.memo(({ index, style, data }) => {
h={'100%'}
pos="relative"
onClick={(event) => handleLogoClick(channel, event)}
onMouseEnter={() => setHoveredChannelId(channel.id)}
onMouseLeave={() => setHoveredChannelId(null)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{hoveredChannelId === channel.id && (
{hovered && (
<Flex
align="center"
justify="center"
@ -194,11 +226,11 @@ const GuideRow = React.memo(({ index, style, data }) => {
h={'100%'}
pl={0}
>
{channelPrograms.length > 0 ? (
channelPrograms.map((program) =>
{visiblePrograms.length > 0 ? (
visiblePrograms.map((program) =>
renderProgram(program, undefined, channel)
)
) : (
) : channelPrograms.length > 0 ? null : (
<PlaceholderProgram />
)}
</Box>

View file

@ -16,7 +16,7 @@ export default function M3URefreshNotification() {
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
const fetchStreams = useStreamsStore((s) => s.fetchStreams);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
const fetchChannelIds = useChannelsStore((s) => s.fetchChannelIds);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
const fetchCategories = useVODStore((s) => s.fetchCategories);
@ -143,7 +143,7 @@ export default function M3URefreshNotification() {
if (data.action == 'parsing') {
fetchStreams();
API.requeryChannels();
fetchChannels();
fetchChannelIds();
} else if (data.action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();

View file

@ -5,7 +5,6 @@ import {
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
Copy,
@ -15,6 +14,12 @@ import {
LogOut,
User,
FileImage,
Webhook,
Logs,
ChevronDown,
ChevronRight,
MonitorCog,
Blocks,
} from 'lucide-react';
import {
Avatar,
@ -26,9 +31,8 @@ import {
UnstyledButton,
TextInput,
ActionIcon,
Menu,
ScrollArea,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
@ -70,10 +74,78 @@ const NavLink = ({ item, isActive, collapsed }) => {
);
};
function NavGroup({ label, icon, paths, location, collapsed }) {
const [open, setOpen] = useState(() =>
location.pathname.startsWith('/connect')
);
const parentActive = paths
.map((path) => path.path)
.includes(location.pathname);
return (
<Box
style={{ width: '100%', paddingRight: 2 }}
className={open ? 'navgroup-open' : ''}
>
<UnstyledButton
onClick={() => setOpen((o) => !o)}
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
{icon}
{!collapsed && (
<Group justify="space-between" style={{ width: '100%' }}>
<Text
sx={{
opacity: open ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: open ? 0 : 150,
}}
>
{label}
</Text>
<Box alignItems="center" style={{ display: 'flex' }}>
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Box>
</Group>
)}
</UnstyledButton>
{open && (
<Box style={{ paddingTop: 10 }}>
<Stack gap="xs" pl={open ? 0 : 'lg'}>
{paths.map((child) => {
const active = location.pathname === child.path;
return (
<Box
style={{ paddingLeft: collapsed ? 0 : 35 }}
key={child.path}
>
<NavLink
key={child.path}
item={child}
isActive={active}
collapsed={collapsed}
/>
</Box>
);
})}
</Stack>
</Box>
)}
</Box>
);
}
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const location = useLocation();
const channels = useChannelsStore((s) => s.channels);
const channelIds = useChannelsStore((s) => s.channelIds);
const environment = useSettingsStore((s) => s.environment);
const appVersion = useSettingsStore((s) => s.version);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@ -94,7 +166,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`,
},
{
label: 'VODs',
@ -111,19 +183,41 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{ label: 'Plugins', icon: <PlugZap size={20} />, path: '/plugins' },
{
label: 'Users',
icon: <User size={20} />,
path: '/users',
label: 'Integrations',
icon: <Blocks size={20} />,
paths: [
{
label: 'Connections',
icon: <Webhook size={20} />,
path: '/connect',
},
{
label: 'Logs',
icon: <Logs size={20} />,
path: '/connect/logs',
},
],
},
{
label: 'Logo Manager',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
label: 'System',
icon: <MonitorCog size={20} />,
paths: [
{
label: 'Users',
icon: <User size={20} />,
path: '/users',
},
{
label: 'Logo Manager',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
],
},
]
: [
@ -131,7 +225,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`,
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{
@ -151,11 +245,6 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
});
};
const onLogout = async () => {
await logout();
window.location.reload();
};
return (
<AppShell.Navbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
@ -205,20 +294,44 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
</Group>
{/* Navigation Links */}
<Stack gap="xs" mt="lg">
{navItems.map((item) => {
const isActive = location.pathname === item.path;
<ScrollArea h="100%" type="scroll" scrollbars="y">
<Stack
gap="xs"
mt="lg"
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
}}
>
{navItems.map((item) => {
if (item.paths) {
return (
<NavGroup
key={item.label}
label={item.label}
paths={item.paths}
location={location}
collapsed={collapsed}
icon={item.icon}
/>
);
}
return (
<NavLink
key={item.path}
item={item}
collapsed={collapsed}
isActive={isActive}
/>
);
})}
</Stack>
const isActive = location.pathname === item.path;
return (
<NavLink
key={item.path}
item={item}
collapsed={collapsed}
isActive={isActive}
/>
);
})}
</Stack>
</ScrollArea>
{/* Profile Section */}
<Box

View file

@ -33,6 +33,8 @@ import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from '../tables/CustomTable';
import { validateCronExpression } from '../../utils/cronUtils';
import ScheduleInput from '../forms/ScheduleInput';
const RowActions = ({
row,
@ -113,95 +115,6 @@ function getDefaultTimeZone() {
}
}
// Validate cron expression
function validateCronExpression(expression) {
if (!expression || expression.trim() === '') {
return { valid: false, error: 'Cron expression is required' };
}
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
return {
valid: false,
error:
'Cron expression must have exactly 5 parts: minute hour day month weekday',
};
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Validate each part (allowing *, */N steps, ranges, lists, steps)
// Supports: *, */2, 5, 1-5, 1-5/2, 1,3,5, etc.
const cronPartRegex =
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
if (!cronPartRegex.test(minute)) {
return {
valid: false,
error: 'Invalid minute field (0-59, *, or cron syntax)',
};
}
if (!cronPartRegex.test(hour)) {
return {
valid: false,
error: 'Invalid hour field (0-23, *, or cron syntax)',
};
}
if (!cronPartRegex.test(dayOfMonth)) {
return {
valid: false,
error: 'Invalid day field (1-31, *, or cron syntax)',
};
}
if (!cronPartRegex.test(month)) {
return {
valid: false,
error: 'Invalid month field (1-12, *, or cron syntax)',
};
}
if (!cronPartRegex.test(dayOfWeek)) {
return {
valid: false,
error: 'Invalid weekday field (0-6, *, or cron syntax)',
};
}
// Additional range validation for numeric values
const validateRange = (value, min, max, name) => {
// Skip if it's * or contains special characters
if (
value === '*' ||
value.includes('/') ||
value.includes('-') ||
value.includes(',')
) {
return null;
}
const num = parseInt(value, 10);
if (isNaN(num) || num < min || num > max) {
return `${name} must be between ${min} and ${max}`;
}
return null;
};
const minuteError = validateRange(minute, 0, 59, 'Minute');
if (minuteError) return { valid: false, error: minuteError };
const hourError = validateRange(hour, 0, 23, 'Hour');
if (hourError) return { valid: false, error: hourError };
const dayError = validateRange(dayOfMonth, 1, 31, 'Day');
if (dayError) return { valid: false, error: dayError };
const monthError = validateRange(month, 1, 12, 'Month');
if (monthError) return { valid: false, error: monthError };
const weekdayError = validateRange(dayOfWeek, 0, 6, 'Weekday');
if (weekdayError) return { valid: false, error: weekdayError };
return { valid: true, error: null };
}
const DAYS_OF_WEEK = [
{ value: '0', label: 'Sunday' },
{ value: '1', label: 'Monday' },
@ -262,8 +175,7 @@ export default function BackupManager() {
const [scheduleLoading, setScheduleLoading] = useState(false);
const [scheduleSaving, setScheduleSaving] = useState(false);
const [scheduleChanged, setScheduleChanged] = useState(false);
const [advancedMode, setAdvancedMode] = useState(false);
const [cronError, setCronError] = useState(null);
const [scheduleType, setScheduleType] = useState('interval');
// For 12-hour display mode
const [displayTime, setDisplayTime] = useState('3:00');
@ -373,12 +285,8 @@ export default function BackupManager() {
try {
const settings = await API.getBackupSchedule();
// Check if using cron expression (advanced mode)
if (settings.cron_expression) {
setAdvancedMode(true);
}
setSchedule(settings);
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
// Initialize 12-hour display values
const { time, period } = to12Hour(settings.time);
@ -398,25 +306,9 @@ export default function BackupManager() {
loadSchedule();
}, []);
// Validate cron expression when switching to advanced mode
useEffect(() => {
if (advancedMode && schedule.cron_expression) {
const validation = validateCronExpression(schedule.cron_expression);
setCronError(validation.valid ? null : validation.error);
} else {
setCronError(null);
}
}, [advancedMode, schedule.cron_expression]);
const handleScheduleChange = (field, value) => {
setSchedule((prev) => ({ ...prev, [field]: value }));
setScheduleChanged(true);
// Validate cron expression if in advanced mode
if (field === 'cron_expression' && advancedMode) {
const validation = validateCronExpression(value);
setCronError(validation.valid ? null : validation.error);
}
};
// Handle time changes in 12-hour mode
@ -442,9 +334,11 @@ export default function BackupManager() {
const handleSaveSchedule = async () => {
setScheduleSaving(true);
try {
const scheduleToSave = advancedMode
? schedule
: { ...schedule, cron_expression: '' };
// Clear cron_expression if not in cron mode
const scheduleToSave =
scheduleType === 'cron'
? schedule
: { ...schedule, cron_expression: '' };
const updated = await API.updateBackupSchedule(scheduleToSave);
setSchedule(updated);
@ -603,207 +497,161 @@ export default function BackupManager() {
/>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>
Advanced (Cron Expression)
</Text>
<Switch
checked={advancedMode}
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
label={advancedMode ? 'Enabled' : 'Disabled'}
disabled={!schedule.enabled}
size="sm"
/>
</Group>
<ScheduleInput
scheduleType={scheduleType}
onScheduleTypeChange={(type) => {
setScheduleType(type);
if (type !== 'cron') {
handleScheduleChange('cron_expression', '');
}
}}
cronValue={schedule.cron_expression}
onCronChange={(expr) => handleScheduleChange('cron_expression', expr)}
disabled={!schedule.enabled}
switchToCronLabel="Use custom cron schedule"
switchToIntervalLabel="Use simple schedule"
>
{/* Simple mode: frequency / time / day selectors */}
<Stack gap="sm">
<Group align="flex-end" gap="xs" wrap="nowrap">
<Select
label="Frequency"
value={schedule.frequency}
onChange={(value) => handleScheduleChange('frequency', value)}
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
]}
disabled={!schedule.enabled}
/>
{schedule.frequency === 'weekly' && (
<Select
label="Day"
value={String(schedule.day_of_week)}
onChange={(value) =>
handleScheduleChange('day_of_week', parseInt(value, 10))
}
data={DAYS_OF_WEEK}
disabled={!schedule.enabled}
/>
)}
{is12Hour ? (
<>
<Select
label="Hour"
value={displayTime ? displayTime.split(':')[0] : '12'}
onChange={(value) => {
const minute = displayTime
? displayTime.split(':')[1]
: '00';
handleTimeChange12h(`${value}:${minute}`, null);
}}
data={Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={displayTime ? displayTime.split(':')[1] : '00'}
onChange={(value) => {
const hour = displayTime
? displayTime.split(':')[0]
: '12';
handleTimeChange12h(`${hour}:${value}`, null);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Period"
value={timePeriod}
onChange={(value) => handleTimeChange12h(null, value)}
data={[
{ value: 'AM', label: 'AM' },
{ value: 'PM', label: 'PM' },
]}
disabled={!schedule.enabled}
/>
</>
) : (
<>
<Select
label="Hour"
value={schedule.time ? schedule.time.split(':')[0] : '00'}
onChange={(value) => {
const minute = schedule.time
? schedule.time.split(':')[1]
: '00';
handleTimeChange24h(`${value}:${minute}`);
}}
data={Array.from({ length: 24 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={schedule.time ? schedule.time.split(':')[1] : '00'}
onChange={(value) => {
const hour = schedule.time
? schedule.time.split(':')[0]
: '00';
handleTimeChange24h(`${hour}:${value}`);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
</>
)}
</Group>
</Stack>
</ScheduleInput>
{scheduleLoading ? (
<Loader size="sm" />
) : (
<>
{advancedMode ? (
<>
<Stack gap="sm">
<TextInput
label="Cron Expression"
value={schedule.cron_expression}
onChange={(e) =>
handleScheduleChange(
'cron_expression',
e.currentTarget.value
)
}
placeholder="0 3 * * *"
description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)"
disabled={!schedule.enabled}
error={cronError}
/>
<Text size="xs" c="dimmed">
Examples: <br /> <code>0 3 * * *</code> - Every day at 3:00
AM
<br /> <code>0 2 * * 0</code> - Every Sunday at 2:00 AM
<br /> <code>0 */6 * * *</code> - Every 6 hours
<br /> <code>30 14 1 * *</code> - 1st of every month at
2:30 PM
</Text>
</Stack>
<Group grow align="flex-end">
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) =>
handleScheduleChange('retention_count', value || 0)
}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged || (advancedMode && cronError)}
variant="default"
>
Save
</Button>
</Group>
</>
) : (
<Stack gap="sm">
<Group align="flex-end" gap="xs" wrap="nowrap">
<Select
label="Frequency"
value={schedule.frequency}
onChange={(value) =>
handleScheduleChange('frequency', value)
}
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
]}
disabled={!schedule.enabled}
/>
{schedule.frequency === 'weekly' && (
<Select
label="Day"
value={String(schedule.day_of_week)}
onChange={(value) =>
handleScheduleChange('day_of_week', parseInt(value, 10))
}
data={DAYS_OF_WEEK}
disabled={!schedule.enabled}
/>
)}
{is12Hour ? (
<>
<Select
label="Hour"
value={displayTime ? displayTime.split(':')[0] : '12'}
onChange={(value) => {
const minute = displayTime
? displayTime.split(':')[1]
: '00';
handleTimeChange12h(`${value}:${minute}`, null);
}}
data={Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={displayTime ? displayTime.split(':')[1] : '00'}
onChange={(value) => {
const hour = displayTime
? displayTime.split(':')[0]
: '12';
handleTimeChange12h(`${hour}:${value}`, null);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Period"
value={timePeriod}
onChange={(value) => handleTimeChange12h(null, value)}
data={[
{ value: 'AM', label: 'AM' },
{ value: 'PM', label: 'PM' },
]}
disabled={!schedule.enabled}
/>
</>
) : (
<>
<Select
label="Hour"
value={
schedule.time ? schedule.time.split(':')[0] : '00'
}
onChange={(value) => {
const minute = schedule.time
? schedule.time.split(':')[1]
: '00';
handleTimeChange24h(`${value}:${minute}`);
}}
data={Array.from({ length: 24 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={
schedule.time ? schedule.time.split(':')[1] : '00'
}
onChange={(value) => {
const hour = schedule.time
? schedule.time.split(':')[0]
: '00';
handleTimeChange24h(`${hour}:${value}`);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
</>
)}
</Group>
<Group grow align="flex-end" gap="xs">
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) =>
handleScheduleChange('retention_count', value || 0)
}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged}
variant="default"
>
Save
</Button>
</Group>
</Stack>
)}
<Group grow align="flex-end" gap="xs">
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) =>
handleScheduleChange('retention_count', value || 0)
}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={
!scheduleChanged ||
(scheduleType === 'cron' &&
schedule.cron_expression &&
!validateCronExpression(schedule.cron_expression).valid)
}
variant="default"
>
Save
</Button>
</Group>
{/* Timezone info - only show in simple mode */}
{!advancedMode && schedule.enabled && schedule.time && (
{scheduleType !== 'cron' && schedule.enabled && schedule.time && (
<Text size="xs" c="dimmed" mt="xs">
System Timezone: {userTimezone} Backup will run at{' '}
{schedule.time} {userTimezone}

View file

@ -14,9 +14,11 @@ import {
Switch,
Text,
UnstyledButton,
Badge,
} from '@mantine/core';
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
const PluginFieldList = ({ plugin, settings, updateField }) => {
return plugin.fields.map((f) => (
@ -35,30 +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>
)}
</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

@ -36,7 +36,12 @@ import {
runComSkip,
} from './../../utils/cards/RecordingCardUtils.js';
const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
const RecordingCard = ({
recording,
onOpenDetails,
onOpenRecurring,
channel: channelProp = null,
}) => {
const channels = useChannelsStore((s) => s.channels);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
@ -45,7 +50,7 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const channel = channels?.[recording.channel];
const channel = channelProp;
const customProps = recording.custom_properties || {};
const program = customProps.program || {};

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import useChannelsStore from '../../store/channels';
import useChannelsTableStore from '../../store/channelsTable.jsx';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import useEPGsStore from '../../store/epgs';
@ -245,38 +246,23 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
try {
const applyRegex = regexFind.trim().length > 0;
// First, handle standard field updates (name, group, logo, etc.)
if (applyRegex) {
// Build per-channel updates to apply unique names via regex
let flags = 'g';
let re;
try {
re = new RegExp(regexFind, flags);
} catch (e) {
console.error('Invalid regex:', e);
setIsSubmitting(false);
return;
}
const channelsMap = useChannelsStore.getState().channels;
const updates = channelIds.map((id) => {
const ch = channelsMap[id];
const currentName = ch?.name ?? '';
const newName = currentName.replace(re, regexReplace ?? '');
const update = { id };
if (newName !== currentName && newName.trim().length > 0) {
update.name = newName;
}
// Merge base values (group/profile/user_level) if present
Object.assign(update, values);
return update;
});
await API.bulkUpdateChannels(updates);
} else if (Object.keys(values).length > 0) {
// First, handle standard field updates (group, logo, profile, etc.)
if (Object.keys(values).length > 0) {
await API.updateChannels(channelIds, values);
}
// Then, handle name changes via server-side regex to avoid loading all channels client-side
if (applyRegex) {
// Default global replace; case-insensitive could be added later via UI if needed
const flags = 'g';
await API.bulkRegexRenameChannels(
channelIds,
regexFind,
regexReplace ?? '',
flags
);
}
// Then, handle EPG assignment if a dummy EPG was selected
if (selectedDummyEpgId) {
if (selectedDummyEpgId === 'clear') {
@ -318,7 +304,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
// Refresh both the channels table data and the main channels store
await Promise.all([
API.requeryChannels(),
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelIds(),
]);
onClose();
} catch (error) {
@ -1095,7 +1081,17 @@ export default ChannelBatchForm;
// Lightweight inline preview component to visualize rename results for a subset
const RegexPreview = ({ channelIds, find, replace }) => {
const channelsMap = useChannelsStore((s) => s.channels);
// Use only current page data from the channels table for preview
const pageChannels = useChannelsTableStore((s) => s.channels);
const nameById = useMemo(() => {
const map = {};
if (Array.isArray(pageChannels)) {
for (const ch of pageChannels) {
if (ch?.id != null) map[ch.id] = ch.name || '';
}
}
return map;
}, [pageChannels]);
const previewItems = useMemo(() => {
const items = [];
if (!find) return items;
@ -1107,24 +1103,25 @@ const RegexPreview = ({ channelIds, find, replace }) => {
console.error('Invalid regex:', error);
return [{ before: 'Invalid regex', after: '' }];
}
for (let i = 0; i < Math.min(channelIds.length, 25); i++) {
const id = channelIds[i];
const before = channelsMap[id]?.name ?? '';
// Limit preview to items that exist on the current page
const pageOnlyIds = channelIds.filter((id) => nameById[id] !== undefined);
for (let i = 0; i < Math.min(pageOnlyIds.length, 25); i++) {
const id = pageOnlyIds[i];
const before = nameById[id] ?? '';
const after = before.replace(re, replace ?? '');
if (before !== after) {
items.push({ before, after });
}
}
return items;
}, [channelIds, channelsMap, find, replace]);
}, [channelIds, nameById, find, replace]);
if (!find) return null;
return (
<Box mt={8}>
<Text size="xs" c="dimmed" mb={4}>
Preview (first {Math.min(channelIds.length, 25)} of {channelIds.length}{' '}
selected)
Preview shows matches from the current page only (up to 25).
</Text>
<ScrollArea h={120} offsetScrollbars>
<Stack gap={4}>

View file

@ -0,0 +1,382 @@
import React, { useEffect, useState } from 'react';
import API from '../../api';
import {
Button,
Modal,
Select,
Stack,
Flex,
TextInput,
Box,
Checkbox,
Text,
SimpleGrid,
Textarea,
Group,
Tabs,
Accordion,
Alert,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { SUBSCRIPTION_EVENTS } from '../../constants';
const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map(
([value, label]) => ({
value,
label,
})
);
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
const form = useForm({
mode: 'controlled',
initialValues: {
name: connection?.name || '',
type: connection?.type || 'webhook',
url: connection?.config?.url || '',
script_path: connection?.config?.path || '',
enabled: connection?.enabled ?? true,
},
validate: {
name: isNotEmpty('Provide a name'),
type: isNotEmpty('Select a type'),
url: (value, values) => {
if (values.type === 'webhook' && !value.trim()) {
return 'Provide a webhook URL';
}
return null;
},
script_path: (value, values) => {
if (values.type === 'script' && !value.trim()) {
return 'Provide a script path';
}
return null;
},
},
});
useEffect(() => {
if (connection) {
const values = {
name: connection.name,
type: connection.type,
url: connection.config?.url,
script_path: connection.config?.path,
enabled: connection.enabled,
};
form.setValues(values);
setSelectedEvents(
connection.subscriptions.reduce((acc, sub) => {
if (sub.enabled) acc.push(sub.event);
return acc;
}, [])
);
// 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]);
const handleClose = () => {
setApiError('');
onClose?.();
};
const onSubmit = async (values) => {
console.log(values);
try {
setSubmitting(true);
setApiError('');
// 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, {
name: values.name,
type: values.type,
config,
enabled: values.enabled,
});
} else {
connection = await API.createConnectIntegration({
name: values.name,
type: values.type,
config,
enabled: values.enabled,
});
}
// 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);
// Try to map server-side validation errors to form fields
const body = error?.body;
if (body && typeof body === 'object') {
const fieldErrors = {};
if (body.name) {
fieldErrors.name = body.name;
}
if (body.type) {
fieldErrors.type = body.type;
}
if (body.config) {
if (values.type === 'webhook') {
fieldErrors.url = msg;
} else {
fieldErrors.script_path = msg;
}
}
const nonField = body.non_field_errors || body.detail;
if (Object.keys(fieldErrors).length > 0) {
form.setErrors(fieldErrors);
}
if (nonField) setApiError(nonField);
if (!nonField && Object.keys(fieldErrors).length === 0) {
setApiError(body);
}
} else {
setApiError(error?.message || 'Unknown error');
}
} finally {
setSubmitting(false);
}
};
const toggleEvent = (event) => {
setSelectedEvents((prev) =>
prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]
);
};
if (!isOpen) return null;
return (
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
<form onSubmit={form.onSubmit(onSubmit)}>
<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>
<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>
</Tabs.Panel>
<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>
);
};
export default ConnectionForm;

View file

@ -0,0 +1,432 @@
/**
* Cron Expression Builder Modal
*
* Provides an easy interface to build cron expressions with:
* - Quick preset buttons for common schedules
* - Simple hour/minute/day selectors
* - Preview of next run times
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
Button,
Group,
Stack,
Select,
NumberInput,
Text,
Badge,
SimpleGrid,
Divider,
TextInput,
Paper,
Tabs,
Code,
} from '@mantine/core';
import { Clock, Calendar } from 'lucide-react';
const PRESETS = [
{
label: 'Every hour',
value: '0 * * * *',
description: 'At the start of every hour',
},
{
label: 'Every 6 hours',
value: '0 */6 * * *',
description: 'Every 6 hours starting at midnight',
},
{
label: 'Every 12 hours',
value: '0 */12 * * *',
description: 'Twice daily at midnight and noon',
},
{
label: 'Daily at midnight',
value: '0 0 * * *',
description: 'Once per day at 12:00 AM',
},
{
label: 'Daily at 3 AM',
value: '0 3 * * *',
description: 'Once per day at 3:00 AM',
},
{
label: 'Daily at noon',
value: '0 12 * * *',
description: 'Once per day at 12:00 PM',
},
{
label: 'Weekly (Sunday midnight)',
value: '0 0 * * 0',
description: 'Once per week on Sunday',
},
{
label: 'Weekly (Monday 3 AM)',
value: '0 3 * * 1',
description: 'Once per week on Monday',
},
{
label: 'Monthly (1st at 2:30 AM)',
value: '30 2 1 * *',
description: 'First day of each month',
},
];
const DAYS_OF_WEEK = [
{ value: '*', label: 'Every day' },
{ value: '0', label: 'Sunday' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
];
const FREQUENCY_OPTIONS = [
{ value: 'hourly', label: 'Hourly' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
];
export default function CronBuilder({
opened,
onClose,
onApply,
currentValue = '',
}) {
const [mode, setMode] = useState('simple'); // 'simple' or 'advanced'
const [frequency, setFrequency] = useState('daily');
const [hour, setHour] = useState(3);
const [minute, setMinute] = useState(0);
const [dayOfWeek, setDayOfWeek] = useState('*');
const [dayOfMonth, setDayOfMonth] = useState(1);
const [generatedCron, setGeneratedCron] = useState('0 3 * * *');
const [manualCron, setManualCron] = useState('* * * * *');
// Initialize manualCron from currentValue when modal opens
useEffect(() => {
if (opened && currentValue) {
setManualCron(currentValue);
}
}, [opened, currentValue]);
// Update generated cron when inputs change
useEffect(() => {
let cron = '';
switch (frequency) {
case 'hourly':
cron = `${minute} * * * *`;
break;
case 'daily':
cron = `${minute} ${hour} * * *`;
break;
case 'weekly':
cron = `${minute} ${hour} * * ${dayOfWeek === '*' ? '0' : dayOfWeek}`;
break;
case 'monthly':
cron = `${minute} ${hour} ${dayOfMonth} * *`;
break;
}
setGeneratedCron(cron);
}, [frequency, hour, minute, dayOfWeek, dayOfMonth]);
const handlePresetClick = (cron) => {
setGeneratedCron(cron);
setManualCron(cron);
// Parse the cron expression and update form fields
const parts = cron.split(' ');
if (parts.length === 5) {
const [min, hr, day, _month, weekday] = parts;
setMinute(parseInt(min) || 0);
// Determine frequency based on pattern
if (hr === '*') {
setFrequency('hourly');
} else if (day !== '*' && day !== '1') {
// Has specific day of month
setFrequency('monthly');
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
setDayOfMonth(parseInt(day) || 1);
} else if (weekday !== '*') {
// Has specific day of week
setFrequency('weekly');
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
setDayOfWeek(weekday);
} else if (day === '1') {
// Monthly on 1st
setFrequency('monthly');
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
setDayOfMonth(1);
} else {
// Daily
setFrequency('daily');
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
}
}
};
const handleApply = () => {
const cronToApply = mode === 'advanced' ? manualCron : generatedCron;
onApply(cronToApply);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title="Cron Expression Builder"
size="xl"
>
<Stack gap="md">
<Tabs value={mode} onChange={setMode}>
<Tabs.List grow>
<Tabs.Tab value="simple">Simple</Tabs.Tab>
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="simple" pt="md">
<Stack gap="md">
{/* Quick Presets */}
<div>
<Text size="sm" fw={500} mb="xs">
Quick Presets
</Text>
<SimpleGrid cols={3} spacing="xs">
{PRESETS.map((preset) => (
<Button
key={preset.value}
variant="light"
size="xs"
onClick={() => handlePresetClick(preset.value)}
style={{
height: '75px',
padding: '8px',
}}
styles={{
root: {
display: 'flex',
flexDirection: 'column',
},
inner: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
width: '100%',
height: '100%',
},
label: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
},
}}
>
<div
style={{
textAlign: 'left',
width: '100%',
flex: '1 1 auto',
}}
>
<Text size="xs" fw={500} mb={2}>
{preset.label}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{preset.description}
</Text>
</div>
<Badge
size="sm"
variant="dot"
color="gray"
style={{
flex: '0 0 auto',
}}
>
{preset.value}
</Badge>
</Button>
))}
</SimpleGrid>
</div>
<Divider label="OR Build Custom" labelPosition="center" />
{/* Custom Builder */}
<div>
<Text size="sm" fw={500} mb="xs">
Custom Schedule
</Text>
<SimpleGrid cols={2} spacing="sm">
<Select
label="Frequency"
data={FREQUENCY_OPTIONS}
value={frequency}
onChange={setFrequency}
leftSection={<Calendar size={16} />}
/>
{frequency !== 'hourly' && (
<NumberInput
label="Hour (0-23)"
value={hour}
onChange={setHour}
min={0}
max={23}
leftSection={<Clock size={16} />}
/>
)}
<NumberInput
label="Minute (0-59)"
value={minute}
onChange={setMinute}
min={0}
max={59}
leftSection={<Clock size={16} />}
/>
{frequency === 'weekly' && (
<Select
label="Day of Week"
data={DAYS_OF_WEEK}
value={dayOfWeek}
onChange={setDayOfWeek}
/>
)}
{frequency === 'monthly' && (
<NumberInput
label="Day of Month (1-31)"
value={dayOfMonth}
onChange={setDayOfMonth}
min={1}
max={31}
/>
)}
</SimpleGrid>
</div>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="advanced" pt="md">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Build advanced cron expressions with comma-separated values
(e.g., <Code>2,4,16</Code>), ranges (e.g., <Code>9-17</Code>),
or steps (e.g., <Code>*/15</Code>).
</Text>
<SimpleGrid cols={2} spacing="sm">
<TextInput
label="Minute (0-59)"
placeholder="*, 0, */15, 0,15,30,45"
value={manualCron.split(' ')[0] || '*'}
onChange={(e) => {
const parts =
manualCron.split(' ').length >= 5
? manualCron.split(' ')
: ['*', '*', '*', '*', '*'];
parts[0] = e.currentTarget.value || '*';
setManualCron(parts.join(' '));
}}
/>
<TextInput
label="Hour (0-23)"
placeholder="*, 0, 9-17, */6, 2,4,16"
value={manualCron.split(' ')[1] || '*'}
onChange={(e) => {
const parts =
manualCron.split(' ').length >= 5
? manualCron.split(' ')
: ['*', '*', '*', '*', '*'];
parts[1] = e.currentTarget.value || '*';
setManualCron(parts.join(' '));
}}
/>
<TextInput
label="Day of Month (1-31)"
placeholder="*, 1, 1-15, */2, 1,15"
value={manualCron.split(' ')[2] || '*'}
onChange={(e) => {
const parts =
manualCron.split(' ').length >= 5
? manualCron.split(' ')
: ['*', '*', '*', '*', '*'];
parts[2] = e.currentTarget.value || '*';
setManualCron(parts.join(' '));
}}
/>
<TextInput
label="Month (1-12)"
placeholder="*, 1, 1-6, */3, 6,12"
value={manualCron.split(' ')[3] || '*'}
onChange={(e) => {
const parts =
manualCron.split(' ').length >= 5
? manualCron.split(' ')
: ['*', '*', '*', '*', '*'];
parts[3] = e.currentTarget.value || '*';
setManualCron(parts.join(' '));
}}
/>
</SimpleGrid>
<TextInput
label="Day of Week (0-6, Sun-Sat)"
placeholder="*, 0, 1-5, 0,6"
value={manualCron.split(' ')[4] || '*'}
onChange={(e) => {
const parts =
manualCron.split(' ').length >= 5
? manualCron.split(' ')
: ['*', '*', '*', '*', '*'];
parts[4] = e.currentTarget.value || '*';
setManualCron(parts.join(' '));
}}
/>
<Text size="xs" c="dimmed">
Examples: <Code>0 4,10,16 * * *</Code> at 4 AM, 10 AM, and 4 PM
&bull; <Code>0 9-17 * * 1-5</Code> hourly 9 AM-5 PM Mon-Fri
&bull; <Code>*/15 * * * *</Code> every 15 minutes
</Text>
</Stack>
</Tabs.Panel>
</Tabs>
{/* Generated Expression */}
<Paper withBorder p="md" bg="dark.6">
<Group gap="xs">
<Text size="sm" fw={500}>
Expression:
</Text>
<Badge size="lg" variant="filled" color="blue">
{mode === 'advanced' ? manualCron : generatedCron}
</Badge>
</Group>
</Paper>
{/* Actions */}
<Group justify="flex-end" gap="sm">
<Button variant="subtle" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleApply}>Apply Expression</Button>
</Group>
</Stack>
</Modal>
);
}

View file

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

View file

@ -16,9 +16,11 @@ import {
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import ScheduleInput from './ScheduleInput';
const EPG = ({ epg = null, isOpen, onClose }) => {
const [sourceType, setSourceType] = useState('xmltv');
const [scheduleType, setScheduleType] = useState('interval');
const form = useForm({
mode: 'uncontrolled',
@ -29,6 +31,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
api_key: '',
is_active: true,
refresh_interval: 24,
cron_expression: '',
priority: 0,
},
@ -41,6 +44,17 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
const onSubmit = async () => {
const values = form.getValues();
// Determine which schedule type is active based on field values
const hasCronExpression =
values.cron_expression && values.cron_expression.trim() !== '';
// Clear the field that isn't active based on actual field values
if (hasCronExpression) {
values.refresh_interval = 0;
} else {
values.cron_expression = '';
}
if (epg?.id) {
// Validate that we have a valid EPG object before updating
if (!epg || typeof epg !== 'object' || !epg.id) {
@ -70,13 +84,21 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
api_key: epg.api_key,
is_active: epg.is_active,
refresh_interval: epg.refresh_interval,
cron_expression: epg.cron_expression || '',
priority: epg.priority ?? 0,
};
form.setValues(values);
setSourceType(epg.source_type);
// Determine schedule type from existing data - check both fields
setScheduleType(
epg.cron_expression && epg.cron_expression.trim() !== ''
? 'cron'
: 'interval'
);
} else {
form.reset();
setSourceType('xmltv');
setScheduleType('interval');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [epg]);
@ -92,127 +114,136 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
}
return (
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
{/* Left Column */}
<Stack gap="md" style={{ flex: 1 }}>
<TextInput
id="name"
name="name"
label="Name"
description="Unique identifier for this EPG source"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<NativeSelect
id="source_type"
name="source_type"
label="Source Type"
description="Format of the EPG data source"
{...form.getInputProps('source_type')}
key={form.key('source_type')}
data={[
{
label: 'XMLTV',
value: 'xmltv',
},
{
label: 'Schedules Direct',
value: 'schedules_direct',
},
]}
onChange={(event) =>
handleSourceTypeChange(event.currentTarget.value)
}
/>
<NumberInput
label="Refresh Interval (hours)"
description="How often to refresh EPG data (0 to disable)"
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
min={0}
/>
</Stack>
<Divider size="sm" orientation="vertical" />
{/* Right Column */}
<Stack gap="md" style={{ flex: 1 }}>
<TextInput
id="url"
name="url"
label="URL"
description="Direct URL to the XMLTV file or API endpoint"
{...form.getInputProps('url')}
key={form.key('url')}
/>
{sourceType === 'schedules_direct' && (
<>
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
{/* Left Column */}
<Stack gap="md" style={{ flex: 1 }}>
<TextInput
id="api_key"
name="api_key"
label="API Key"
description="API key for services that require authentication"
{...form.getInputProps('api_key')}
key={form.key('api_key')}
id="name"
name="name"
label="Name"
description="Unique identifier for this EPG source"
{...form.getInputProps('name')}
key={form.key('name')}
/>
)}
<NumberInput
min={0}
max={999}
label="Priority"
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
{...form.getInputProps('priority')}
key={form.key('priority')}
/>
<NativeSelect
id="source_type"
name="source_type"
label="Source Type"
description="Format of the EPG data source"
{...form.getInputProps('source_type')}
key={form.key('source_type')}
data={[
{
label: 'XMLTV',
value: 'xmltv',
},
{
label: 'Schedules Direct',
value: 'schedules_direct',
},
]}
onChange={(event) =>
handleSourceTypeChange(event.currentTarget.value)
}
/>
{/* Put checkbox at the same level as Refresh Interval */}
<Box style={{ marginTop: 0 }}>
<Text size="sm" fw={500} mb={3}>
Status
</Text>
<Text size="xs" c="dimmed" mb={12}>
When enabled, this EPG source will auto update.
</Text>
<Box
style={{
display: 'flex',
alignItems: 'center',
height: '30px',
marginTop: '-4px',
}}
>
<Checkbox
id="is_active"
name="is_active"
label="Enable this EPG source"
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
<ScheduleInput
scheduleType={scheduleType}
onScheduleTypeChange={setScheduleType}
intervalValue={form.getValues().refresh_interval}
onIntervalChange={(v) =>
form.setFieldValue('refresh_interval', v)
}
cronValue={form.getValues().cron_expression}
onCronChange={(expr) =>
form.setFieldValue('cron_expression', expr)
}
intervalLabel="Refresh Interval (hours)"
intervalDescription="How often to refresh EPG data (0 to disable)"
/>
</Stack>
<Divider size="sm" orientation="vertical" />
{/* Right Column */}
<Stack gap="md" style={{ flex: 1 }}>
<TextInput
id="url"
name="url"
label="URL"
description="Direct URL to the XMLTV file or API endpoint"
{...form.getInputProps('url')}
key={form.key('url')}
/>
{sourceType === 'schedules_direct' && (
<TextInput
id="api_key"
name="api_key"
label="API Key"
description="API key for services that require authentication"
{...form.getInputProps('api_key')}
key={form.key('api_key')}
/>
)}
<NumberInput
min={0}
max={999}
label="Priority"
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
{...form.getInputProps('priority')}
key={form.key('priority')}
/>
{/* Put checkbox at the same level as Refresh Interval */}
<Box style={{ marginTop: 0 }}>
<Text size="sm" fw={500} mb={3}>
Status
</Text>
<Text size="xs" c="dimmed" mb={12}>
When enabled, this EPG source will auto update.
</Text>
<Box
style={{
display: 'flex',
alignItems: 'center',
height: '30px',
marginTop: '-4px',
}}
>
<Checkbox
id="is_active"
name="is_active"
label="Enable this EPG source"
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
/>
</Box>
</Box>
</Box>
</Stack>
</Group>
{/* Full Width Section */}
<Box mt="md">
<Divider my="sm" />
<Group justify="end" mt="xl">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="filled" disabled={form.submitting}>
{epg?.id ? 'Update' : 'Create'} EPG Source
</Button>
</Stack>
</Group>
</Box>
</form>
</Modal>
{/* Full Width Section */}
<Box mt="md">
<Divider my="sm" />
<Group justify="end" mt="xl">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="filled" disabled={form.submitting}>
{epg?.id ? 'Update' : 'Create'} EPG Source
</Button>
</Group>
</Box>
</form>
</Modal>
</>
);
};

View file

@ -314,17 +314,119 @@ const LiveGroupFilter = ({
{group.auto_channel_sync && group.enabled && (
<>
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) =>
updateChannelStart(group.channel_group, value)
<Tooltip
label={
<div>
<div>
<strong>Fixed:</strong> Start at a specific number
and increment
</div>
<div>
<strong>Provider:</strong> Use channel numbers
from the M3U source
</div>
<div>
<strong>Next Available:</strong> Auto-assign
starting from 1, skipping used numbers
</div>
</div>
}
min={1}
step={1}
size="xs"
precision={1}
/>
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="Channel Numbering Mode"
placeholder="Select mode..."
value={
group.custom_properties?.channel_numbering_mode ||
'fixed'
}
onChange={(value) => {
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group === group.channel_group
) {
return {
...state,
custom_properties: {
...state.custom_properties,
channel_numbering_mode: value || 'fixed',
},
};
}
return state;
})
);
}}
data={[
{
value: 'fixed',
label: 'Fixed Start Number',
},
{
value: 'provider',
label: 'Use Provider Number',
},
{
value: 'next_available',
label: 'Next Available',
},
]}
size="xs"
/>
</Tooltip>
{(!group.custom_properties?.channel_numbering_mode ||
group.custom_properties?.channel_numbering_mode ===
'fixed') && (
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) =>
updateChannelStart(group.channel_group, value)
}
min={1}
step={1}
size="xs"
precision={0}
/>
)}
{group.custom_properties?.channel_numbering_mode ===
'provider' && (
<NumberInput
label="Fallback Channel # (if provider # missing)"
value={
group.custom_properties
?.channel_numbering_fallback || 1
}
onChange={(value) => {
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group === group.channel_group
) {
return {
...state,
custom_properties: {
...state.custom_properties,
channel_numbering_fallback: value || 1,
},
};
}
return state;
})
);
}}
min={1}
step={1}
size="xs"
precision={0}
/>
)}
{/* Auto Channel Sync Options Multi-Select */}
<MultiSelect

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,
@ -25,6 +26,7 @@ const LoginForm = () => {
const logout = useAuthStore((s) => s.logout);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const initData = useAuthStore((s) => s.initData);
const user = useAuthStore((s) => s.user);
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
const storedVersion = useSettingsStore((s) => s.version);
@ -86,10 +88,15 @@ const LoginForm = () => {
}, []);
useEffect(() => {
// If user is loaded, set isLoading to true so the UI indicates login is complete and is loading data
if (user) {
setIsLoading(true);
}
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
}, [isAuthenticated, user, navigate]);
const handleInputChange = (e) => {
setFormData({
@ -130,6 +137,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

@ -28,6 +28,7 @@ import { isNotEmpty, useForm } from '@mantine/form';
import useEPGsStore from '../../store/epgs';
import useVODStore from '../../store/useVODStore';
import M3UFilters from './M3UFilters';
import ScheduleInput from './ScheduleInput';
const M3U = ({
m3uAccount = null,
@ -49,6 +50,7 @@ const M3U = ({
const [filterModalOpen, setFilterModalOpen] = useState(false);
const [loadingText, setLoadingText] = useState('');
const [showCredentialFields, setShowCredentialFields] = useState(false);
const [scheduleType, setScheduleType] = useState('interval');
const form = useForm({
mode: 'uncontrolled',
@ -59,6 +61,7 @@ const M3U = ({
is_active: true,
max_streams: 0,
refresh_interval: 24,
cron_expression: '',
account_type: 'XC',
create_epg: false,
username: '',
@ -71,12 +74,10 @@ const M3U = ({
validate: {
name: isNotEmpty('Please select a name'),
user_agent: isNotEmpty('Please select a user-agent'),
refresh_interval: isNotEmpty('Please specify a refresh interval'),
},
});
useEffect(() => {
console.log(m3uAccount);
if (m3uAccount) {
setPlaylist(m3uAccount);
form.setValues({
@ -86,6 +87,7 @@ const M3U = ({
user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0',
is_active: m3uAccount.is_active,
refresh_interval: m3uAccount.refresh_interval,
cron_expression: m3uAccount.cron_expression || '',
account_type: m3uAccount.account_type,
username: m3uAccount.username ?? '',
password: '',
@ -101,6 +103,13 @@ const M3U = ({
enable_vod: m3uAccount.enable_vod || false,
});
// Determine schedule type from existing data
setScheduleType(
m3uAccount.cron_expression && m3uAccount.cron_expression.trim() !== ''
? 'cron'
: 'interval'
);
if (m3uAccount.account_type == 'XC') {
setShowCredentialFields(true);
} else {
@ -109,6 +118,7 @@ const M3U = ({
} else {
setPlaylist(null);
form.reset();
setScheduleType('interval');
}
}, [m3uAccount]);
@ -121,6 +131,17 @@ const M3U = ({
const onSubmit = async () => {
const { create_epg, ...values } = form.getValues();
// Determine which schedule type is active based on field values
const hasCronExpression =
values.cron_expression && values.cron_expression.trim() !== '';
// Clear the field that isn't active based on actual field values
if (hasCronExpression) {
values.refresh_interval = 0;
} else {
values.cron_expression = '';
}
if (values.account_type == 'XC' && values.password == '') {
// If account XC and no password input, assuming no password change
// from previously stored value.
@ -148,7 +169,7 @@ const M3U = ({
API.addEPG({
name: values.name,
source_type: 'xmltv',
url: `${values.server_url}/xmltv.php?username=${values.username}&password=${values.password}`,
url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`,
api_key: '',
is_active: true,
refresh_interval: 24,
@ -377,17 +398,25 @@ const M3U = ({
)}
/>
<NumberInput
label="Refresh Interval (hours)"
description={
<ScheduleInput
scheduleType={scheduleType}
onScheduleTypeChange={setScheduleType}
intervalValue={form.getValues().refresh_interval}
onIntervalChange={(v) =>
form.setFieldValue('refresh_interval', v)
}
cronValue={form.getValues().cron_expression}
onCronChange={(expr) =>
form.setFieldValue('cron_expression', expr)
}
intervalLabel="Refresh Interval (hours)"
intervalDescription={
<>
How often to automatically refresh M3U data
<br />
(0 to disable automatic refreshes)
</>
}
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
/>
<NumberInput

View file

@ -11,6 +11,7 @@ import {
MultiSelect,
Group,
TextInput,
Loader,
} from '@mantine/core';
import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates';
import { CircleAlert } from 'lucide-react';
@ -87,10 +88,13 @@ const RecordingModal = ({
isOpen,
onClose,
}) => {
const channels = useChannelsStore((s) => s.channels);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
// All channels loaded via lightweight summary API
const [allChannels, setAllChannels] = useState([]);
const [isChannelsLoading, setIsChannelsLoading] = useState(false);
const [mode, setMode] = useState('single');
const [submitting, setSubmitting] = useState(false);
@ -210,8 +214,31 @@ const RecordingModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, recording, channel]);
// Load all channels via lightweight summary API when modal opens
useEffect(() => {
let cancelled = false;
const run = async () => {
if (!isOpen) return;
try {
setIsChannelsLoading(true);
const chans = await API.getChannelsSummary();
if (cancelled) return;
setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
console.warn('Failed to load channels for recording form', e);
if (!cancelled) setAllChannels([]);
} finally {
if (!cancelled) setIsChannelsLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [isOpen]);
const channelOptions = useMemo(() => {
const list = Object.values(channels || {});
const list = Array.isArray(allChannels) ? [...allChannels] : [];
list.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
@ -220,9 +247,11 @@ const RecordingModal = ({
});
return list.map((item) => ({
value: `${item.id}`,
label: item.name || `Channel ${item.id}`,
label: item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`,
}));
}, [channels]);
}, [allChannels]);
const resetForms = () => {
singleForm.reset();
@ -341,6 +370,10 @@ const RecordingModal = ({
placeholder="Select channel"
searchable
data={channelOptions}
disabled={isChannelsLoading}
rightSection={
isChannelsLoading ? <Loader size="xs" color="blue" /> : null
}
/>
) : (
<Select
@ -350,6 +383,7 @@ const RecordingModal = ({
placeholder="Select channel"
searchable
data={channelOptions}
rightSection={isChannelsLoading ? 'Loading…' : null}
/>
)}

View file

@ -1,4 +1,5 @@
import useChannelsStore from '../../store/channels.jsx';
import API from '../../api';
import {
useDateTimeFormat,
useTimeHelpers,
@ -43,7 +44,8 @@ const RecordingDetailsModal = ({
onEdit,
}) => {
const allRecordings = useChannelsStore((s) => s.recordings);
const channelMap = useChannelsStore((s) => s.channels);
// Local channel cache to avoid the global channels map
const [channelsById, setChannelsById] = React.useState({});
const { toUserTime, userNow } = useTimeHelpers();
const [childOpen, setChildOpen] = React.useState(false);
const [childRec, setChildRec] = React.useState(null);
@ -91,6 +93,42 @@ const RecordingDetailsModal = ({
userNow,
]);
// Ensure channel is available for a given id
const loadChannel = React.useCallback(
async (id) => {
if (!id) {
return null;
}
const existing = channelsById[id];
if (existing) {
return existing;
}
try {
const ch = await API.getChannel(id);
if (ch && ch.id === id) {
setChannelsById((prev) => ({ ...prev, [id]: ch }));
return ch;
}
} catch (e) {
console.warn(
'Failed to fetch channel for RecordingDetailsModal',
id,
e
);
}
return null;
},
[channelsById]
);
// When opening a child episode, fetch that episode's channel
React.useEffect(() => {
if (!childOpen || !childRec) return;
loadChannel(childRec.channel);
}, [childOpen, childRec, loadChannel]);
const handleOnWatchLive = () => {
const rec = childRec;
const now = userNow();
@ -98,10 +136,11 @@ const RecordingDetailsModal = ({
const e = toUserTime(rec.end_time);
if (now.isAfter(s) && now.isBefore(e)) {
if (!channelMap[rec.channel]) return;
useVideoStore
.getState()
.showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
const ch =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
if (!ch) return;
useVideoStore.getState().showVideo(getShowVideoUrl(ch, env_mode), 'live');
}
};
@ -109,13 +148,16 @@ const RecordingDetailsModal = ({
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode);
if (!fileUrl) return;
const ch =
channelsById[childRec.channel] ||
(childRec.channel === recording?.channel ? channel : null);
useVideoStore.getState().showVideo(fileUrl, 'vod', {
name: childRec.custom_properties?.program?.title || 'Recording',
logo: {
url: getPosterUrl(
childRec.custom_properties?.poster_logo_id,
undefined,
channelMap[childRec.channel]?.logo?.cache_url
ch?.logo?.cache_url
),
},
});
@ -169,6 +211,7 @@ const RecordingDetailsModal = ({
setChildRec(rec);
setChildOpen(true);
};
return (
<Card
withBorder
@ -281,11 +324,11 @@ const RecordingDetailsModal = ({
opened={childOpen}
onClose={() => setChildOpen(false)}
recording={childRec}
channel={channelMap[childRec.channel]}
channel={channelsById[childRec.channel]}
posterUrl={getPosterUrl(
childRec.custom_properties?.poster_logo_id,
childRec.custom_properties,
channelMap[childRec.channel]?.logo?.cache_url
channelsById[childRec.channel]?.logo?.cache_url
)}
env_mode={env_mode}
onWatchLive={handleOnWatchLive}

View file

@ -0,0 +1,229 @@
/**
* Reusable Schedule Input
*
* Shows the active scheduling mode with a subtle text link to switch.
* Interval mode is the default; a small "Use cron schedule" link beneath
* toggles to cron mode, and vice-versa.
*
* For M3U / EPG (default interval NumberInput):
* <ScheduleInput
* scheduleType={scheduleType}
* onScheduleTypeChange={setScheduleType}
* intervalValue={form.getValues().refresh_interval}
* onIntervalChange={(v) => form.setFieldValue('refresh_interval', v)}
* cronValue={form.getValues().cron_expression}
* onCronChange={(v) => form.setFieldValue('cron_expression', v)}
* />
*
* For Backups (custom simple-mode UI via children):
* <ScheduleInput
* scheduleType={scheduleType}
* onScheduleTypeChange={setScheduleType}
* cronValue={schedule.cron_expression}
* onCronChange={(v) => handleScheduleChange('cron_expression', v)}
* switchToCronLabel="Use custom cron schedule"
* switchToIntervalLabel="Use simple schedule"
* >
* ...frequency / time / day selectors...
* </ScheduleInput>
*/
import React, { useState, useEffect } from 'react';
import {
TextInput,
NumberInput,
Anchor,
Stack,
Text,
Code,
Popover,
ActionIcon,
Group,
Divider,
SimpleGrid,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { validateCronExpression } from '../../utils/cronUtils';
import CronBuilder from './CronBuilder';
export default function ScheduleInput({
// Schedule type
scheduleType = 'interval',
onScheduleTypeChange,
// Cron
cronValue = '',
onCronChange,
// Default interval input (used when children not provided)
intervalValue = 0,
onIntervalChange,
intervalLabel = 'Refresh Interval (hours)',
intervalDescription = 'How often to refresh (0 to disable)',
min = 0,
// Custom simple-mode content (replaces the default NumberInput)
children,
// Link text for toggling
switchToCronLabel = 'Use cron schedule',
switchToIntervalLabel = 'Use interval schedule',
disabled = false,
}) {
const [cronError, setCronError] = useState(null);
const [builderOpened, setBuilderOpened] = useState(false);
// Validate cron whenever it changes
useEffect(() => {
if (scheduleType === 'cron' && cronValue) {
const v = validateCronExpression(cronValue);
setCronError(v.valid ? null : v.error);
} else {
setCronError(null);
}
}, [scheduleType, cronValue]);
const switchToCron = (e) => {
e.preventDefault();
onScheduleTypeChange('cron');
};
const switchToInterval = (e) => {
e.preventDefault();
onScheduleTypeChange('interval');
onCronChange('');
setCronError(null);
};
const handleCronChange = (val) => {
onCronChange(val);
if (val) {
const v = validateCronExpression(val);
setCronError(v.valid ? null : v.error);
} else {
setCronError(null);
}
};
const handleBuilderApply = (cron) => {
handleCronChange(cron);
};
return (
<Stack gap="xs">
{scheduleType === 'cron' ? (
<Stack gap="xs">
<TextInput
label={
<Group gap="xs">
Cron Expression
<Popover width={320} position="top" withArrow shadow="md">
<Popover.Target>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="sm">
<Text size="xs" fw={600} mb="xs" c="dimmed">
COMMON EXAMPLES
</Text>
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Text
size="xs"
c="dimmed"
style={{ minWidth: '140px' }}
>
Every day at 3 AM:
</Text>
<Code size="xs">0 3 * * *</Code>
</Group>
<Group gap="xs" wrap="nowrap">
<Text
size="xs"
c="dimmed"
style={{ minWidth: '140px' }}
>
At 4 AM, 10 AM, 4 PM:
</Text>
<Code size="xs">0 4,10,16 * * *</Code>
</Group>
<Group gap="xs" wrap="nowrap">
<Text
size="xs"
c="dimmed"
style={{ minWidth: '140px' }}
>
Sundays at 2 AM:
</Text>
<Code size="xs">0 2 * * 0</Code>
</Group>
<Group gap="xs" wrap="nowrap">
<Text
size="xs"
c="dimmed"
style={{ minWidth: '140px' }}
>
1st of month at 2:30 PM:
</Text>
<Code size="xs">30 14 1 * *</Code>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
placeholder="0 3 * * *"
description="minute hour day month weekday"
value={cronValue}
onChange={(e) => handleCronChange(e.currentTarget.value)}
error={cronError}
disabled={disabled}
/>
{!disabled && (
<Group gap="sm">
<Anchor size="xs" onClick={switchToInterval}>
{switchToIntervalLabel}
</Anchor>
<Anchor size="xs" onClick={() => setBuilderOpened(true)}>
Open Cron Builder
</Anchor>
</Group>
)}
<CronBuilder
opened={builderOpened}
onClose={() => setBuilderOpened(false)}
onApply={handleBuilderApply}
currentValue={cronValue}
/>
</Stack>
) : children ? (
<Stack gap="xs">
{children}
{!disabled && (
<Anchor size="xs" onClick={switchToCron}>
{switchToCronLabel}
</Anchor>
)}
</Stack>
) : (
<Stack gap="xs">
<NumberInput
label={intervalLabel}
description={intervalDescription}
value={intervalValue}
onChange={onIntervalChange}
min={min}
disabled={disabled}
suffix=" hours"
/>
{!disabled && (
<Anchor size="xs" onClick={switchToCron}>
{switchToCronLabel}
</Anchor>
)}
</Stack>
)}
</Stack>
);
}

View file

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

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

@ -9,6 +9,7 @@
background-color: transparent; /* Default background when not active */
border: 1px solid transparent;
transition: all 0.3s ease;
margin-left: 2px;
}
/* Active state styles */
@ -19,9 +20,9 @@
}
/* Hover effect */
.navlink:hover {
background-color: #2a2f34; /* Gray hover effect when not active */
border: 1px solid #3d3d42;
.navlink:hover, .navlink-parent-active {
background-color: #2A2F34; /* Gray hover effect when not active */
border: 1px solid #3D3D42;
}
/* Hover effect for active state */
@ -38,3 +39,20 @@
.navlink:not(.navlink-collapsed) {
justify-content: flex-start;
}
/* Left indicator for open nav groups rendered outside buttons */
.navgroup-open {
position: relative;
overflow: visible;
}
.navgroup-open::before {
content: '';
position: absolute;
left: 0; /* avoid horizontal overflow; sits at container edge */
top: 0;
bottom: 0;
width: 2px;
background: #3BA882;
pointer-events: none;
z-index: 2;
}

View file

@ -257,10 +257,6 @@ const ChannelsTable = ({ onReady }) => {
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
const canDeleteChannelGroup = useChannelsStore(
(s) => s.canDeleteChannelGroup
);
const hasSignaledReady = useRef(false);
/**
@ -379,28 +375,15 @@ const ChannelsTable = ({ onReady }) => {
/**
* Derived variables
*/
const activeGroupIds = new Set(
Object.values(channels).map((channel) => channel.channel_group_id)
);
const groupOptions = Object.values(channelGroups)
.filter((group) => activeGroupIds.has(group.id))
.map((group) => group.name);
.filter((group) => group.hasChannels)
.map((group) => group.name)
.sort((a, b) => a.localeCompare(b));
// Get unique EPG sources from active channels
const activeEPGSources = new Set();
let hasUnlinkedChannels = false;
Object.values(channels).forEach((channel) => {
if (channel.epg_data_id) {
const epgObj = tvgsById[channel.epg_data_id];
if (epgObj && epgObj.epg_source) {
const epgName = epgs[epgObj.epg_source]?.name || epgObj.epg_source;
activeEPGSources.add(epgName);
}
} else {
hasUnlinkedChannels = true;
}
});
const epgOptions = Array.from(activeEPGSources).sort();
const epgOptions = Object.values(epgs)
.map((epg) => epg.name)
.sort();
if (hasUnlinkedChannels) {
epgOptions.unshift('No EPG');
}
@ -594,6 +577,10 @@ const ChannelsTable = ({ onReady }) => {
const deleteChannel = async (id) => {
console.log(`Deleting channel with ID: ${id}`);
const rows = table.getRowModel().rows;
const knownChannel = rows.find((row) => row.original.id === id)?.original;
table.setSelectedTableIds([]);
if (selectedChannelIds.length > 0) {
@ -613,7 +600,7 @@ const ChannelsTable = ({ onReady }) => {
// Single channel delete
setIsBulkDelete(false);
setDeleteTarget(id);
setChannelToDelete(channels[id]); // Store the channel object for displaying details
setChannelToDelete(knownChannel); // Store the channel object for displaying details
if (isWarningSuppressed('delete-channel')) {
// Skip warning if suppressed

View file

@ -195,7 +195,6 @@ const ChannelTableHeader = ({
});
// Refresh the channel list
// await fetchChannels();
API.requeryChannels();
} catch (err) {
console.error(err);

View file

@ -354,3 +354,21 @@ export const CONTAINER_EXTENSIONS = [
'ts',
'mpg',
];
export const SUBSCRIPTION_EVENTS = {
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',
};

View file

@ -0,0 +1,203 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
Group,
Stack,
Switch,
Card,
Flex,
useMantineTheme,
Text,
Badge,
Tooltip,
} from '@mantine/core';
import API from '../api';
import useConnectStore from '../store/connect';
import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react';
import ConnectionForm from '../components/forms/Connection';
import { SUBSCRIPTION_EVENTS } from '../constants';
export default function ConnectPage() {
const { integrations, isLoading, fetchIntegrations } = useConnectStore();
const theme = useMantineTheme();
const [connection, setConnection] = useState(null);
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
useEffect(() => {
fetchIntegrations();
}, [fetchIntegrations]);
const newConnection = () => {
setConnection(null);
setIsConnectionModalOpen(true);
};
const editConnection = (connection) => {
setConnection(connection);
setIsConnectionModalOpen(true);
};
const deleteConnection = async (id) => {
console.log('Deleting connection', id);
await API.deleteConnectIntegration(id);
};
return (
<Box p="md">
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="sm"
onClick={() => newConnection()}
p={10}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
New Connection
</Button>
{isLoading && <div>Loading...</div>}
{!isLoading && (
<Box
style={{
gap: '1rem',
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
alignContent: 'start',
}}
display="grid"
py={10}
>
{integrations.map((i) => (
<IntegrationRow
key={i.id}
integration={i}
editConnection={editConnection}
deleteConnection={deleteConnection}
/>
))}
</Box>
)}
<ConnectionForm
connection={connection}
isOpen={isConnectionModalOpen}
onClose={() => setIsConnectionModalOpen(false)}
/>
</Box>
);
}
function IntegrationRow({ integration, editConnection, deleteConnection }) {
const type = integration.type || 'webhook';
const [enabled, setEnabled] = useState(!!integration.enabled);
const webhookUrl = integration?.config?.url || '';
const scriptPath = integration?.config?.path || '';
const toggleIntegration = async () => {
try {
await API.updateConnectIntegration(integration.id, {
...integration,
enabled: !enabled,
});
setEnabled(!enabled);
} catch (error) {
console.error('Failed to update integration', error);
} finally {
}
};
return (
<Card
key={integration.id}
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
backgroundColor: '#27272A',
}}
color="#fff"
w={'100%'}
>
<Stack gap="xs">
<Group justify="space-between">
<Group align="flex-start">
{integration.type == 'webhook' ? <Webhook /> : <FileCode />}
<Text fw={800}>{integration.name}</Text>
</Group>
<Switch
label="Enabled"
checked={enabled}
onChange={toggleIntegration}
/>
</Group>
{type === 'webhook' ? (
<Group gap={5} align="center">
<Text fw={500}>Target:</Text>
<Box style={{ flex: 1, minWidth: 0 }}>
<Tooltip label={webhookUrl} withArrow multiline>
<Text
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{webhookUrl}
</Text>
</Tooltip>
</Box>
</Group>
) : (
<Group gap={5} align="center">
<Text fw={500}>Target:</Text>
<Box style={{ flex: 1, minWidth: 0 }}>
<Tooltip label={scriptPath} withArrow multiline>
<Text
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{scriptPath}
</Text>
</Tooltip>
</Box>
</Group>
)}
<Text>Triggers</Text>
<Group>
{integration.subscriptions.map(
(sub) =>
sub.enabled && (
<Badge size="sm" variant="light" color="green">
{SUBSCRIPTION_EVENTS[sub.event] || sub.event}
</Badge>
)
)}
</Group>
</Stack>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button size="xs" onClick={() => editConnection(integration)}>
Edit
</Button>
<Button
variant="outline"
size="xs"
color="red"
onClick={() => deleteConnection(integration.id)}
>
Delete
</Button>
</Flex>
</Card>
);
}

View file

@ -0,0 +1,299 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import {
Box,
Title,
Badge,
Group,
Text,
Paper,
NativeSelect,
Pagination,
Select,
LoadingOverlay,
} from '@mantine/core';
import API from '../api';
import useConnectStore from '../store/connect';
import { FileCode, Webhook } from 'lucide-react';
import { SUBSCRIPTION_EVENTS } from '../constants';
import { CustomTable, useTable } from '../components/tables/CustomTable';
import { copyToClipboard } from '../utils';
export default function ConnectLogsPage() {
const { integrations, fetchIntegrations } = useConnectStore();
const [logs, setLogs] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [count, setCount] = useState(0);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
const [filters, setFilters] = useState({ type: '', integration: '' });
const pageCount = useMemo(
() => Math.max(1, Math.ceil(count / Math.max(1, pagination.pageSize))),
[count, pagination.pageSize]
);
const onPageSizeChange = useCallback((e) => {
const value = parseInt(e.target.value, 10);
setPagination((prev) => ({ ...prev, pageSize: value, pageIndex: 0 }));
}, []);
const onPageIndexChange = useCallback((page) => {
setPagination((prev) => ({ ...prev, pageIndex: page - 1 }));
}, []);
const fetchLogs = useCallback(async () => {
setIsLoading(true);
try {
const params = {
page: pagination.pageIndex + 1,
page_size: pagination.pageSize,
};
if (filters.type) params.type = filters.type;
if (filters.integration) params.integration = filters.integration;
const data = await API.getConnectLogs(params);
const results = Array.isArray(data) ? data : data?.results || [];
setLogs(results);
setCount(data?.count || results.length || 0);
} finally {
setIsLoading(false);
}
}, [pagination.pageIndex, pagination.pageSize, filters]);
useEffect(() => {
// Load integrations for filter options if not already available
if (!integrations || integrations.length === 0) {
fetchIntegrations?.();
}
}, []);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const columns = useMemo(
() => [
{
header: 'Time',
accessorKey: 'created_at',
size: 180,
cell: ({ getValue }) => (
<Text size="sm">{new Date(getValue()).toLocaleString()}</Text>
),
},
{
header: 'Integration',
accessorKey: 'subscription',
size: 200,
cell: ({ getValue }) => {
const subscription = getValue();
const integration = integrations.find(
(i) => i.id === subscription?.integration
);
const isWebhook = integration?.type === 'webhook';
return (
<Group gap={6}>
{isWebhook ? <Webhook size={16} /> : <FileCode size={16} />}
<Text size="sm">{integration?.name || '-'}</Text>
</Group>
);
},
},
{
header: 'Event',
accessorKey: 'subscription',
size: 160,
cell: ({ getValue }) => (
<Text size="sm">{SUBSCRIPTION_EVENTS[getValue()?.event] || '—'}</Text>
),
},
{
header: 'Response',
accessorKey: 'response_payload',
grow: true,
cell: ({ getValue }) => (
<Text
size="sm"
truncate
style={{ cursor: 'pointer' }}
onClick={() =>
copyToClipboard(getValue() ? JSON.stringify(getValue()) : '')
}
>
{getValue() ? JSON.stringify(getValue()) : '—'}
</Text>
),
},
{
header: 'Error',
accessorKey: 'error_message',
size: 150,
cell: ({ getValue }) => (
<Text
size="sm"
truncate
onClick={() => copyToClipboard(getValue() || '')}
style={{ cursor: 'pointer' }}
>
{getValue() || '—'}
</Text>
),
},
{
header: 'Status',
accessorKey: 'status',
size: 100,
cell: ({ getValue }) => (
<Badge
color={getValue() === 'success' ? 'green' : 'red'}
variant="light"
>
{getValue()}
</Badge>
),
},
],
[integrations]
);
const data = useMemo(() => logs, [logs]);
const allRowIds = useMemo(() => logs.map((l) => l.id), [logs]);
const renderHeaderCell = (header) => (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
const table = useTable({
columns,
data,
allRowIds,
enablePagination: false,
enableRowSelection: false,
enableRowVirtualization: false,
renderTopToolbar: false,
manualSorting: false,
manualFiltering: false,
manualPagination: true,
headerCellRenderFns: {
created_at: renderHeaderCell,
subscription: renderHeaderCell,
response_payload: renderHeaderCell,
error_message: renderHeaderCell,
status: renderHeaderCell,
},
});
const startIdx = pagination.pageIndex * pagination.pageSize + 1;
const endIdx = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
count
);
const paginationString = `Showing ${startIdx}-${endIdx} of ${count}`;
const integrationOptions = useMemo(
() => integrations.map((i) => ({ value: String(i.id), label: i.name })),
[integrations]
);
return (
<Box p="md">
<Title order={3} fw={'bold'}>
Connect Logs
</Title>
<Paper
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 65px)',
backgroundColor: '#27272A',
border: '1px solid #3f3f46',
borderRadius: 'var(--mantine-radius-md)',
}}
>
<Group gap={12} p={12} style={{ borderBottom: '1px solid #3f3f46' }}>
<Text size="sm">Type</Text>
<Select
size="xs"
data={[
{ value: '', label: 'All' },
{ value: 'webhook', label: 'Webhooks' },
{ value: 'script', label: 'Scripts' },
]}
value={filters.type}
onChange={(value) =>
setFilters((prev) => ({ ...prev, type: value }))
}
style={{ width: 150 }}
/>
<Text size="sm">Integration</Text>
<Select
size="xs"
searchable
data={[{ value: '', label: 'All' }, ...integrationOptions]}
value={filters.integration}
onChange={(value) =>
setFilters((prev) => ({ ...prev, integration: value }))
}
style={{ width: 250 }}
/>
</Group>
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 100px)',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<div style={{ minWidth: '900px', position: 'relative' }}>
<LoadingOverlay visible={isLoading} />
<CustomTable table={table} />
</div>
</Box>
<Box
style={{
position: 'sticky',
bottom: 0,
zIndex: 3,
backgroundColor: '#27272A',
}}
>
<Group
gap={5}
justify="center"
style={{ padding: 8, borderTop: '1px solid #666' }}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
</Box>
</Box>
</Paper>
</Box>
);
}

View file

@ -12,6 +12,7 @@ import {
} from '@mantine/core';
import { SquarePlus } from 'lucide-react';
import useChannelsStore from '../store/channels';
import API from '../api';
import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
@ -29,13 +30,19 @@ import {
} from '../utils/cards/RecordingCardUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
const RecordingList = ({
list,
onOpenDetails,
onOpenRecurring,
channelsById,
}) => {
return list.map((rec) => (
<RecordingCard
key={`rec-${rec.id}`}
recording={rec}
onOpenDetails={onOpenDetails}
onOpenRecurring={onOpenRecurring}
channel={channelsById?.[rec.channel]}
/>
));
};
@ -44,9 +51,8 @@ const DVRPage = () => {
const theme = useMantineTheme();
const recordings = useChannelsStore((s) => s.recordings);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const channels = useChannelsStore((s) => s.channels);
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
const [channelsById, setChannelsById] = useState({});
const { toUserTime, userNow } = useTimeHelpers();
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
@ -84,12 +90,28 @@ const DVRPage = () => {
const closeRuleModal = () => setRuleModal({ open: false, ruleId: null });
useEffect(() => {
if (!channels || Object.keys(channels).length === 0) {
fetchChannels();
}
fetchRecordings();
fetchRecurringRules();
}, [channels, fetchChannels, fetchRecordings, fetchRecurringRules]);
}, [fetchRecordings, fetchRecurringRules]);
// Load channel details for recordings via lightweight summary API
useEffect(() => {
let cancelled = false;
(async () => {
try {
const channels = await API.getChannelsSummary();
if (cancelled) return;
const byId = {};
for (const ch of channels) if (ch?.id) byId[ch.id] = ch;
setChannelsById(byId);
} catch (e) {
console.warn('Failed to fetch channels for DVR page', e);
}
})();
return () => {
cancelled = true;
};
}, []);
// Re-render every second so time-based bucketing updates without a refresh
const [now, setNow] = useState(userNow());
@ -114,7 +136,7 @@ const DVRPage = () => {
const e = toUserTime(rec.end_time);
if (isAfter(now, s) && isBefore(now, e)) {
// call into child RecordingCard behavior by constructing a URL like there
const channel = channels[rec.channel];
const channel = channelsById[rec.channel];
if (!channel) return;
const url = getShowVideoUrl(
channel,
@ -136,7 +158,7 @@ const DVRPage = () => {
url: getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
undefined,
channels[detailsRecording.channel]?.logo?.cache_url
channelsById[detailsRecording.channel]?.logo?.cache_url
),
},
});
@ -177,6 +199,7 @@ const DVRPage = () => {
list={inProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
channelsById={channelsById}
/>
}
{inProgress.length === 0 && (
@ -205,6 +228,7 @@ const DVRPage = () => {
list={upcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
channelsById={channelsById}
/>
}
{upcoming.length === 0 && (
@ -233,6 +257,7 @@ const DVRPage = () => {
list={completed}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
channelsById={channelsById}
/>
}
{completed.length === 0 && (
@ -273,11 +298,11 @@ const DVRPage = () => {
opened={detailsOpen}
onClose={closeDetails}
recording={detailsRecording}
channel={channels[detailsRecording.channel]}
channel={channelsById[detailsRecording.channel]}
posterUrl={getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
detailsRecording.custom_properties,
channels[detailsRecording.channel]?.logo?.cache_url
channelsById[detailsRecording.channel]?.logo?.cache_url
)}
env_mode={useSettingsStore.getState().environment.env_mode}
onWatchLive={handleOnWatchLive}

View file

@ -52,11 +52,9 @@ import {
fetchRules,
filterGuideChannels,
formatTime,
getGroupOptions,
getProfileOptions,
getRuleByProgram,
HOUR_WIDTH,
mapChannelsById,
mapProgramsByChannel,
mapRecordingsByProgramId,
matchChannelByTvgId,
@ -65,6 +63,7 @@ import {
PROGRAM_HEIGHT,
sortChannels,
} from './guideUtils';
import API from '../api';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
import {
add,
@ -87,7 +86,10 @@ import { showNotification } from '../utils/notificationUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
export default function TVChannelGuide({ startDate, endDate }) {
const channels = useChannelsStore((s) => s.channels);
const [isChannelsLoading, setIsChannelsLoading] = useState(false);
const [allowAllGroups, setAllowAllGroups] = useState(true);
const MAX_ALL_CHANNELS = 99999;
const recordings = useChannelsStore((s) => s.recordings);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
@ -123,38 +125,114 @@ export default function TVChannelGuide({ startDate, endDate }) {
const tvGuideRef = useRef(null); // Ref for the main tv-guide wrapper
const isSyncingScroll = useRef(false);
const guideScrollLeftRef = useRef(0);
const nowLineRef = useRef(null);
const [settledScrollLeft, setSettledScrollLeft] = useState(0);
const scrollDebounceRef = useRef(null);
const {
ref: guideContainerRef,
width: guideWidth,
height: guideHeight,
} = useElementSize();
const [guideScrollLeft, setGuideScrollLeft] = useState(0);
// Add new state to track hovered logo
const [hoveredChannelId, setHoveredChannelId] = useState(null);
// Load program data once
// Decide if 'All Channel Groups' should be enabled (based on total channel count)
useEffect(() => {
if (Object.keys(channels).length === 0) {
console.warn('No channels provided or empty channels array');
showNotification({ title: 'No channels available', color: 'red.5' });
setIsProgramsLoading(false);
return;
let cancelled = false;
(async () => {
try {
const params = new URLSearchParams();
const ids = await API.getAllChannelIds(params);
if (cancelled) {
return;
}
const total = Array.isArray(ids)
? ids.length
: (ids?.length ?? ids?.count ?? 0);
setAllowAllGroups(total <= MAX_ALL_CHANNELS);
} catch (e) {
// If we cannot determine, keep current default (true)
console.error('Failed to get total channel IDs', e);
}
})();
return () => {
cancelled = true;
};
}, []);
// If 'All' is not allowed, default to the first available group
useEffect(() => {
if (!allowAllGroups && selectedGroupId === 'all') {
const firstGroup = Object.values(channelGroups).find(
(g) => g?.hasChannels
);
if (firstGroup) {
setSelectedGroupId(String(firstGroup.id));
}
}
}, [allowAllGroups, channelGroups, selectedGroupId]);
const sortedChannels = sortChannels(channels);
setGuideChannels(sortedChannels);
// Fetch channels on demand based on filters
useEffect(() => {
let cancelled = false;
const fetchGuideChannels = async () => {
try {
setIsChannelsLoading(true);
const params = new URLSearchParams();
// Group filter by name, if not 'all'
if (selectedGroupId !== 'all') {
const group = channelGroups[Number(selectedGroupId)];
if (group?.name) params.set('channel_group', group.name);
} else if (!allowAllGroups) {
// If 'all' is not allowed, fall back to first available group
const firstGroup = Object.values(channelGroups).find(
(g) => g?.hasChannels
);
if (firstGroup?.name) params.set('channel_group', firstGroup.name);
}
fetchPrograms()
.then((data) => {
setPrograms(data);
// Profile filter
if (selectedProfileId && selectedProfileId !== 'all') {
params.set('channel_profile_id', String(selectedProfileId));
}
// Search filter
if (searchQuery && searchQuery.trim() !== '') {
params.set('search', searchQuery.trim());
}
// Use lightweight summary endpoint returns only the fields
// the Guide needs (id, name, logo_id, channel_number, uuid,
// epg_data_id, channel_group_id) without serializer/join overhead.
const channels = await API.getChannelsSummary(params);
if (cancelled) return;
const sorted = sortChannels(channels || []);
setGuideChannels(sorted);
// Load program data after channels are available
fetchPrograms()
.then((data) => {
setPrograms(data);
setIsProgramsLoading(false);
})
.catch((error) => {
console.error('Failed to fetch programs:', error);
setIsProgramsLoading(false);
});
} catch (e) {
if (cancelled) return;
setIsProgramsLoading(false);
})
.catch((error) => {
console.error('Failed to fetch programs:', error);
setIsProgramsLoading(false);
});
}, [channels]);
} finally {
if (!cancelled) setIsChannelsLoading(false);
}
};
fetchGuideChannels();
return () => {
cancelled = true;
};
}, [channelGroups, searchQuery, selectedGroupId, selectedProfileId]);
// Apply filters when search, group, or profile changes
const filteredChannels = useMemo(() => {
@ -195,15 +273,24 @@ export default function TVChannelGuide({ startDate, endDate }) {
const start = calculateStart(earliestProgramStart, defaultStart);
const end = calculateEnd(latestProgramEnd, defaultEnd);
// Pre-compute timeline origin in ms for horizontal culling in GuideRow
const timelineStartMs = useMemo(() => convertToMs(start), [start]);
const channelIdByTvgId = useMemo(
() => buildChannelIdMap(guideChannels, tvgsById, epgs),
[guideChannels, tvgsById, epgs]
);
const channelById = useMemo(
() => mapChannelsById(guideChannels),
[guideChannels]
);
// Local map of channel id -> channel object for quick lookup
const channelById = useMemo(() => {
const map = new Map();
for (const ch of guideChannels) {
if (ch && ch.id !== undefined && ch.id !== null) {
map.set(ch.id, ch);
}
}
return map;
}, [guideChannels]);
const programsByChannelId = useMemo(
() => mapProgramsByChannel(programs, channelIdByTvgId),
@ -263,14 +350,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
isSyncingScroll.current = true;
timelineRef.current.scrollLeft = scrollLeft;
guideScrollLeftRef.current = scrollLeft;
setGuideScrollLeft(scrollLeft);
updateNowLine();
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
} else if (scrollLeft !== guideScrollLeftRef.current) {
// Update ref even if timeline was already synced
guideScrollLeftRef.current = scrollLeft;
setGuideScrollLeft(scrollLeft);
updateNowLine();
}
};
@ -281,11 +368,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
};
}, []);
// Update "now" every second
// Update "now" every 60 seconds (on a 24h guide, per-second is imperceptible)
useEffect(() => {
const interval = setInterval(() => {
setNow(getNow());
}, 1000);
}, 60000);
return () => clearInterval(interval);
}, []);
@ -295,6 +382,25 @@ export default function TVChannelGuide({ startDate, endDate }) {
[now, start, end]
);
// Update the now-line DOM element directly (no React re-render)
const updateNowLine = useCallback(() => {
if (nowLineRef.current) {
nowLineRef.current.style.left = `${nowPosition + CHANNEL_WIDTH - guideScrollLeftRef.current}px`;
}
// Debounce horizontal culling update fires 150ms after scrolling stops
if (scrollDebounceRef.current) {
clearTimeout(scrollDebounceRef.current);
}
scrollDebounceRef.current = setTimeout(() => {
setSettledScrollLeft(guideScrollLeftRef.current);
}, 150);
}, [nowPosition]);
// Sync now-line whenever nowPosition changes (every 60s)
useEffect(() => {
updateNowLine();
}, [updateNowLine]);
useEffect(() => {
const tvGuide = tvGuideRef.current;
@ -333,7 +439,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Update the ref to keep state in sync
guideScrollLeftRef.current = newScrollLeft;
setGuideScrollLeft(newScrollLeft);
updateNowLine();
}
};
@ -363,7 +469,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
if (guide && timeline && guide.scrollLeft !== timeline.scrollLeft) {
timeline.scrollLeft = guide.scrollLeft;
guideScrollLeftRef.current = guide.scrollLeft;
setGuideScrollLeft(guide.scrollLeft);
updateNowLine();
}
lastCheck = timestamp;
}
@ -400,7 +506,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
if (currentScroll !== lastScrollLeft) {
timeline.scrollLeft = currentScroll;
guideScrollLeftRef.current = currentScroll;
setGuideScrollLeft(currentScroll);
updateNowLine();
lastScrollLeft = currentScroll;
stableFrames = 0;
return true; // Still scrolling
@ -496,7 +602,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
guideScrollLeftRef.current = nextLeft;
setGuideScrollLeft(nextLeft);
updateNowLine();
requestAnimationFrame(() => {
isSyncingScroll.current = false;
@ -658,7 +764,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
guideScrollLeftRef.current = nextLeft;
setGuideScrollLeft(nextLeft);
updateNowLine();
isSyncingScroll.current = true;
if (guideRef.current) {
@ -724,7 +830,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const programStartInView = leftPx + gapSize;
const programEndInView = leftPx + gapSize + widthPx;
const viewportLeft = guideScrollLeft;
const viewportLeft = guideScrollLeftRef.current;
const startsBeforeView = programStartInView < viewportLeft;
const extendsIntoView = programEndInView > viewportLeft;
@ -895,10 +1001,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
},
[
expandedProgramId,
guideScrollLeft,
handleProgramClick,
handleWatchStream,
now,
openRecordChoice,
recordingsByProgramId,
start,
@ -935,11 +1039,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
expandedProgramId,
rowHeights,
logos,
hoveredChannelId,
setHoveredChannelId,
renderProgram,
handleLogoClick,
contentWidth,
guideScrollLeftRef,
viewportWidth:
guideWidth ||
(typeof window !== 'undefined' ? window.innerWidth : 1200),
timelineStartMs,
settledScrollLeft, // triggers row re-renders after scrolling stops
}),
[
filteredChannels,
@ -947,11 +1055,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
expandedProgramId,
rowHeights,
logos,
hoveredChannelId,
renderProgram,
handleLogoClick,
contentWidth,
setHoveredChannelId,
guideWidth,
timelineStartMs,
settledScrollLeft,
]
);
@ -967,11 +1076,20 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
}, [searchQuery, selectedGroupId, selectedProfileId]);
// Create group options for dropdown - but only include groups used by guide channels
const groupOptions = useMemo(
() => getGroupOptions(channelGroups, guideChannels),
[channelGroups, guideChannels]
);
// Group options: show all groups; gate 'All' if too many channels
const groupOptions = useMemo(() => {
const opts = [];
if (allowAllGroups) {
opts.push({ value: 'all', label: 'All Channel Groups' });
}
const groupsArr = Object.values(channelGroups)
.filter((g) => g?.hasChannels)
.sort((a, b) => (a?.name || '').localeCompare(b?.name || ''));
groupsArr.forEach((g) => {
opts.push({ value: String(g.id), label: g.name });
});
return opts;
}, [channelGroups, allowAllGroups]);
// Create profile options for dropdown
const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]);
@ -1075,7 +1193,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
value={selectedGroupId}
onChange={handleGroupChange} // Use the new handler
w={'220px'}
clearable={true} // Allow clearing the selection
clearable={allowAllGroups} // Allow clearing the selection
/>
<Select
@ -1194,16 +1312,19 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
pos="relative"
>
<LoadingOverlay visible={isLoading || isProgramsLoading} />
<LoadingOverlay
visible={isLoading || isProgramsLoading || isChannelsLoading}
/>
{nowPosition >= 0 && (
<Box
ref={nowLineRef}
style={{
backgroundColor: '#38b2ac',
zIndex: 15,
pointerEvents: 'none',
left: `${nowPosition + CHANNEL_WIDTH - guideScrollLeftRef.current}px`,
}}
pos="absolute"
left={nowPosition + CHANNEL_WIDTH - guideScrollLeft}
top={0}
bottom={0}
w={'2px'}
@ -1222,7 +1343,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
itemData={listData}
ref={listRef}
outerRef={guideRef}
overscanCount={8}
overscanCount={3}
>
{GuideRow}
</VariableSizeList>

View file

@ -16,6 +16,7 @@ import {
Title,
} from '@mantine/core';
import useChannelsStore from '../store/channels';
import API from '../api';
import useLogosStore from '../store/logos';
import useStreamProfilesStore from '../store/streamProfiles';
import useLocalStorage from '../hooks/useLocalStorage';
@ -96,8 +97,6 @@ const Connections = ({
};
const StatsPage = () => {
const channels = useChannelsStore((s) => s.channels);
const channelsByUUID = useChannelsStore((s) => s.channelsByUUID);
const channelStats = useChannelsStore((s) => s.stats);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
@ -107,19 +106,55 @@ const StatsPage = () => {
const [channelHistory, setChannelHistory] = useState({});
const [isPollingActive, setIsPollingActive] = useState(false);
const [currentPrograms, setCurrentPrograms] = useState({});
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);
// 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 || {}).filter((id) => UUID_REGEX.test(id)),
[channelHistory]
);
// Update refs when values change
// Keep a ref so the programs poller always has the latest valid UUIDs
const neededUUIDsRef = useRef(neededUUIDs);
useEffect(() => {
channelHistoryRef.current = channelHistory;
}, [channelHistory]);
neededUUIDsRef.current = neededUUIDs;
}, [neededUUIDs]);
// Fetch any missing channels by UUID when the needed set changes (for card name/logo)
useEffect(() => {
channelsByUUIDRef.current = channelsByUUID;
}, [channelsByUUID]);
if (!neededUUIDs || neededUUIDs.length === 0) return;
const missing = neededUUIDs.filter((u) => channelsByUUID[u] === undefined);
if (missing.length === 0) return;
let cancelled = false;
(async () => {
try {
const res = await API.getChannelsByUUIDs(missing);
if (cancelled) return;
if (Array.isArray(res)) {
setChannels((prev) => {
const next = { ...prev };
for (const ch of res) next[ch.id] = ch;
return next;
});
setChannelsByUUID((prev) => {
const next = { ...prev };
for (const ch of res) next[ch.uuid] = ch.id;
return next;
});
}
} catch (e) {
console.error('Failed to fetch channels by UUIDs', e);
}
})();
return () => {
cancelled = true;
};
}, [neededUUIDs.join(',')]);
// Use localStorage for stats refresh interval (in seconds)
const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage(
@ -260,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
@ -300,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

@ -1,11 +1,18 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import {
render,
screen,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import DVRPage from '../DVR';
import dayjs from 'dayjs';
import useChannelsStore from '../../store/channels';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useLocalStorage from '../../hooks/useLocalStorage';
import API from '../../api';
import {
isAfter,
isBefore,
@ -22,6 +29,7 @@ vi.mock('../../store/channels');
vi.mock('../../store/settings');
vi.mock('../../store/useVideoStore');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../api');
// Mock Mantine components
vi.mock('@mantine/core', () => ({
@ -169,6 +177,9 @@ describe('DVRPage', () => {
const now = new Date('2024-01-15T12:00:00Z');
vi.setSystemTime(now);
// Default: API.getChannelsSummary returns empty array
API.getChannelsSummary.mockResolvedValue([]);
isAfter.mockImplementation((a, b) => new Date(a) > new Date(b));
isBefore.mockImplementation((a, b) => new Date(a) < new Date(b));
useTimeHelpers.mockReturnValue({
@ -223,21 +234,25 @@ describe('DVRPage', () => {
});
describe('Initial Render', () => {
it('renders new recording buttons', () => {
render(<DVRPage />);
it('renders new recording buttons', async () => {
await act(async () => {
render(<DVRPage />);
});
expect(screen.getByText('New Recording')).toBeInTheDocument();
});
it('renders empty state when no recordings', () => {
render(<DVRPage />);
it('renders empty state when no recordings', async () => {
await act(async () => {
render(<DVRPage />);
});
expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument();
});
});
describe('Recording Display', () => {
it('displays recordings grouped by date', () => {
it('displays recordings grouped by date', async () => {
const now = dayjs('2024-01-15T12:00:00Z');
const recordings = [
{
@ -261,7 +276,9 @@ describe('DVRPage', () => {
return selector ? selector(state) : state;
});
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
expect(screen.getByTestId('recording-card-1')).toBeInTheDocument();
expect(screen.getByTestId('recording-card-2')).toBeInTheDocument();
@ -270,24 +287,34 @@ describe('DVRPage', () => {
describe('New Recording', () => {
it('opens recording form when new recording button is clicked', async () => {
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const newButton = screen.getByText('New Recording');
fireEvent.click(newButton);
act(() => {
fireEvent.click(newButton);
});
expect(screen.getByTestId('recording-form')).toBeInTheDocument();
});
it('closes recording form when close is clicked', async () => {
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const newButton = screen.getByText('New Recording');
fireEvent.click(newButton);
act(() => {
fireEvent.click(newButton);
});
expect(screen.getByTestId('recording-form')).toBeInTheDocument();
const closeButton = screen.getByText('Close Form');
fireEvent.click(closeButton);
act(() => {
fireEvent.click(closeButton);
});
expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument();
});
@ -390,10 +417,13 @@ describe('DVRPage', () => {
return selector ? selector(state) : state;
});
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const recurringButton = screen.getByText('Open Recurring');
fireEvent.click(recurringButton);
act(() => {
fireEvent.click(screen.getByText('Open Recurring'));
});
expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
expect(screen.getByText('Rule ID: 100')).toBeInTheDocument();
@ -421,15 +451,19 @@ describe('DVRPage', () => {
return selector ? selector(state) : state;
});
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const recurringButton = screen.getByText('Open Recurring');
fireEvent.click(recurringButton);
act(() => {
fireEvent.click(screen.getByText('Open Recurring'));
});
expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
const closeButton = screen.getByText('Close Recurring');
fireEvent.click(closeButton);
act(() => {
fireEvent.click(screen.getByText('Close Recurring'));
});
expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument();
});
@ -448,13 +482,16 @@ describe('DVRPage', () => {
custom_properties: { Title: 'Live Show' },
};
// DVR.jsx loads all channel data via getChannelsSummary.
// Mock the API so channelsById gets populated before the handler runs.
API.getChannelsSummary.mockResolvedValue([
{ id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
]);
useChannelsStore.mockImplementation((selector) => {
const state = {
...defaultChannelsState,
recordings: [recording],
channels: {
1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
},
};
return selector ? selector(state) : state;
});
@ -466,6 +503,11 @@ describe('DVRPage', () => {
await screen.findByTestId('details-modal');
// Wait for channelsById to be populated from the async API call
await waitFor(() => {
expect(API.getChannelsSummary).toHaveBeenCalled();
});
const watchLiveButton = screen.getByText('Watch Live');
fireEvent.click(watchLiveButton);

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import API from '../../api';
import dayjs from 'dayjs';
import Guide from '../Guide';
import useChannelsStore from '../../store/channels';
@ -21,6 +22,7 @@ vi.mock('../../store/epgs');
vi.mock('../../store/settings');
vi.mock('../../store/useVideoStore');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../api');
vi.mock('@mantine/hooks', () => ({
useElementSize: () => ({
@ -261,7 +263,13 @@ describe('Guide', () => {
},
recordings: [],
channelGroups: {
'group-1': { id: 'group-1', name: 'News', channels: ['channel-1'] },
// hasChannels is required: Guide.jsx filters groups by this property
'group-1': {
id: 'group-1',
name: 'News',
channels: ['channel-1'],
hasChannels: true,
},
},
profiles: {
'profile-1': { id: 'profile-1', name: 'HD Profile' },
@ -346,6 +354,14 @@ describe('Guide', () => {
]);
recordingCardUtils.getShowVideoUrl.mockReturnValue('http://video.test');
// Guide.jsx now fetches channels from the API rather than reading them
// from the channel store. Mock both calls so guideChannels gets populated.
const mockChannelsArray = Object.values(mockChannelsState.channels);
API.getAllChannelIds.mockResolvedValue(
Object.keys(mockChannelsState.channels)
);
API.getChannelsSummary.mockResolvedValue(mockChannelsArray);
});
afterEach(() => {
@ -367,9 +383,14 @@ describe('Guide', () => {
});
it('renders channel rows when channels are available', async () => {
vi.useRealTimers();
render(<Guide />);
expect(screen.getAllByTestId('guide-row')).toHaveLength(2);
// Channels are now fetched asynchronously from the API
await waitFor(() => {
expect(screen.getAllByTestId('guide-row')).toHaveLength(2);
});
});
it('shows no channels message when filters exclude all channels', async () => {
@ -385,11 +406,14 @@ describe('Guide', () => {
});
it('displays channel count', async () => {
vi.useRealTimers();
render(<Guide />);
// await waitFor(() => {
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
// });
// Channels are now fetched asynchronously from the API
await waitFor(() => {
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
});
});
});
@ -416,7 +440,9 @@ describe('Guide', () => {
await user.type(searchInput, 'Test');
expect(searchInput).toHaveValue('Test');
await user.click(screen.getByText('Clear Filters'));
// Use getAllByText to safely handle the brief window where both the
// filter-bar and the empty-state buttons are in the DOM simultaneously.
await user.click(screen.getAllByText('Clear Filters')[0]);
expect(searchInput).toHaveValue('');
});
@ -495,8 +521,10 @@ describe('Guide', () => {
await user.type(searchInput, 'Test');
// Clear them
const clearButton = await screen.findByText('Clear Filters');
await user.click(clearButton);
// Use findAllByText + [0] to target the filter-bar button specifically
// in case the empty-state also shows a Clear Filters button.
const clearButtons = await screen.findAllByText('Clear Filters');
await user.click(clearButtons[0]);
expect(searchInput).toHaveValue('');
});
@ -572,22 +600,19 @@ describe('Guide', () => {
});
describe('Error Handling', () => {
it('shows notification when no channels are available', async () => {
useChannelsStore.mockImplementation((selector) => {
const state = {
channels: {},
recordings: [],
channelGroups: {},
profiles: {},
};
return selector ? selector(state) : state;
});
it('shows empty state when the API returns no channels', async () => {
vi.useRealTimers();
// Guide.jsx no longer emits a notification for an empty channel list;
// instead it renders an empty-state message directly in the UI.
API.getChannelsSummary.mockResolvedValue([]);
render(<Guide />);
expect(showNotification).toHaveBeenCalledWith({
title: 'No channels available',
color: 'red.5',
await waitFor(() => {
expect(
screen.getByText('No channels match your filters')
).toBeInTheDocument();
});
});
});

View file

@ -419,11 +419,15 @@ describe('StatsPage', () => {
render(<StatsPage />);
await waitFor(() => {
// Stats page now lazily loads channelsByUUID and channels via API
// (keyed by UUIDID and IDchannel respectively) rather than reading
// them directly from the channel store. Both start as empty objects
// and are populated on demand; the first call therefore sees {}.
expect(getStatsByChannelId).toHaveBeenCalledWith(
mockChannelStats,
expect.any(Object),
mockChannelsByUUID,
mockChannels,
expect.any(Object), // prevChannelHistory
{}, // channelsByUUID (local state, starts empty)
{}, // channels (local state, starts empty)
mockStreamProfiles
);
});

View file

@ -371,10 +371,12 @@ describe('useAuthStore', () => {
// Mock getState for each store
useSettingsStore.getState = () => ({ fetchSettings });
const fetchChannelIds = vi.fn().mockResolvedValue();
useChannelsStore.getState = () => ({
fetchChannels,
fetchChannelGroups,
fetchChannelProfiles,
fetchChannelIds,
});
usePlaylistsStore.getState = () => ({ fetchPlaylists });
useEPGsStore.getState = () => ({ fetchEPGs, fetchEPGData });
@ -401,7 +403,7 @@ describe('useAuthStore', () => {
expect(result.current.user).toEqual(mockUser);
expect(result.current.isAuthenticated).toBe(true);
expect(fetchSettings).toHaveBeenCalled();
expect(fetchChannels).toHaveBeenCalled();
expect(fetchChannelIds).toHaveBeenCalled();
expect(fetchUsers).toHaveBeenCalled();
});
@ -458,6 +460,7 @@ describe('useAuthStore', () => {
fetchChannels,
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
fetchChannelIds: vi.fn().mockResolvedValue(),
}));
const { result } = renderHook(() => useAuthStore());
@ -645,10 +648,12 @@ describe('useAuthStore', () => {
};
const fetchChannels = vi.fn().mockResolvedValue();
const fetchChannelIdsSpy = vi.fn().mockResolvedValue();
useChannelsStore.getState = () => ({
fetchChannels,
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
fetchChannelIds: fetchChannelIdsSpy,
});
API.me.mockResolvedValue(mockUser);
@ -666,7 +671,7 @@ describe('useAuthStore', () => {
// The background fetchChannels is called synchronously without await
// so we just need to verify it was called
expect(fetchChannels).toHaveBeenCalled();
expect(fetchChannelIdsSpy).toHaveBeenCalled();
});
});
});

View file

@ -30,41 +30,31 @@ describe('useChannelsStore', () => {
});
});
describe('fetchChannels', () => {
describe('fetchChannelIds', () => {
it('should fetch and store channels successfully', async () => {
const mockChannels = [
{ id: 1, uuid: 'uuid-1', name: 'Channel 1' },
{ id: 2, uuid: 'uuid-2', name: 'Channel 2' },
];
api.getChannels.mockResolvedValue(mockChannels);
const mockChannelIds = [1, 2];
api.getAllChannelIds.mockResolvedValue(mockChannelIds);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchChannels();
await result.current.fetchChannelIds();
});
expect(api.getChannels).toHaveBeenCalledOnce();
expect(result.current.channels).toEqual({
1: mockChannels[0],
2: mockChannels[1],
});
expect(result.current.channelsByUUID).toEqual({
'uuid-1': 1,
'uuid-2': 2,
});
expect(api.getAllChannelIds).toHaveBeenCalledOnce();
expect(result.current.channelIds).toEqual(mockChannelIds);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('should handle fetch error', async () => {
const errorMessage = 'Network error';
api.getChannels.mockRejectedValue(new Error(errorMessage));
api.getAllChannelIds.mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchChannels();
await result.current.fetchChannelIds();
});
expect(result.current.error).toBe(errorMessage);

View file

@ -56,8 +56,6 @@ const useAuthStore = create((set, get) => ({
await useSettingsStore.getState().fetchSettings();
// Fetch essential data needed for initial render
// Note: fetchChannels() is intentionally NOT awaited here - it's slow (~3s)
// and only needed for delete modal details. It loads in background after UI renders.
await Promise.all([
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchChannelProfiles(),
@ -66,6 +64,7 @@ const useAuthStore = create((set, get) => ({
useEPGsStore.getState().fetchEPGData(),
useStreamProfilesStore.getState().fetchProfiles(),
useUserAgentsStore.getState().fetchUserAgents(),
useChannelsStore.getState().fetchChannelIds(),
]);
if (user.user_level >= USER_LEVELS.ADMIN) {
@ -80,9 +79,6 @@ const useAuthStore = create((set, get) => ({
isInitializing: false,
});
// Load channels data in background (not blocking) - needed for delete modal details
useChannelsStore.getState().fetchChannels();
// Note: Logos are loaded after the Channels page tables finish loading
// This is handled by the tables themselves signaling completion
} catch (error) {

View file

@ -104,6 +104,7 @@ const showNotificationIfClientStopped = (
const useChannelsStore = create((set, get) => ({
channels: [],
channelIds: [],
channelsByUUID: {},
channelGroups: {},
profiles: {},
@ -122,6 +123,19 @@ const useChannelsStore = create((set, get) => ({
set({ forceUpdate: new Date() });
},
fetchChannelIds: async () => {
set({ isLoading: true, error: null });
try {
const channelIds = await api.getAllChannelIds();
set({
channelIds,
isLoading: false,
});
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
fetchChannels: async () => {
set({ isLoading: true, error: null });
try {
@ -263,8 +277,10 @@ const useChannelsStore = create((set, get) => ({
set((state) => {
const updatedChannels = { ...state.channels };
const channelsByUUID = { ...state.channelsByUUID };
const channelIdsSet = new Set(state.channelIds); // Convert to Set for O(1) lookups
for (const id of channelIds) {
delete updatedChannels[id];
channelIdsSet.delete(id);
for (const uuid in channelsByUUID) {
if (channelsByUUID[uuid] == id) {
@ -274,7 +290,12 @@ const useChannelsStore = create((set, get) => ({
}
}
return { channels: updatedChannels, channelsByUUID };
console.log(channelIdsSet);
return {
channels: updatedChannels,
channelsByUUID,
channelIds: Array.from(channelIdsSet),
};
});
},

View file

@ -0,0 +1,46 @@
import { create } from 'zustand';
import API from '../api';
const useConnectStore = create((set, get) => ({
integrations: [],
isLoading: false,
error: null,
fetchIntegrations: async () => {
set({ isLoading: true, error: null });
try {
const list = await API.getConnectIntegrations();
console.log(list);
set({
integrations: Array.isArray(list) ? list : list?.results || [],
isLoading: false,
});
} catch (error) {
set({ error, isLoading: false });
}
},
addIntegration: (integration) =>
set((state) => ({ integrations: [...state.integrations, integration] })),
updateIntegration: (integration) =>
set((state) => ({
integrations: state.integrations.map((i) =>
i.id === integration.id ? integration : i
),
})),
removeIntegration: (id) =>
set((state) => ({
integrations: state.integrations.filter((i) => i.id !== id),
})),
updateIntegrationSubscriptions: (id, events) =>
set((state) => ({
integrations: state.integrations.map((i) =>
i.id === id ? { ...i, subscriptions: events } : i
),
})),
}));
export default useConnectStore;

View file

@ -0,0 +1,63 @@
/**
* Cron expression validation utility.
*
* Shared across CronModal, BackupManager, and any other component
* that needs to validate 5-part cron expressions.
*/
export function validateCronExpression(expression) {
if (!expression || expression.trim() === '') {
return { valid: false, error: 'Cron expression is required' };
}
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
return {
valid: false,
error:
'Cron expression must have exactly 5 parts: minute hour day month weekday',
};
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
const cronPartRegex =
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
const fields = [
{ value: minute, label: 'minute', min: 0, max: 59 },
{ value: hour, label: 'hour', min: 0, max: 23 },
{ value: dayOfMonth, label: 'day', min: 1, max: 31 },
{ value: month, label: 'month', min: 1, max: 12 },
{ value: dayOfWeek, label: 'weekday', min: 0, max: 6 },
];
for (const { value, label, min, max } of fields) {
if (!cronPartRegex.test(value)) {
return {
valid: false,
error: `Invalid ${label} field (${min}-${max}, *, or cron syntax)`,
};
}
// Extra numeric-range check for plain numbers
if (
!(
value === '*' ||
value.includes('/') ||
value.includes('-') ||
value.includes(',')
)
) {
const num = parseInt(value, 10);
if (isNaN(num) || num < min || num > max) {
return {
valid: false,
error: `${label.charAt(0).toUpperCase() + label.slice(1)} must be between ${min} and ${max}`,
};
}
}
}
return { valid: true, error: null };
}

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