mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
commit
393bfd384c
100 changed files with 7315 additions and 2148 deletions
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
85
CHANGELOG.md
85
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
49
apps/accounts/authentication.py
Normal file
49
apps/accounts/authentication.py
Normal 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
|
||||
18
apps/accounts/migrations/0004_user_api_key.py
Normal file
18
apps/accounts/migrations/0004_user_api_key.py
Normal 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),
|
||||
),
|
||||
]
|
||||
20
apps/accounts/migrations/0005_alter_user_managers.py
Normal file
20
apps/accounts/migrations/0005_alter_user_managers.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
72
apps/accounts/tests.py
Normal 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())
|
||||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
0
apps/connect/__init__.py
Normal file
17
apps/connect/api_urls.py
Normal file
17
apps/connect/api_urls.py
Normal 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
198
apps/connect/api_views.py
Normal 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
8
apps/connect/apps.py
Normal 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'
|
||||
0
apps/connect/handlers/__init__.py
Normal file
0
apps/connect/handlers/__init__.py
Normal file
0
apps/connect/handlers/api.py
Normal file
0
apps/connect/handlers/api.py
Normal file
12
apps/connect/handlers/base.py
Normal file
12
apps/connect/handlers/base.py
Normal 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
|
||||
81
apps/connect/handlers/script.py
Normal file
81
apps/connect/handlers/script.py
Normal 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,
|
||||
}
|
||||
21
apps/connect/handlers/webhook.py
Normal file
21
apps/connect/handlers/webhook.py
Normal 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}
|
||||
52
apps/connect/migrations/0001_initial.py
Normal file
52
apps/connect/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
0
apps/connect/migrations/__init__.py
Normal file
0
apps/connect/migrations/__init__.py
Normal file
47
apps/connect/models.py
Normal file
47
apps/connect/models.py
Normal 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)
|
||||
68
apps/connect/serializers.py
Normal file
68
apps/connect/serializers.py
Normal 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
116
apps/connect/utils.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
203
core/scheduling.py
Normal 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()
|
||||
150
core/utils.py
150
core/utils.py
|
|
@ -222,6 +222,75 @@ def release_task_lock(task_name, id):
|
|||
# Remove the lock
|
||||
redis_client.delete(lock_id)
|
||||
|
||||
|
||||
class TaskLockRenewer:
|
||||
"""Periodically renews a Redis task lock to prevent expiry during long-running tasks.
|
||||
|
||||
Use as a context manager after acquiring a lock:
|
||||
|
||||
if acquire_task_lock("my_task", task_id):
|
||||
with TaskLockRenewer("my_task", task_id):
|
||||
# ... long-running work ...
|
||||
release_task_lock("my_task", task_id)
|
||||
|
||||
A daemon thread extends the lock TTL at regular intervals so that
|
||||
slow downloads or large parsing jobs don't lose their lock mid-operation.
|
||||
"""
|
||||
|
||||
def __init__(self, task_name, id, ttl=300, renewal_interval=120):
|
||||
self.task_name = task_name
|
||||
self.id = id
|
||||
self.ttl = ttl
|
||||
self.renewal_interval = renewal_interval
|
||||
self.lock_id = f"task_lock_{task_name}_{id}"
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
def _renew_loop(self):
|
||||
"""Background loop that extends the lock TTL until stopped."""
|
||||
while not self._stop_event.wait(self.renewal_interval):
|
||||
try:
|
||||
redis_client = RedisClient.get_client()
|
||||
if redis_client.exists(self.lock_id):
|
||||
redis_client.expire(self.lock_id, self.ttl)
|
||||
logger.debug(
|
||||
f"Renewed lock {self.lock_id} TTL to {self.ttl}s"
|
||||
)
|
||||
else:
|
||||
# Lock was deleted externally (e.g. manual release) — stop renewing
|
||||
logger.warning(
|
||||
f"Lock {self.lock_id} no longer exists, stopping renewal"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error renewing lock {self.lock_id}: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the background renewal thread."""
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._renew_loop, daemon=True,
|
||||
name=f"lock-renew-{self.task_name}-{self.id}"
|
||||
)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
"""Stop the renewal thread."""
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
|
||||
def send_websocket_update(group_name, event_type, data, collect_garbage=False):
|
||||
"""
|
||||
Standardized function to send WebSocket updates with proper memory management.
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1284
frontend/package-lock.json
generated
1284
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -70,6 +70,7 @@
|
|||
"react-dom": "19.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"js-yaml": "^4.1.1"
|
||||
"js-yaml": "^4.1.1",
|
||||
"minimatch": "^10.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
382
frontend/src/components/forms/Connection.jsx
Normal file
382
frontend/src/components/forms/Connection.jsx
Normal 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;
|
||||
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
432
frontend/src/components/forms/CronBuilder.jsx
Normal 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
|
||||
• <Code>0 9-17 * * 1-5</Code> hourly 9 AM-5 PM Mon-Fri
|
||||
• <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
(?<groupname>pattern).
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Define regex patterns to extract information from channel titles or
|
||||
stream names. Use named capture groups like
|
||||
(?<groupname>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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
229
frontend/src/components/forms/ScheduleInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -195,7 +195,6 @@ const ChannelTableHeader = ({
|
|||
});
|
||||
|
||||
// Refresh the channel list
|
||||
// await fetchChannels();
|
||||
API.requeryChannels();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
203
frontend/src/pages/Connect.jsx
Normal file
203
frontend/src/pages/Connect.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
frontend/src/pages/ConnectLogs.jsx
Normal file
299
frontend/src/pages/ConnectLogs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -419,11 +419,15 @@ describe('StatsPage', () => {
|
|||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Stats page now lazily loads channelsByUUID and channels via API
|
||||
// (keyed by UUID→ID and ID→channel 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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
46
frontend/src/store/connect.jsx
Normal file
46
frontend/src/store/connect.jsx
Normal 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;
|
||||
63
frontend/src/utils/cronUtils.js
Normal file
63
frontend/src/utils/cronUtils.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue