diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8f4a3a7..bf5b6040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/1.png b/1.png deleted file mode 100644 index 10b11162..00000000 Binary files a/1.png and /dev/null differ diff --git a/2.png b/2.png deleted file mode 100644 index 730a9d07..00000000 Binary files a/2.png and /dev/null differ diff --git a/3.png b/3.png deleted file mode 100644 index 800592bb..00000000 Binary files a/3.png and /dev/null differ diff --git a/4.png b/4.png deleted file mode 100644 index e6d18010..00000000 Binary files a/4.png and /dev/null differ diff --git a/5.png b/5.png deleted file mode 100644 index edd44882..00000000 Binary files a/5.png and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bef9bf..47808842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,222 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Configurable sidebar navigation ordering and visibility — Thanks [@jcasimir](https://github.com/jcasimir) + - Sidebar nav items can be reordered via drag-and-drop in Settings → UI Settings → Navigation. + - Individual nav items can be hidden from the sidebar using the eye toggle. Hiding an item preserves its position in the order. + - A "Reset to Default" button restores the role-appropriate default order and clears all hidden items. + - Order and visibility are saved per-user with optimistic updates and automatic rollback on failure. Changes appear in the sidebar immediately without a page reload. + - Admin users see a grouped navigation: flat items (`Channels`, `VODs`, `M3U & EPG Manager`, `TV Guide`, `DVR`, `Stats`, `Plugins`) plus collapsible `Integrations` (Connections, Logs) and `System` (Users, Logo Manager, Settings) groups. The `System` group cannot be hidden. + - Non-admin users see `Channels`, `TV Guide`, and `Settings`, with the `Settings` item not hideable. +- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810) +- Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. — Thanks [@CodeBormen](https://github.com/CodeBormen) +- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. +- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". +- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) +- DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454) + - **Extend Recording**: In-progress recordings can be extended by 15, 30, or 60 minutes without interrupting the stream. + - **Inline metadata editing**: Title and description can now be edited directly in the recording details modal. + - **Refresh artwork button**: Manually re-run poster resolution on demand from the recording card. + - **Multi-source poster resolution**: Added pipeline querying EPG, VOD, TMDB, OMDb, TVMaze, and iTunes for richer recording artwork. + - **Series rules for currently-airing episodes**: Series rules now capture currently-airing episodes in addition to future scheduled ones. (Closes #473) + - **Search and filter controls**: Added search and filter controls to the recordings list. + - **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration. + - **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention. + +### Changed + +- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810) + - `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`). + - `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component. + - `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`). + - `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`). + - `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs. + - `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting. + - Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element. +- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data. + +### Fixed + +- **Security**: Any authenticated user could self-escalate privileges by sending `user_level`, `is_staff`, or `is_superuser` in a `PATCH /api/accounts/users/me/` request. The `/me/` endpoint now enforces an allowlist (`custom_properties`, `first_name`, `last_name`, `email`, `password`); any other fields return HTTP 400. Privilege-sensitive fields can only be changed via the admin-only `PATCH /api/accounts/users/{id}/` endpoint. +- Double error notification when saving user preferences: `API.updateMe` was catching errors internally and displaying a notification before re-throwing, causing callers to display a second notification for the same failure. +- Navigation preference saves from concurrent sessions could overwrite each other due to a double-merge race: the frontend was pre-merging `custom_properties` before sending, then the backend merged again against the DB value, causing the second session's write to silently drop keys set by the first. The frontend now sends only the delta; the backend merges authoritatively against the stored value. +- Stale nav item IDs (e.g. from a previous nav structure) are now scrubbed from `navOrder` and `hiddenNav` on the next preference save, preventing unbounded growth of the `custom_properties` JSON field. +- Version update notification persisting after upgrading to the notified version (e.g. "v0.20.2 available" shown while already running v0.20.2). Root cause: `check_for_version_update.delay()` was called from `AppConfig.ready()`, which fires inside Celery prefork pool subprocesses before the broker connection is established, causing the dispatch to fail silently with no log output. Fixed by moving the startup dispatch to the `worker_ready` signal in `celery.py` (consistent with the existing `recover_recordings_on_startup` pattern), and deleting the stale `version-{current_version}` notification at the top of the production check path so it is cleared even when GitHub is unreachable. A WebSocket update is sent immediately on deletion so the frontend badge clears without waiting for the API response. +- VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run. +- XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. +- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer) +- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` → `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek) +- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek) + - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding. + - PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`. + - `PATH` in the Celery worker, Celery Beat, and Daphne systemd service files extended to include `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`, fixing failures where background tasks could not locate `ffmpeg` or `ffprobe`. +- `is_adult` field parsing now guards against invalid values (e.g. the string `"None"`) that providers may send instead of a valid integer, preventing a `ValueError` crash during M3U/XC stream refresh. A new `parse_is_adult()` helper wraps the cast in a `try/except`, returning `False` for anything that cannot be interpreted as `1`. (Fixes #1061) — Thanks [@JCBird1012](https://github.com/JCBird1012) +- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012) +- Celery worker memory leak during M3U/XC refresh causing 20–80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen) + - Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return) + - Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out) + - Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation +- EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included. +- TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen) + - **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. — Thanks [@patchy8736](https://github.com/patchy8736) + - **Leak on URL generation failure**: `generate_stream_url()` called `get_stream()` (which `INCR`s the counter) but had no cleanup path if subsequent DB lookups or URL construction failed. The post-`get_stream()` block is now wrapped in a `try/except` that calls `release_stream()` on any error. + - **Leak on retry-loop timeout**: the retry loop in `stream_ts()` called a bare `get_stream()` on the first failure to classify the error reason. If a slot was available, this `INCR`'d the counter and set Redis keys that were never released when the loop timed out. A `release_stream()` call is now issued before returning 503. + - **Leak on `initialize_channel()` failure**: when `initialize_channel()` returned `False`, the connection slot allocated by the preceding `get_stream()` was never released. A `connection_allocated` flag now tracks whether this request performed the `INCR` (fresh initialization vs. joining an existing channel), and `release_stream()` is called guarded by that flag to prevent incorrect decrements when attaching to an already-running channel. + - **Safety net for unexpected exceptions**: the outer `except` in `stream_ts()` now checks `connection_allocated` and calls `release_stream()` as a last-resort cleanup for any unhandled exception that escapes before the channel is handed off to the stream lifecycle. + - **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. — Thanks [@patchy8736](https://github.com/patchy8736) + - **`update_stream_profile()` uses a Redis pipeline** for the old-profile DECR + key update + new-profile INCR sequence, preventing counter drift if the process crashes between operations. + - **`stream_generator._cleanup()`** now falls back to `Stream.objects.get()` when the channel UUID resolves to a preview flow rather than a normal channel, rather than silently skipping the slot release. + - **VOD `cleanup_persistent_connection()`** fallback DECR is now conditional: it only decrements the profile counter when the connection tracking key had already expired by TTL (i.e., `remove_connection()` would have skipped the DECR), preventing double-decrements when the key is still present. +- TS proxy client stream lag recovery now only bumps clients forward when their next required chunk has genuinely expired from Redis (TTL), rather than unconditionally jumping if they fell more than 50 chunks behind. Clients are repositioned to the oldest available chunk (minimum data loss) using an atomic server-side Lua binary search, falling back to near the buffer head if nothing is available. +- TS proxy streams dying after 30–200 seconds in multi-worker uWSGI/Celery deployments, caused by three interrelated bugs. (Fixes #992, #980) - Thanks [@PFalko](https://github.com/PFalko) + - **Double ProxyServer instantiation**: `ProxyConfig.ready()` called `TSProxyServer()` directly while `TSProxyConfig.ready()` also called `TSProxyServer.get_instance()`, creating two instances per worker — each with its own cleanup thread. The orphaned thread could not extend ownership because it had no entries in `stream_managers`. Fixed by using `TSProxyServer.get_instance()` in `ProxyConfig.ready()`. + - **`flushdb()` on every Redis client init**: `RedisClient.get_client()` called `client.flushdb()` whenever `_client` was `None`. Celery autoscale (`--autoscale=6,1`) spawning new workers mid-stream triggered this path, nuking all Redis keys including active ownership keys, client records, and channel metadata. Removed the `flushdb()` call entirely. + - **No recovery from expired ownership**: `get_channel_owner()` called `redis.get()` twice inside a lambda (TOCTOU race — key could expire between calls); `extend_ownership()` silently returned `False` on expiry with no re-acquisition; and the non-owner cleanup path unconditionally killed streams even when the worker held the `stream_manager`. Fixed with a single `GET` in `get_channel_owner()`, re-acquisition via atomic `SET NX EX` in `extend_ownership()`, and a re-acquisition attempt with client-aware cleanup deferral in the cleanup thread. +- `get_instance()` deadlock: if `ProxyServer()` raised an exception during singleton construction, `_instance` was left permanently as the `_INITIALIZING` sentinel, causing all subsequent `get_instance()` callers to spin in an infinite `gevent.sleep()` loop. Construction is now wrapped in `try/except`; on failure `_instance` resets to `None` so the next call can retry. +- Non-atomic ownership acquisition in `try_acquire_ownership()`: replaced the separate `setnx()` + `expire()` calls with a single atomic `SET NX EX`, eliminating the race window where a process crash between the two calls could leave an ownership key with no TTL (permanent ownership lock). +- DVR bug fixes — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Duplicate recording execution**: `run_recording.apply_async(countdown=...)` exceeded Redis' default `visibility_timeout` (3600 s) for recordings scheduled more than one hour out, causing Redis to redeliver the task to multiple workers simultaneously and producing corrupted output files. Replaced `apply_async` with `ClockedSchedule` + `PeriodicTask` for database-backed one-shot scheduling that survives restarts and upgrades without the redelivery race. `run_recording` also now exits immediately if the recording is already in progress, completed, or stopped. `revoke_task()` cleans up both the `PeriodicTask` and its orphaned `ClockedSchedule` on execution. (Fixes #940, #641) + - **Stream reconnection resilience**: Recordings now survive transient network drops with automatic reconnection retrying up to 5 times and appending to the existing file. DB operations use exponential-backoff retry for transient database errors throughout the recording lifecycle. + - **Crash recovery pipeline**: On worker restart, recordings stuck in "recording" status have their segments concatenated and remuxed. Remux sanity checks reject MKV output that is less than 50% the size of a previous MKV (duplicate-task overwrite) or less than 10% of the source TS (corrupt first attempt); the source `.ts` is preserved for manual recovery on all failure paths. (Fixes #619, #624) + - **Output file collision**: Fixed collision when multiple tasks targeted the same filename. + - **WebSocket deadlock**: `send_websocket_update()` was deadlocking the gevent event loop, causing one recording's WebSocket events to block all other simultaneous recordings. + - **DVR client isolation**: Stop and Cancel operations now identify the target client by recording ID (via `User-Agent: Dispatcharr-DVR/recording-{id}`), ensuring only the correct proxy client is torn down and never affecting other recordings on the same channel. + - **Accidental stream termination on delete**: `destroy()` now only calls `_stop_dvr_clients()` for in-progress recordings, preventing stream termination when deleting a completed recording. + - **Recording card logos**: Logos were not displaying due to a channel summary API shape mismatch. + - **Logo fetch negative cache**: Added negative cache for failed remote logo fetches so dead CDNs no longer block Daphne workers on repeated requests. + - **Artwork fuzzy-match sanitisation**: Poster artwork fuzzy-matching against external APIs (TMDB, OMDb, etc.) was producing incorrect results for channels with names like "USA A&E SD\*"; channel-name strings are now sanitised before querying external sources. + - **Series modal "No upcoming episodes"**: Fixed due to a missing `_group_count` merge and an incorrect time filter. + - **Series rule cleanup**: Deleting a series rule left orphaned recordings and stale Guide indicators; rule deletion now cleans up all associated recordings. Orphaned recordings with no parent rule are also cleaned up automatically. (Fixes #1041) + - **Series rule timezone calculation**: Recurring rules silently dropped scheduled recordings for users in UTC-negative timezones after 4 pm local time. (Fixes #1042) + - **Recording modal TDZ crash**: Modal crashed on load in production bundles due to a Temporal Dead Zone error — editing state was referenced before its declaration in the minified bundle. + - **Description textarea focus loss**: The description textarea lost focus immediately when opened because the inline editing component was remounting on every render. + - **WebSocket-driven refresh**: Replaced all manual `fetchRecordings()` polling calls with debounced WebSocket-driven refresh so the recordings list stays up to date without redundant API requests. + - **comskip exit code handling**: comskip treated exit code 1 ("no commercials found") as a fatal error, causing post-processing to fail on clean recordings. Exit code 1 is now recognised as a successful no-op. + - **Differentiated WebSocket notification events**: `recording_stopped`, `recording_cancelled` (in-progress cancel), and `recording_deleted` with a `was_in_progress` flag now allow the frontend to display distinct "Recording stopped", "Recording cancelled", and "Recording deleted" toasts. + - **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path. + - **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card. + - **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently. +- Modular mode deployment hardening — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. (Fixes #1045) + - **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`. + - **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode — clearing stale stream locks, proxy metadata, and server-state keys — while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed. + - **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present. + - **Stale environment variables after container restart**: `/etc/profile.d/dispatcharr.sh` was only written on the first container run; restarts with changed env vars (e.g. a rotated `POSTGRES_PASSWORD`) retained stale values. The file is now truncated and rewritten on every startup. `/etc/environment` entries are likewise updated rather than skipped when a key already exists. All exported values are now quoted to prevent breakage from special characters. + - **Celery entrypoint startup timeouts**: The JWT key wait and migration wait loops had no timeout, leaving the Celery worker hanging indefinitely if the web container was stuck. Each loop now times out (120 s for JWT, 300 s for migrations) and exits with a clear diagnostic message. The migration readiness check is also replaced from a fragile `showmigrations | grep` pattern to `migrate --check`, which exits cleanly on both unapplied migrations and connection errors. + - **Service startup ordering**: `depends_on` entries for `db` and `redis` in `docker-compose.yml` upgraded from plain name-link ordering to `condition: service_healthy`, ensuring containers wait for actual readiness signals before starting. + - **`host.docker.internal` resolution on Linux**: Added `extra_hosts: host.docker.internal:host-gateway` to the web service in `docker-compose.yml` so Linux hosts resolve `host.docker.internal` the same way Docker Desktop does on macOS/Windows. + +## [0.20.2] - 2026-03-03 + +### Security + +- Updated frontend npm dependencies to resolve 2 high-severity vulnerabilities: + - Updated `minimatch` to ≥10.2.3, resolving **high** ReDoS via matchOne() combinatorial backtracking with multiple non-adjacent GLOBSTAR segments ([GHSA-7r86-cg39-jmmj](https://github.com/advisories/GHSA-7r86-cg39-jmmj)) + - Updated `rollup` to ≥4.58.1, resolving **high** Arbitrary File Write via Path Traversal ([GHSA-mw96-cpmx-2vgc](https://github.com/advisories/GHSA-mw96-cpmx-2vgc)) + +### Fixed + +- EPG filter regression in channel table (introduced in 0.20.0 channel store refactor): The EPG filter dropdown was showing all EPG sources regardless of whether they had any channels assigned, and the "No EPG" option was never displayed. Fixed by annotating EPGSource records with a `has_channels` flag (via a lightweight `EXISTS` subquery) so only active EPG sources with at least one channel assigned appear as filter options. "No EPG" now appears only when at least one channel globally has no EPG assigned; this is determined by a second `EXISTS` query embedded directly in the paginated channel response (`has_unassigned_epg_channels`), avoiding any additional network requests. +- Stale stream rows missing hover effect: Stale streams in the streams table had no hover color change, unlike channels with no streams assigned. Converted the inline `backgroundColor` style to a CSS class (`stale-stream-row`) so the `:hover` rule can apply correctly. Applied the same fix to the channel-streams sub-table, where the teal expanded-row background caused the semi-transparent red tint to visually mismatch; the sub-table now uses a pre-blended solid color via `color-mix()` to match the appearance of stale rows in the main streams table. +- Channel table onboarding shown when filter returns zero results: The channel store refactor changed to loading only channel IDs instead of full channel objects, leaving `Object.keys(channels).length` always `0` and incorrectly triggering the onboarding state on any empty filter. Fixed by checking `channelIds.length` instead. +- TV Guide scrolls to position 0 when a filter yields no results: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before. +- `debian_install.sh` regressions after `uv` migration on clean/minimal Debian installs: fixed pip-less venv (`ensurepip`), missing `gunicorn` for the systemd unit, and inconsistent `DJANGO_SECRET_KEY` availability (now persisted to `.env` via `EnvironmentFile`). Docker unaffected. - Thanks [@marcinolek](https://github.com/marcinolek) + +## [0.20.1] - 2026-02-26 + +### Fixed + +- Login form disabled after token expiry: The login button was permanently rendered as disabled ("Logging you in...") on page load after a session expired, preventing users from logging back in. A regression in v0.20.0 caused `LoginForm` to check `if (user)` to detect an already-authenticated reload, but the Zustand auth store initializes `user` as a truthy empty object `{ username: '', email: '', user_level: '' }`, so the loading state was set immediately on every mount. Reverted to pre-regression behavior. (Fixes #1029) + +## [0.20.0] - 2026-02-26 + +### Security + +- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs: + - **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 ` header or the `X-API-Key: ` header. Admin users can additionally generate and revoke keys on behalf of any user. +- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations. +- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942) +- Event-driven webhooks and script execution (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 +296,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths. - Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action. - Plugin enable/disable toggles now update immediately without requiring a full page refresh. +- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen) ## [0.18.1] - 2026-01-27 diff --git a/README.md b/README.md index 9b359e25..1efaecb3 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,87 @@ # 🎬 Dispatcharr — Your Ultimate IPTV & Stream Management Companion +

- Dispatcharr Logo + Dispatcharr Logo

--- ## 📖 What is Dispatcharr? -Dispatcharr is an **open-source powerhouse** for managing IPTV streams and EPG data with elegance and control.\ +Dispatcharr is an **open-source powerhouse** for managing IPTV streams, EPG data, and VOD content with elegance and control.\ Born from necessity and built with passion, it started as a personal project by **[OkinawaBoss](https://github.com/OkinawaBoss)** and evolved with contributions from legends like **[dekzter](https://github.com/dekzter)**, **[SergeantPanda](https://github.com/SergeantPanda)** and **Bucatini**. -> Think of Dispatcharr as the \*arr family’s IPTV cousin — simple, smart, and designed for streamers who want reliability and flexibility. +> Think of Dispatcharr as the \*arr family's IPTV cousin — simple, smart, and designed for streamers who want reliability and flexibility. --- -## 🧪 What’s New in Beta +## 🎯 What Can I Do With Dispatcharr? -Dispatcharr has officially entered **BETA**, bringing powerful new features and improvements across the board: +Dispatcharr empowers you with complete IPTV control. Here are some real-world scenarios: -✨ **Proxy Streaming Engine** — Optimize bandwidth, reduce provider connections, and increase stream reliability\ -📊 **Real-Time Stats Dashboard** — Live insights into stream health and client activity\ -🧠 **EPG Auto-Match** — Match program data to channels automatically\ -⚙️ **Streamlink + FFmpeg Support** — Flexible backend options for streaming and recording\ -🎬 **VOD Management** — Full Video on Demand support with movies and TV series\ -🧼 **UI & UX Enhancements** — Smoother, faster, more responsive interface\ -🛁 **Output Compatibility** — HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more +💡 **Consolidate Multiple IPTV Sources**\ +Combine streams from multiple providers into a single interface. Manage, filter, and organize thousands of channels with ease. + +📺 **Integrate with Media Centers**\ +Use HDHomeRun emulation to add virtual tuners to **Plex**, **Emby**, or **Jellyfin**. They'll discover Dispatcharr as a live TV source and can record programs directly to their own DVR libraries. + +📡 **Create a Personal TV Ecosystem**\ +Merge live TV channels with custom EPG guides. Generate XMLTV schedules or use auto-matching to align channels with existing program data. Export as M3U, Xtream Codes API, or HDHomeRun device. + +🔧 **Transcode & Optimize Streams**\ +Configure output profiles with FFmpeg transcoding to optimize streams for different clients — reduce bandwidth, standardize formats, or add audio normalization. + +🔐 **Centralize VPN Access**\ +Run Dispatcharr through a VPN container (like Gluetun) so all streams route through a single VPN connection. Your clients access geo-blocked content without needing individual VPNs, reducing bandwidth overhead and simplifying network management. + +🚀 **Monitor & Manage in Real-Time**\ +Track active streams, client connections, and bandwidth usage with live statistics. Monitor buffering events and stream quality. Automatic failover keeps viewers connected when streams fail—seamlessly switching to backup sources without interruption. + +👥 **Share Access Safely**\ +Create multiple user accounts with granular permissions. Share streams via M3U playlists or Xtream Codes API while controlling which users access which channels, profiles, or features. Network-based access restrictions available for additional security. + +🔌 **Extend with Plugins**\ +Build custom integrations using Dispatcharr's robust plugin system. Automate tasks, connect to external services, or add entirely new workflows. --- ## ✨ Why You'll Love Dispatcharr -✅ **Full IPTV Control** — Import, organize, proxy, and monitor IPTV streams on your own terms\ -✅ **Smart Playlist Handling** — M3U import, filtering, grouping, and failover support\ -✅ **VOD Content Management** — Organize movies and TV series with metadata and streaming\ -✅ **Reliable EPG Integration** — Match and manage TV guide data with ease\ -✅ **Clean & Responsive Interface** — Modern design that gets out of your way\ -✅ **Fully Self-Hosted** — Total control, zero reliance on third-party services +✅ **Stream Proxy & Relay** — Intercept and proxy IPTV streams with real-time client management\ +✅ **M3U & Xtream Codes** — Import, filter, and organize playlists with multiple backend support\ +✅ **EPG Matching & Generation** — Auto-match EPG to channels or generate custom TV guides\ +✅ **Video on Demand** — Stream movies and TV series with rich metadata and IMDB/TMDB integration\ +✅ **Multi-Format Output** — Export as M3U, XMLTV EPG, Xtream Codes API, or HDHomeRun device\ +✅ **Real-Time Monitoring** — Live connection stats, bandwidth tracking, and automatic failover\ +✅ **Stream Profiles** — Configure different stream profiles for various clients and bandwidth requirements\ +✅ **Flexible Streaming Backends** — VLC, FFmpeg, Streamlink, or custom backends for transcoding and streaming\ +✅ **Multi-User & Access Control** — Granular permissions and network-based access restrictions\ +✅ **Plugin System** — Extend functionality with custom plugins for automation and integrations\ +✅ **Fully Self-Hosted** — Total control, no third-party dependencies --- - # Screenshots -![image](https://github.com/user-attachments/assets/bf7bc40a-d0e6-4f9f-8029-65b27d4205f9) +
+ Channels + TV Guide + Stats & Monitoring + M3U & EPG Manager + VOD Library + Settings +
-![image](https://github.com/user-attachments/assets/0835fd92-f7dc-4773-bdb7-7f88fd2f882d) +--- -![image](https://github.com/user-attachments/assets/710f2bc4-250f-4161-a6ed-44d5082a30c4) +## 🛠️ Troubleshooting & Help -![image](https://github.com/user-attachments/assets/68a38d78-8f61-4c27-88f8-c52ba93d460d) +- **General help?** Visit [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +- **Community support?** Join our [Discord](https://discord.gg/Sp45V5BcxU) -![image](https://github.com/user-attachments/assets/63686b9a-6faf-43a3-ae7a-c9e10a216b5b) +--- - - - -# 🚀 Get Started in Minutes +## 🚀 Get Started in Minutes ### 🐳 Quick Start with Docker (Recommended) @@ -72,42 +98,50 @@ docker run -d \ --- -### 🐳 Docker Compose Options +### 🐋 Docker Compose Options -| Use Case | File | Description | -| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution — everything runs in a single container for quick setup. | -| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, and Postgres — perfect if you want more granular control. | -| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | +| Use Case | File | Description | +| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution — everything runs in a single container for quick setup. | +| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, Redis, and Postgres — perfect if you want more granular control. | +| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | --- -### ⚒️ Building from Source (For the Adventurous) +### 🛠️ Building from Source > ⚠️ **Warning**: Not officially supported — but if you're here, you know what you're doing! -If you are running a Debian based operating system you can install using the `debian_install.sh` script. If you are on another operating system and come up with a script let us know! We would love to add it here! +If you are running a Debian-based OS, use the `debian_install.sh` script. For other OS, contribute a script and we’ll add it! --- ## 🤝 Want to Contribute? We welcome **PRs, issues, ideas, and suggestions**!\ -Here’s how you can join the party: +Here's how you can join the party: - Follow our coding style and best practices. - Be respectful, helpful, and open-minded. - Respect the **CC BY-NC-SA license**. -> Whether it’s writing docs, squashing bugs, or building new features, your contribution matters! 🙌 +> Whether it's writing docs, squashing bugs, or building new features, your contribution matters! 🙋 --- -## 📚 Roadmap & Documentation +## 📚 Documentation & Roadmap -- 📚 **Roadmap:** Coming soon! - 📖 **Documentation:** [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +**Upcoming Features (in no particular order):** + +- 🎬 **VOD Management Enhancements** — Granular metadata control and cleanup of unwanted VOD content +- 📁 **Media Library** — Import local files and serve them over XC API +- 👥 **Enhanced User Management** — Customizable XC API output per user account +- 🔄 **Output Stream Profiles** — Different clients with different stream profiles (bandwidth control, quality tiers) +- 🔌 **Fallback Videos** — Automatic fallback content when channels are unavailable +- 📡 **Webhooks** — Event-driven integrations and automations + --- ## ❤️ Shoutouts @@ -120,7 +154,7 @@ A huge thank you to all the incredible open-source projects and libraries that p > Dispatcharr is licensed under **CC BY-NC-SA 4.0**: -- **BY**: Give credit where credit’s due. +- **BY**: Give credit where credit's due. - **NC**: No commercial use. - **SA**: Share alike if you remix. @@ -131,8 +165,8 @@ For full license details, see [LICENSE](https://creativecommons.org/licenses/by- ## ✉️ Connect With Us Have a question? Want to suggest a feature? Just want to say hi?\ -➡️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord]( https://discord.gg/Sp45V5BcxU). +➡️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord](https://discord.gg/Sp45V5BcxU). --- -### 🚀 *Happy Streaming! The Dispatcharr Team* +### 🚀 _Happy Streaming! The Dispatcharr Team_ diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index ac062841..90aa84fa 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -7,7 +7,7 @@ from .models import User class CustomUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('username', 'password', 'avatar_config', 'groups')}), - ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), + ('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}), ('Important dates', {'fields': ('last_login', 'date_joined')}), ) diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py index dda3832c..27de8eb7 100644 --- a/apps/accounts/api_urls.py +++ b/apps/accounts/api_urls.py @@ -4,6 +4,7 @@ from .api_views import ( AuthViewSet, UserViewSet, GroupViewSet, + APIKeyViewSet, TokenObtainPairView, TokenRefreshView, list_permissions, @@ -17,6 +18,7 @@ app_name = "accounts" router = DefaultRouter() router.register(r"users", UserViewSet, basename="user") router.register(r"groups", GroupViewSet, basename="group") +router.register(r"api-keys", APIKeyViewSet, basename="api-key") # 🔹 Custom Authentication Endpoints auth_view = AuthViewSet.as_view({"post": "login"}) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 607ce199..76892929 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -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"}) @@ -264,11 +278,24 @@ class UserViewSet(viewsets.ModelViewSet): return super().destroy(request, *args, **kwargs) @extend_schema( - description="Get active user information", + description="Get or update active user information. PATCH updates custom_properties with merge semantics.", + methods=["GET", "PATCH"], ) - @action(detail=False, methods=["get"], url_path="me") + @action(detail=False, methods=["get", "patch"], url_path="me") def me(self, request): user = request.user + if request.method == "PATCH": + ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"} + disallowed = set(request.data.keys()) - ALLOWED_FIELDS + if disallowed: + return Response( + {"detail": f"Fields not allowed for self-update: {', '.join(disallowed)}"}, + status=400, + ) + serializer = UserSerializer(user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) serializer = UserSerializer(user) return Response(serializer.data) @@ -305,6 +332,59 @@ class GroupViewSet(viewsets.ModelViewSet): return super().destroy(request, *args, **kwargs) +# API Key management +class APIKeyViewSet(viewsets.ViewSet): + permission_classes = [Authenticated] + + def list(self, request): + user = request.user + return Response({"key": user.api_key}) + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + raw = secrets.token_urlsafe(40) + target_user.api_key = raw + target_user.save(update_fields=["api_key"]) + + user_data = UserSerializer(target_user).data + return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["post"], url_path="revoke") + def revoke(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + target_user.api_key = None + target_user.save(update_fields=["api_key"]) + + return Response({"success": True}) + + # 🔹 4) Permissions List API @extend_schema( description="Retrieve a list of all permissions", diff --git a/apps/accounts/authentication.py b/apps/accounts/authentication.py new file mode 100644 index 00000000..5380af49 --- /dev/null +++ b/apps/accounts/authentication.py @@ -0,0 +1,49 @@ +from rest_framework import authentication +from rest_framework import exceptions +from django.conf import settings +from .models import User + + +class ApiKeyAuthentication(authentication.BaseAuthentication): + """ + Accepts header `Authorization: ApiKey ` or `X-API-Key: `. + """ + + keyword = "ApiKey" + + def authenticate(self, request): + # Check X-API-Key header first + raw_key = request.META.get("HTTP_X_API_KEY") + + if not raw_key: + auth = authentication.get_authorization_header(request).split() + if not auth: + return None + + if len(auth) != 2: + return None + + scheme = auth[0].decode().lower() + if scheme != self.keyword.lower(): + return None + + raw_key = auth[1].decode() + + if not raw_key: + return None + + if not raw_key: + return None + + try: + user = User.objects.get(api_key=raw_key) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid API key") + + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return (user, None) + + def authenticate_header(self, request): + return self.keyword diff --git a/apps/accounts/migrations/0004_user_api_key.py b/apps/accounts/migrations/0004_user_api_key.py new file mode 100644 index 00000000..fee7c983 --- /dev/null +++ b/apps/accounts/migrations/0004_user_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-21 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_user_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='api_key', + field=models.CharField(blank=True, db_index=True, max_length=200, null=True), + ), + ] diff --git a/apps/accounts/migrations/0005_alter_user_managers.py b/apps/accounts/migrations/0005_alter_user_managers.py new file mode 100644 index 00000000..af66e123 --- /dev/null +++ b/apps/accounts/migrations/0005_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +import apps.accounts.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_user_api_key'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', apps.accounts.models.CustomUserManager()), + ], + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index da5e36bc..e04d66ed 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,9 +1,16 @@ # apps/accounts/models.py from django.db import models -from django.contrib.auth.models import AbstractUser, Permission +from django.contrib.auth.models import AbstractUser, Permission, UserManager + + +class CustomUserManager(UserManager): + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('user_level', 10) + return super().create_superuser(username, email, password, **extra_fields) class User(AbstractUser): + objects = CustomUserManager() """ Custom user model for Dispatcharr. Inherits from Django's AbstractUser to add additional fields if needed. @@ -22,6 +29,7 @@ class User(AbstractUser): ) user_level = models.IntegerField(default=UserLevel.STREAMER) custom_properties = models.JSONField(default=dict, blank=True, null=True) + api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) def __str__(self): return self.username diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 865d29af..c90ffef4 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,9 +1,32 @@ +import json + from rest_framework import serializers from django.contrib.auth.models import Group, Permission from .models import User from apps.channels.models import ChannelProfile +# Valid navigation item IDs for validation +VALID_NAV_ITEM_IDS = { + 'channels', 'vods', 'sources', 'guide', 'dvr', + 'stats', 'plugins', 'integrations', 'system', 'settings' +} +MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit + + +def validate_nav_array(value, field_name): + """Validate that a value is an array of valid nav item ID strings.""" + if not isinstance(value, list): + raise serializers.ValidationError(f"{field_name} must be an array") + if len(value) > 50: + raise serializers.ValidationError(f"{field_name} exceeds maximum length of 50 items") + for item in value: + if not isinstance(item, str): + raise serializers.ValidationError(f"{field_name} items must be strings") + if item not in VALID_NAV_ITEM_IDS: + raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID") + + # 🔹 Fix for Permission serialization class PermissionSerializer(serializers.ModelSerializer): class Meta: @@ -24,23 +47,24 @@ class GroupSerializer(serializers.ModelSerializer): # 🔹 Fix for User serialization class UserSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True) + password = serializers.CharField(write_only=True, required=False) 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", @@ -49,12 +73,37 @@ class UserSerializer(serializers.ModelSerializer): "last_name", ] + def validate_custom_properties(self, value): + """Validate custom_properties structure and size.""" + if value is None: + return {} + if not isinstance(value, dict): + raise serializers.ValidationError("custom_properties must be a dictionary") + + # Size limit check + try: + if len(json.dumps(value)) > MAX_CUSTOM_PROPS_SIZE: + raise serializers.ValidationError( + f"custom_properties exceeds maximum size of {MAX_CUSTOM_PROPS_SIZE} bytes" + ) + except (TypeError, ValueError): + raise serializers.ValidationError("custom_properties contains non-serializable data") + + # Validate navOrder if present + if 'navOrder' in value: + validate_nav_array(value['navOrder'], 'navOrder') + + # Validate hiddenNav if present + if 'hiddenNav' in value: + validate_nav_array(value['hiddenNav'], 'hiddenNav') + + return value + def create(self, validated_data): channel_profiles = validated_data.pop("channel_profiles", []) user = User(**validated_data) user.set_password(validated_data["password"]) - user.is_active = True user.save() user.channel_profiles.set(channel_profiles) @@ -65,6 +114,22 @@ class UserSerializer(serializers.ModelSerializer): password = validated_data.pop("password", None) channel_profiles = validated_data.pop("channel_profiles", None) + # Merge custom_properties instead of replacing (prevents data loss) + # Strip null values — sending null for a key omits it rather than overwriting with null + custom_properties = validated_data.pop("custom_properties", None) + if custom_properties is not None: + existing = instance.custom_properties or {} + cleaned = {k: v for k, v in custom_properties.items() if v is not None} + merged = {**existing, **cleaned} + # Scrub stale nav IDs so the DB self-heals on next save + for nav_field in ('navOrder', 'hiddenNav'): + if nav_field in merged and isinstance(merged[nav_field], list): + merged[nav_field] = [ + item for item in merged[nav_field] + if item in VALID_NAV_ITEM_IDS + ] + instance.custom_properties = merged + for attr, value in validated_data.items(): setattr(instance, attr, value) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 00000000..629207ba --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,72 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +User = get_user_model() + + +class InitializeSuperuserTests(TestCase): + """Tests for the initialize_superuser endpoint""" + + def setUp(self): + self.client = APIClient() + self.url = "/api/accounts/initialize-superuser/" + + def test_returns_true_when_superuser_exists(self): + """Superuser with is_superuser=True should be detected""" + User.objects.create_superuser( + username="admin", password="testpass123", user_level=10 + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_true_when_admin_level_user_exists(self): + """User with user_level=10 but is_superuser=False should be detected""" + user = User.objects.create_user(username="admin", password="testpass123") + user.user_level = 10 + user.is_superuser = False + user.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_false_when_no_admin_exists(self): + """No admin or superuser should return false""" + # Create a non-admin user + User.objects.create_user(username="regular", password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_returns_false_when_no_users_exist(self): + """Empty database should return false""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_create_superuser_when_none_exists(self): + """POST should create superuser when none exists""" + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123", "email": "admin@test.com"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists()) + + def test_cannot_create_superuser_when_admin_exists(self): + """POST should fail when an admin-level user already exists""" + user = User.objects.create_user(username="existing", password="testpass123") + user.user_level = 10 + user.save() + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + # Should NOT have created a new user + self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file diff --git a/apps/api/urls.py b/apps/api/urls.py index 5a688778..b176bc1b 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -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')), diff --git a/apps/backups/api_views.py b/apps/backups/api_views.py index c6ff7d26..a5af495e 100644 --- a/apps/backups/api_views.py +++ b/apps/backups/api_views.py @@ -9,7 +9,8 @@ from django.conf import settings from django.http import HttpResponse, StreamingHttpResponse, Http404 from rest_framework import status from rest_framework.decorators import api_view, permission_classes, parser_classes -from rest_framework.permissions import IsAdminUser, AllowAny +from rest_framework.permissions import AllowAny +from apps.accounts.permissions import IsAdmin from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response @@ -33,7 +34,7 @@ def _verify_task_token(task_id: str, token: str) -> bool: @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def list_backups(request): """List all available backup files.""" try: @@ -47,7 +48,7 @@ def list_backups(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def create_backup(request): """Create a new backup (async via Celery).""" try: @@ -86,7 +87,7 @@ def backup_status(request, task_id): ) else: # Fall back to admin auth check - if not request.user.is_authenticated or not request.user.is_staff: + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: return Response( {"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED, @@ -124,7 +125,7 @@ def backup_status(request, task_id): @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def get_download_token(request, filename): """Get a signed token for downloading a backup file.""" try: @@ -168,7 +169,7 @@ def download_backup(request, filename): ) else: # Fall back to admin auth check - if not request.user.is_authenticated or not request.user.is_staff: + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: return Response( {"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED, @@ -230,7 +231,7 @@ def download_backup(request, filename): @api_view(["DELETE"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def delete_backup(request, filename): """Delete a backup file.""" try: @@ -253,7 +254,7 @@ def delete_backup(request, filename): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) @parser_classes([MultiPartParser, FormParser]) def upload_backup(request): """Upload a backup file for restoration.""" @@ -299,7 +300,7 @@ def upload_backup(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def restore_backup(request, filename): """Restore from a backup file (async via Celery). WARNING: This will flush the database!""" try: @@ -332,7 +333,7 @@ def restore_backup(request, filename): @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def get_schedule(request): """Get backup schedule settings.""" try: @@ -346,7 +347,7 @@ def get_schedule(request): @api_view(["PUT"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def update_schedule(request): """Update backup schedule settings.""" try: diff --git a/apps/backups/scheduler.py b/apps/backups/scheduler.py index aa7e9bcd..a427757d 100644 --- a/apps/backups/scheduler.py +++ b/apps/backups/scheduler.py @@ -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() diff --git a/apps/backups/tests.py b/apps/backups/tests.py index 85076a85..e5743623 100644 --- a/apps/backups/tests.py +++ b/apps/backups/tests.py @@ -735,6 +735,94 @@ class BackupAPITestCase(TestCase): self.assertIn('frequency', data['detail']) +class BackupAdminPermissionTestCase(TestCase): + """Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks. + + This validates the IsAdminUser -> IsAdmin permission change. + API-created admins have user_level=10 but is_staff=False and is_superuser=False. + """ + + def setUp(self): + self.client = APIClient() + # API-created admin: user_level=10 but NOT is_staff or is_superuser + self.api_admin = User.objects.create_user( + username='api_admin', + email='apiadmin@example.com', + password='testpass123' + ) + self.api_admin.user_level = 10 + self.api_admin.is_staff = False + self.api_admin.is_superuser = False + self.api_admin.save() + + # User with is_staff=True but low user_level (should NOT have access) + self.staff_user = User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123' + ) + self.staff_user.is_staff = True + self.staff_user.user_level = 1 + self.staff_user.save() + + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.list_backups') + def test_api_created_admin_can_list_backups(self, mock_list_backups): + """API-created admin (user_level=10, is_staff=False) should access backup endpoints""" + mock_list_backups.return_value = [] + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + + def test_staff_user_without_user_level_cannot_list_backups(self): + """User with is_staff=True but user_level < 10 should NOT access backup endpoints""" + auth_header = self.get_auth_header(self.staff_user) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_api_created_admin_can_create_backup(self, mock_create_task): + """API-created admin should be able to create backups""" + mock_task = MagicMock() + mock_task.id = 'test-task-id' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + + @patch('apps.backups.services.get_backup_dir') + def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir): + """API-created admin should be able to delete backups""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.delete( + '/api/backups/test-backup.zip/delete/', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 204) + + class BackupSchedulerTestCase(TestCase): """Test cases for backup scheduler""" diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b1ea9a45..550f6302 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction from django.db.models import Count, F from django.db.models import Q -import os, json, requests, logging, mimetypes +import os, json, requests, logging, mimetypes, threading, time +from datetime import timedelta from django.utils.http import http_date from urllib.parse import unquote from apps.accounts.permissions import ( @@ -48,7 +49,6 @@ from .serializers import ( ) from .tasks import ( match_epg_channels, - evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg, match_selected_channels_epg, @@ -72,6 +72,12 @@ from rest_framework.pagination import PageNumberPagination logger = logging.getLogger(__name__) +# Negative cache for remote logo URLs that failed to fetch. +# Prevents repeated blocking requests to unreachable hosts (e.g., dead CDNs) +# from exhausting Daphne workers. Keyed by URL, value is expiry timestamp. +_logo_fetch_failures = {} +_LOGO_FAIL_TTL = 300 # seconds + class OrInFilter(django_filters.Filter): """ @@ -413,6 +419,13 @@ class ChannelPagination(PageNumberPagination): return super().paginate_queryset(queryset, request, view) + def get_paginated_response(self, data): + from django.db.models import Exists, OuterRef + has_unassigned = Channel.objects.filter(epg_data__isnull=True).exists() + response = super().get_paginated_response(data) + response.data['has_unassigned_epg_channels'] = has_unassigned + return response + class EPGFilter(django_filters.Filter): """ @@ -571,6 +584,7 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_profile_id = self.request.query_params.get("channel_profile_id") show_disabled_param = self.request.query_params.get("show_disabled", None) only_streamless = self.request.query_params.get("only_streamless", None) + only_stale = self.request.query_params.get("only_stale", None) if channel_profile_id: try: @@ -590,6 +604,9 @@ class ChannelViewSet(viewsets.ModelViewSet): if only_streamless: q_filters &= Q(streams__isnull=True) + if only_stale: + # Filter channels that have at least one related stream marked as stale + q_filters &= Q(streams__is_stale=True) if self.request.user.user_level < 10: filters["user_level__lte"] = self.request.user.user_level @@ -724,6 +741,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., (?...)) and converts them to Python syntax. " + "Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $ 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 (?...) -> (?P...) + 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>") + # $ -> \g + 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 +946,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.", @@ -1803,6 +1962,12 @@ class LogoViewSet(viewsets.ModelViewSet): return response else: # Remote image + # Skip URLs that recently failed to avoid blocking Daphne workers + # on unreachable hosts (e.g., dead CDNs referenced by old recordings). + fail_expiry = _logo_fetch_failures.get(logo_url) + if fail_expiry and time.monotonic() < fail_expiry: + raise Http404("Remote image temporarily unavailable") + try: # Get the default user agent try: @@ -1821,6 +1986,9 @@ class LogoViewSet(viewsets.ModelViewSet): headers={'User-Agent': user_agent} ) if remote_response.status_code == 200: + # Success — clear any previous failure entry + _logo_fetch_failures.pop(logo_url, None) + # Try to get content type from response headers first content_type = remote_response.headers.get("Content-Type") @@ -1844,14 +2012,19 @@ class LogoViewSet(viewsets.ModelViewSet): os.path.basename(logo_url) ) return response + # Non-200 response — cache the failure and evict stale entries + now = time.monotonic() + _logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL + if len(_logo_fetch_failures) > 256: + for k in [k for k, v in _logo_fetch_failures.items() if v <= now]: + _logo_fetch_failures.pop(k, None) raise Http404("Remote image not found") - except requests.exceptions.Timeout: - logger.warning(f"Timeout fetching logo from {logo_url}") - raise Http404("Logo request timed out") - except requests.exceptions.ConnectionError: - logger.warning(f"Connection error fetching logo from {logo_url}") - raise Http404("Unable to connect to logo server") except requests.RequestException as e: + now = time.monotonic() + _logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL + if len(_logo_fetch_failures) > 256: + for k in [k for k, v in _logo_fetch_failures.items() if v <= now]: + _logo_fetch_failures.pop(k, None) logger.warning(f"Error fetching logo from {logo_url}: {e}") raise Http404("Error fetching remote image") @@ -2095,6 +2268,54 @@ class RecurringRecordingRuleViewSet(viewsets.ModelViewSet): logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}") +def _stop_dvr_clients(channel_uuid, recording_id=None): + """Stop DVR recording clients for a channel. + + If recording_id is provided, only the client whose User-Agent contains that + recording ID is stopped (safe for simultaneous recordings on the same channel). + If recording_id is None, all Dispatcharr-DVR clients for the channel are stopped + (used by destroy() when deleting a recording whose task_id is unknown). + + Returns the number of DVR clients stopped. + """ + from core.utils import RedisClient + from apps.proxy.ts_proxy.redis_keys import RedisKeys + from apps.proxy.ts_proxy.services.channel_service import ChannelService + + r = RedisClient.get_client() + if not r: + return 0 + client_set_key = RedisKeys.clients(channel_uuid) + client_ids = r.smembers(client_set_key) or [] + stopped = 0 + for raw_id in client_ids: + try: + cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id) + meta_key = RedisKeys.client_metadata(channel_uuid, cid) + ua = r.hget(meta_key, "user_agent") + ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "") + if not (ua_s and "Dispatcharr-DVR" in ua_s): + continue + # When a recording_id is specified, only stop the client for that recording. + # Each run_recording task connects with User-Agent "Dispatcharr-DVR/recording-{id}", + # so we can safely target just this recording without affecting others on the channel. + if recording_id is not None and f"recording-{recording_id}" not in ua_s: + continue + try: + ChannelService.stop_client(channel_uuid, cid) + stopped += 1 + except Exception as inner_e: + logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}") + except Exception as inner: + logger.debug(f"Error while checking client metadata: {inner}") + # Do not call ChannelService.stop_channel() here. + # Stopping the channel proxy would terminate the source connection which may + # be shared with other recordings on the same channel. The TS proxy server + # already detects when client count reaches zero and tears down the channel + # cleanly on its own (with the configured shutdown delay). + return stopped + + class RecordingViewSet(viewsets.ModelViewSet): queryset = Recording.objects.all() serializer_class = RecordingSerializer @@ -2189,72 +2410,290 @@ class RecordingViewSet(viewsets.ModelViewSet): response["Content-Disposition"] = f"inline; filename=\"{file_name}\"" return response + @action(detail=True, methods=["post"], url_path="stop") + def stop(self, request, pk=None): + """Stop a recording early while retaining the partial content for playback.""" + instance = self.get_object() + + cp = instance.custom_properties or {} + current_status = cp.get("status", "") + + # Reject stop on recordings that are already in a terminal state. + # Without this guard, stop() would overwrite "completed" or + # "interrupted" with "stopped", losing the original outcome. + terminal = {"completed", "interrupted", "failed"} + if current_status in terminal: + return Response( + {"success": False, "error": f"Recording is already {current_status}"}, + status=status.HTTP_409_CONFLICT, + ) + + # Mark as stopped in the DB first so run_recording detects it. + # This is the only operation that MUST be synchronous — run_recording reads + # the status field to decide whether the stream disconnection was deliberate. + cp["status"] = "stopped" + cp["stopped_at"] = str(timezone.now()) + instance.custom_properties = cp + instance.save(update_fields=["custom_properties"]) + + # Send the WebSocket notification before returning the response. + # send_websocket_update is gevent-safe (offloads async_to_sync to a + # real OS thread when monkey-patching is active). + channel_uuid = str(instance.channel.uuid) + recording_id = instance.id + task_id = instance.task_id + channel_name = instance.channel.name + + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_stopped", + "channel": channel_name, + }) + except Exception: + pass + + # DVR client teardown and task revocation are deferred to a daemon thread + # because they have occasional slow paths (Redis timeouts, Celery control + # broadcasts) that would otherwise add 5-15 s to the HTTP response time. + def _background_stop(): + try: + stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id) + if stopped: + logger.info( + f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)" + ) + except Exception as e: + logger.debug(f"Unable to stop DVR clients for stopped recording: {e}") + + try: + from apps.channels.signals import revoke_task + revoke_task(task_id) + except Exception as e: + logger.debug(f"Unable to revoke task for stopped recording: {e}") + + try: + from django.db import connection as _conn + _conn.close() + except Exception: + pass + + threading.Thread(target=_background_stop, daemon=True).start() + + return Response({"success": True, "status": "stopped"}) + + @action(detail=True, methods=["post"], url_path="extend") + def extend(self, request, pk=None): + """Extend an in-progress recording's end_time without interrupting the stream. + + The running task re-reads end_time every ~2 s and adjusts its deadline + dynamically. The pre_save signal skips task revocation while the + recording status is 'recording'. + """ + instance = self.get_object() + cp = instance.custom_properties or {} + + if cp.get("status") in ("completed", "stopped", "interrupted"): + return Response( + {"success": False, "error": "Recording has already finished"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + extra_minutes = int(request.data.get("extra_minutes", 0)) + except (TypeError, ValueError): + extra_minutes = 0 + + if extra_minutes <= 0: + return Response( + {"success": False, "error": "extra_minutes must be a positive integer"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_end_time = instance.end_time + timedelta(minutes=extra_minutes) + # Use queryset .update() to bypass pre_save/post_save signals. + # This avoids the pre_save signal revoking the scheduled/running + # Celery task. The running task's 2-second polling loop re-reads + # end_time from the DB and extends its deadline dynamically. + # If the task hasn't started yet (still in Beat's queue), it will + # read the updated end_time from the DB on its first poll cycle. + Recording.objects.filter(pk=instance.pk).update(end_time=new_end_time) + + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_extended", + "recording_id": instance.id, + "new_end_time": new_end_time.isoformat(), + "extra_minutes": extra_minutes, + "channel": instance.channel.name, + }) + except Exception: + pass + + return Response({"success": True, "new_end_time": new_end_time.isoformat()}) + + @action(detail=True, methods=["post"], url_path="refresh-artwork") + def refresh_artwork(self, request, pk=None): + """Re-run the poster resolution pipeline for this recording. + + Useful when a recording fell back to a channel logo or default logo + because external sources were temporarily unavailable. + """ + instance = self.get_object() + + def _background_refresh(rec_id): + try: + from .tasks import _resolve_poster_for_program + from .models import Recording + from core.utils import send_websocket_update + from django.db import close_old_connections + + rec = Recording.objects.select_related("channel").get(id=rec_id) + cp = rec.custom_properties or {} + program = cp.get("program") or {} + + poster_logo_id, poster_url = _resolve_poster_for_program( + rec.channel.name, program, channel_logo_id=rec.channel.logo_id, + ) + + # Refresh and merge to avoid overwriting concurrent changes. + # Only upgrade — never replace a real poster with a channel logo fallback. + rec.refresh_from_db() + fresh_cp = rec.custom_properties or {} + updated = False + is_channel_logo_fallback = ( + poster_logo_id == rec.channel.logo_id + and not poster_url + ) + if program and program.get("id"): + fresh_cp["program"] = program + updated = True + if not is_channel_logo_fallback: + if poster_logo_id and fresh_cp.get("poster_logo_id") != poster_logo_id: + fresh_cp["poster_logo_id"] = poster_logo_id + updated = True + if poster_url and fresh_cp.get("poster_url") != poster_url: + fresh_cp["poster_url"] = poster_url + updated = True + + if updated: + rec.custom_properties = fresh_cp + rec.save(update_fields=["custom_properties"]) + + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_updated", + "recording_id": rec_id, + }) + except Exception as e: + logger.debug(f"refresh-artwork background failed for {rec_id}: {e}") + finally: + close_old_connections() + + t = threading.Thread(target=_background_refresh, args=(instance.id,), daemon=True) + t.start() + + return Response({"success": True, "message": "Artwork refresh started"}) + + @action(detail=True, methods=["post"], url_path="update-metadata") + def update_metadata(self, request, pk=None): + """Update user-editable recording metadata (title, description). + + Sets user_edited flag to prevent EPG auto-enrichment from overwriting + the user's changes on subsequent task runs. + """ + instance = self.get_object() + title = request.data.get("title") + description = request.data.get("description") + + if title is None and description is None: + return Response( + {"success": False, "error": "No fields to update"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Strip whitespace; treat blank strings as "no change" + clean_title = str(title).strip() if title is not None else None + clean_desc = str(description).strip() if description is not None else None + + if not clean_title and not clean_desc: + return Response( + {"success": False, "error": "Title and description cannot be blank"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cp = instance.custom_properties or {} + program = cp.get("program") or {} + + if clean_title: + program["title"] = clean_title + if clean_desc: + program["description"] = clean_desc + program["user_edited"] = True + + cp["program"] = program + instance.custom_properties = cp + instance.save(update_fields=["custom_properties"]) + + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_updated", + "recording_id": instance.id, + }) + except Exception: + pass + + return Response({"success": True}) + def destroy(self, request, *args, **kwargs): """Delete the Recording and ensure any active DVR client connection is closed. Also removes the associated file(s) from disk if present. + + Operation order matters for correctness: + 1. Delete the DB record first — run_recording's cancellation guard + (Recording.objects.filter(id=...).exists()) will now return False, + preventing it from saving 'interrupted' status or sending + recording_ended after the stream is torn down. + 2. Send recording_cancelled WebSocket immediately so the frontend + removes the card without waiting for the slow DVR client teardown. + 3. Spawn a background thread to stop the DVR client and delete files. + This mirrors the stop() endpoint's approach and avoids the 5-15 s + delay that _stop_dvr_clients() can introduce. """ instance = self.get_object() + recording_id = instance.pk + channel_name = instance.channel.name - # Attempt to close the DVR client connection for this channel if active - try: - channel_uuid = str(instance.channel.uuid) - # Lazy imports to avoid module overhead if proxy isn't used - from core.utils import RedisClient - from apps.proxy.ts_proxy.redis_keys import RedisKeys - from apps.proxy.ts_proxy.services.channel_service import ChannelService - - r = RedisClient.get_client() - if r: - client_set_key = RedisKeys.clients(channel_uuid) - client_ids = r.smembers(client_set_key) or [] - stopped = 0 - for raw_id in client_ids: - try: - cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id) - meta_key = RedisKeys.client_metadata(channel_uuid, cid) - ua = r.hget(meta_key, "user_agent") - ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "") - # Identify DVR recording client by its user agent - if ua_s and "Dispatcharr-DVR" in ua_s: - try: - ChannelService.stop_client(channel_uuid, cid) - stopped += 1 - except Exception as inner_e: - logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}") - except Exception as inner: - logger.debug(f"Error while checking client metadata: {inner}") - if stopped: - logger.info(f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation") - # If no clients remain after stopping DVR clients, proactively stop the channel - try: - remaining = r.scard(client_set_key) or 0 - except Exception: - remaining = 0 - if remaining == 0: - try: - ChannelService.stop_channel(channel_uuid) - logger.info(f"Stopped channel {channel_uuid} (no clients remain)") - except Exception as sc_e: - logger.debug(f"Unable to stop channel {channel_uuid}: {sc_e}") - except Exception as e: - logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}") - - # Capture paths before deletion + # Capture state before the DB row is deleted cp = instance.custom_properties or {} + rec_status = cp.get("status", "") file_path = cp.get("file_path") temp_ts_path = cp.get("_temp_file_path") + channel_uuid = str(instance.channel.uuid) - # Perform DB delete first, then try to remove files + # 1. Delete the DB record (also fires post_delete → revoke_task_on_delete) response = super().destroy(request, *args, **kwargs) - # Notify frontends to refresh recordings + # 2. Notify frontends immediately try: from core.utils import send_websocket_update - send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"}) + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_cancelled", + "recording_id": recording_id, + "channel": channel_name, + "was_in_progress": rec_status == "recording", + }) except Exception: pass + # 3. Defer slow teardown to a background thread library_dir = '/data' allowed_roots = ['/data/', library_dir.rstrip('/') + '/'] @@ -2268,8 +2707,32 @@ class RecordingViewSet(viewsets.ModelViewSet): except Exception as ex: logger.warning(f"Failed to delete recording artifact {path}: {ex}") - _safe_remove(file_path) - _safe_remove(temp_ts_path) + def _background_cancel(): + # Only stop the DVR client if the recording was actively streaming. + # Stopping for completed/upcoming recordings would kill an unrelated + # in-progress recording on the same channel. + if rec_status == "recording": + try: + stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id) + if stopped: + logger.info( + f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation" + ) + except Exception as e: + logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}") + + # Best-effort file cleanup in case run_recording already exited + # before the DB delete. + _safe_remove(file_path) + _safe_remove(temp_ts_path) + + try: + from django.db import connection as _conn + _conn.close() + except Exception: + pass + + threading.Thread(target=_background_cancel, daemon=True).start() return response @@ -2382,11 +2845,9 @@ class SeriesRulesAPIView(APIView): else: rules.append({"tvg_id": tvg_id, "mode": mode, "title": title}) CoreSettings.set_dvr_series_rules(rules) - # Evaluate immediately for this tvg_id (async) - try: - evaluate_series_rules.delay(tvg_id) - except Exception: - pass + # Note: frontend calls the evaluate endpoint explicitly after creating + # the rule, so do NOT fire evaluate_series_rules.delay() here to + # avoid a race that creates duplicate recordings. return Response({"success": True, "rules": rules}) @@ -2399,16 +2860,44 @@ class DeleteSeriesRuleAPIView(APIView): @extend_schema( summary="Delete a series rule", - description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.", parameters=[ OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'), ], ) def delete(self, request, tvg_id): tvg_id = unquote(str(tvg_id or "")) - rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id] - CoreSettings.set_dvr_series_rules(rules) - return Response({"success": True, "rules": rules}) + + # Find the rule before removing to retain the title for cleanup + rules = CoreSettings.get_dvr_series_rules() + deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None) + remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id] + CoreSettings.set_dvr_series_rules(remaining) + + # Delete only FUTURE recordings — preserve previously recorded episodes + removed = 0 + if deleted_rule: + from .models import Recording + qs = Recording.objects.filter( + start_time__gte=timezone.now(), + custom_properties__program__tvg_id=tvg_id, + ) + title = deleted_rule.get("title") + if title: + qs = qs.filter(custom_properties__program__title=title) + removed = qs.count() + qs.delete() + + # Notify frontend to refresh recordings list + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, "type": "recordings_refreshed", "removed": removed, + }) + except Exception: + pass + + return Response({"success": True, "rules": remaining, "removed": removed}) class EvaluateSeriesRulesAPIView(APIView): diff --git a/apps/channels/models.py b/apps/channels/models.py index ec5e135b..217a92c0 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -3,6 +3,8 @@ from django.core.exceptions import ValidationError from django.conf import settings from core.models import StreamProfile, CoreSettings from core.utils import RedisClient +from apps.proxy.ts_proxy.redis_keys import RedisKeys +from apps.proxy.ts_proxy.constants import ChannelMetadataField import logging import uuid from datetime import datetime @@ -204,7 +206,10 @@ class Stream(models.Model): def get_stream(self): """ - Finds an available stream for the requested channel and returns the selected stream and profile. + Finds an available profile for this stream and reserves a connection slot. + + Returns: + Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) """ redis_client = RedisClient.get_client() profile_id = redis_client.get(f"stream_profile:{self.id}") @@ -226,33 +231,32 @@ class Stream(models.Model): if profile.is_active == False: continue - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) + # Atomic slot reservation: INCR first, check, rollback if over capacity + if profile.max_streams == 0: + reserved = True + else: + profile_connections_key = f"profile_connections:{profile.id}" + new_count = redis_client.incr(profile_connections_key) + if new_count <= profile.max_streams: + reserved = True + else: + redis_client.decr(profile_connections_key) + reserved = False - # Check if profile has available slots (or unlimited connections) - if profile.max_streams == 0 or current_connections < profile.max_streams: - # Start a new stream + if reserved: redis_client.set(f"channel_stream:{self.id}", self.id) - redis_client.set( - f"stream_profile:{self.id}", profile.id - ) # Store only the matched profile + redis_client.set(f"stream_profile:{self.id}", profile.id) + return self.id, profile.id, None - # Increment connection count for profiles with limits - if profile.max_streams > 0: - redis_client.incr(profile_connections_key) - - return ( - self.id, - profile.id, - None, - ) # Return newly assigned stream and matched profile - - # 4. No available streams - return None, None, None + return None, None, "All active M3U profiles have reached maximum connection limits" def release_stream(self): """ Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no profile info could be found for cleanup. """ redis_client = RedisClient.get_client() @@ -260,14 +264,17 @@ class Stream(models.Model): # Get the matched profile for cleanup profile_id = redis_client.get(f"stream_profile:{stream_id}") if not profile_id: - logger.debug("Invalid profile ID pulled from stream index") - return + logger.debug( + f"Stream {stream_id}: no profile found in " + f"stream_profile:{stream_id}" + ) + return False redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association profile_id = int(profile_id) logger.debug( - f"Found profile ID {profile_id} associated with stream {stream_id}" + f"Stream {stream_id}: found profile_id={profile_id}" ) profile_connections_key = f"profile_connections:{profile_id}" @@ -277,6 +284,8 @@ class Stream(models.Model): if current_count > 0: redis_client.decr(profile_connections_key) + return True + class ChannelManager(models.Manager): def active(self): @@ -391,6 +400,38 @@ class Channel(models.Model): return stream_profile + def _check_and_reserve_profile_slot(self, profile, redis_client): + """ + 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. + + Args: + profile: M3UAccountProfile instance + redis_client: Redis client instance + + Returns: + tuple: (reserved: bool, current_count: int) + """ + if profile.max_streams == 0: + return (True, 0) + + profile_connections_key = f"profile_connections:{profile.id}" + + # Atomically increment first — this is a single Redis command + new_count = redis_client.incr(profile_connections_key) + + if new_count <= profile.max_streams: + return (True, new_count) + + # Over capacity — roll back the increment + redis_client.decr(profile_connections_key) + return (False, new_count - 1) + def get_stream(self): """ Finds an available stream for the requested channel and returns the selected stream and profile. @@ -456,24 +497,16 @@ class Channel(models.Model): for profile in profiles: has_active_profiles = True - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int( - redis_client.get(profile_connections_key) or 0 + # Atomically check and reserve a slot (INCR-first pattern) + reserved, current_count = self._check_and_reserve_profile_slot( + profile, redis_client ) - # Check if profile has available slots (or unlimited connections) - if ( - profile.max_streams == 0 - or current_connections < profile.max_streams - ): - # Start a new stream + if reserved: + # Slot reserved — assign stream to this channel redis_client.set(f"channel_stream:{self.id}", stream.id) redis_client.set(f"stream_profile:{stream.id}", profile.id) - # Increment connection count for profiles with limits - if profile.max_streams > 0: - redis_client.incr(profile_connections_key) - return ( stream.id, profile.id, @@ -483,7 +516,8 @@ class Channel(models.Model): # This profile is at max connections has_streams_but_maxed_out = True logger.debug( - f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}" + f"Profile {profile.id} at max connections: " + f"{current_count}/{profile.max_streams}" ) # No available streams - determine specific reason @@ -499,32 +533,93 @@ class Channel(models.Model): def release_stream(self): """ Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no stream/profile info could be found for cleanup. """ redis_client = RedisClient.get_client() stream_id = redis_client.get(f"channel_stream:{self.id}") if not stream_id: - logger.debug("Invalid stream ID pulled from channel index") - return + # Primary key missing — try metadata hash fallback. + # The proxy may have already cleaned up channel_stream/stream_profile + # keys, but the metadata hash can still have the stream_id and profile. + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_stream_id = redis_client.hget( + metadata_key, ChannelMetadataField.STREAM_ID + ) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + + if meta_stream_id and meta_profile_id: + stream_id = int(meta_stream_id) + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered stream_id={stream_id}, " + f"profile_id={profile_id} from metadata fallback" + ) + # Clean up any remaining keys + redis_client.delete(f"channel_stream:{self.id}") + redis_client.delete(f"stream_profile:{stream_id}") + + # Clear metadata fields so duplicate release_stream() calls + # won't find them and DECR again + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + + profile_connections_key = f"profile_connections:{profile_id}" + current_count = int( + redis_client.get(profile_connections_key) or 0 + ) + if current_count > 0: + redis_client.decr(profile_connections_key) + return True + + logger.debug( + f"Channel {self.uuid}: no stream info found in primary keys " + f"or metadata fallback" + ) + return False redis_client.delete(f"channel_stream:{self.id}") # Remove active stream stream_id = int(stream_id) logger.debug( - f"Found stream ID {stream_id} associated with channel stream {self.id}" + f"Channel {self.uuid}: found stream_id={stream_id} for " + f"channel_stream:{self.id}" ) # Get the matched profile for cleanup profile_id = redis_client.get(f"stream_profile:{stream_id}") - if not profile_id: - logger.debug("Invalid profile ID pulled from stream index") - return - - redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association - - profile_id = int(profile_id) + if profile_id: + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + profile_id = int(profile_id) + else: + # stream_profile key missing — try metadata hash fallback + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + if meta_profile_id: + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered profile_id={profile_id} " + f"from metadata fallback (stream_profile:{stream_id} was missing)" + ) + else: + logger.warning( + f"Channel {self.uuid}: no profile found for " + f"stream_profile:{stream_id} or in metadata fallback" + ) + return False logger.debug( - f"Found profile ID {profile_id} associated with stream {stream_id}" + f"Channel {self.uuid}: found profile_id={profile_id} for " + f"stream {stream_id}" ) profile_connections_key = f"profile_connections:{profile_id}" @@ -534,6 +629,18 @@ class Channel(models.Model): if current_count > 0: redis_client.decr(profile_connections_key) + # Clear metadata fields so duplicate release_stream() calls + # (e.g. from _clean_redis_keys or ChannelService.stop_channel) + # won't find them via fallback and DECR again + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + + return True + def update_stream_profile(self, new_profile_id): """ Updates the profile for the current stream and adjusts connection counts. @@ -566,18 +673,18 @@ class Channel(models.Model): if current_profile_id == new_profile_id: return True - # Decrement connection count for old profile + # Use pipeline for atomic profile switch to prevent counter drift + # if an exception occurs between DECR and INCR old_profile_connections_key = f"profile_connections:{current_profile_id}" - old_count = int(redis_client.get(old_profile_connections_key) or 0) - if old_count > 0: - redis_client.decr(old_profile_connections_key) - - # Update the profile mapping - redis_client.set(f"stream_profile:{stream_id}", new_profile_id) - - # Increment connection count for new profile new_profile_connections_key = f"profile_connections:{new_profile_id}" - redis_client.incr(new_profile_connections_key) + old_count = int(redis_client.get(old_profile_connections_key) or 0) + + pipe = redis_client.pipeline() + if old_count > 0: + pipe.decr(old_profile_connections_key) + pipe.set(f"stream_profile:{stream_id}", new_profile_id) + pipe.incr(new_profile_connections_key) + pipe.execute() logger.info( f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}" ) diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 27b361ba..dd2ac158 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -2,14 +2,15 @@ from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete from django.dispatch import receiver -from django.utils.timezone import now +from django.utils.timezone import now, is_aware, make_aware from celery.result import AsyncResult +from django_celery_beat.models import ClockedSchedule, PeriodicTask from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording from apps.m3u.models import M3UAccount from apps.epg.tasks import parse_programs_for_tvg_id -import logging, requests, time +import json +import logging from .tasks import run_recording, prefetch_recording_artwork -from django.utils.timezone import now, is_aware, make_aware from datetime import timedelta logger = logging.getLogger(__name__) @@ -85,18 +86,76 @@ def create_profile_memberships(sender, instance, created, **kwargs): for channel in channels ]) -def schedule_recording_task(instance): - eta = instance.start_time - # 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 +def _dvr_task_name(recording_id): + """Predictable PeriodicTask name for a DVR recording.""" + return f"dvr-recording-{recording_id}" + + +def schedule_recording_task(instance, eta=None): + """Schedule a recording task via ClockedSchedule + one-off PeriodicTask. + + The task is stored in the database and dispatched by Celery Beat at the + scheduled time with no countdown. This avoids the Redis visibility_timeout + redelivery bug that caused duplicate recordings when using apply_async + with long countdowns. + """ + if eta is None: + eta = instance.start_time + if eta is not None and not is_aware(eta): + eta = make_aware(eta) + # Clamp to now so Beat dispatches immediately for past/current start times + if eta <= now(): + eta = now() + + task_args = [ + instance.id, + instance.channel_id, + str(instance.start_time), + str(instance.end_time), + ] + + clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta) + task_name = _dvr_task_name(instance.id) + PeriodicTask.objects.update_or_create( + name=task_name, + defaults={ + "task": "apps.channels.tasks.run_recording", + "clocked": clocked, + "args": json.dumps(task_args), + "one_off": True, + "enabled": True, + "interval": None, + "crontab": None, + "solar": None, + }, ) - return task.id + return task_name + def revoke_task(task_id): - if task_id: + """Cancel a pending recording task. + + task_id is normally a PeriodicTask name (e.g. "dvr-recording-42"). + For backwards compatibility with legacy Celery async-result UUIDs, + falls back to AsyncResult.revoke(). + """ + if not task_id: + return + # Primary path: delete the PeriodicTask and clean up its ClockedSchedule + try: + pt = PeriodicTask.objects.get(name=task_id) + old_clocked = pt.clocked + pt.delete() + if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists(): + old_clocked.delete() + return + except PeriodicTask.DoesNotExist: + pass + # Fallback for legacy Celery task UUIDs + try: AsyncResult(task_id).revoke() + except Exception: + pass @receiver(pre_save, sender=Recording) def revoke_old_task_on_update(sender, instance, **kwargs): @@ -109,6 +168,12 @@ def revoke_old_task_on_update(sender, instance, **kwargs): old.end_time != instance.end_time or old.channel_id != instance.channel_id ): + # Do NOT revoke while the recording is actively streaming. + # run_recording re-reads end_time from the DB every ~2 s and extends + # its internal deadline dynamically — revoking here would kill the task. + old_status = (old.custom_properties or {}).get("status", "") + if old_status == "recording": + return revoke_task(old.task_id) instance.task_id = None except Recording.DoesNotExist: @@ -117,32 +182,50 @@ def revoke_old_task_on_update(sender, instance, **kwargs): @receiver(post_save, sender=Recording) def schedule_task_on_save(sender, instance, created, **kwargs): try: + # Skip processing for internal field-only saves (metadata updates, + # task_id assignment, end_time extensions) to prevent re-entrant + # artwork dispatch and redundant recording_updated WS events. + update_fields = kwargs.get('update_fields') + if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}: + return + if not instance.task_id: start_time = instance.start_time + end_time = instance.end_time - # Make both datetimes aware (in UTC) + # Make datetimes aware (in UTC) if not is_aware(start_time): - print("Start time was not aware, making aware") start_time = make_aware(start_time) + if end_time and not is_aware(end_time): + end_time = make_aware(end_time) current_time = now() - # Debug log - print(f"Start time: {start_time}, Now: {current_time}") - - # 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) + # Future recording — schedule at start_time + logger.info(f"Recording {instance.id}: scheduling task at {start_time}") + task_id = schedule_recording_task(instance, eta=start_time) + instance.task_id = task_id + instance.save(update_fields=['task_id']) + elif end_time and end_time > current_time: + # Currently-playing — start immediately (e.g. series rule for in-progress program) + logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately") + task_id = schedule_recording_task(instance, eta=current_time) instance.task_id = task_id instance.save(update_fields=['task_id']) else: - print("Start time is in the past. Not scheduling.") - # Kick off poster/artwork prefetch to enrich Upcoming cards - try: - prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1) - except Exception as e: - print("Error scheduling artwork prefetch:", e) + logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling") + # Kick off poster/artwork prefetch to enrich Upcoming cards. + # Skip when the recording is already active or finished — run_recording + # handles its own poster resolution, and scheduling artwork prefetch + # while the task is running causes a race that can overwrite status. + cp = instance.custom_properties or {} + rec_status = cp.get("status", "") + if rec_status not in ("recording", "completed", "stopped", "interrupted"): + try: + prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1) + except Exception as e: + print("Error scheduling artwork prefetch:", e) except Exception as e: import traceback print("Error in post_save signal:", e) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 8d49287b..06cd45cf 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -20,16 +20,149 @@ from apps.channels.models import Channel from apps.epg.models import EPGData from core.models import CoreSettings +from django.db import OperationalError, close_old_connections from channels.layers import get_channel_layer from asgiref.sync import async_to_sync - -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer import tempfile from urllib.parse import quote logger = logging.getLogger(__name__) + +_url_validation_cache = {} +_URL_CACHE_TTL = 300 # seconds + + +def _validate_url(url, timeout=4): + """Validate that an HTTP(S) URL is reachable via HEAD request. + Returns True for non-HTTP URLs (skip validation) or 2xx/3xx responses. + Results are cached per-worker for 5 minutes to avoid redundant requests + when multiple recordings reference the same dead URL. + """ + if not url or not isinstance(url, str): + return False + if not url.startswith(("http://", "https://")): + return True + + now = time.monotonic() + cached = _url_validation_cache.get(url) + if cached is not None and (now - cached[1]) < _URL_CACHE_TTL: + return cached[0] + + try: + resp = requests.head(url, timeout=timeout, allow_redirects=True) + if resp.status_code == 405: + # Server doesn't support HEAD; fall back to ranged GET + resp = requests.get( + url, timeout=timeout, allow_redirects=True, + headers={"Range": "bytes=0-0"}, stream=True, + ) + resp.close() + result = resp.status_code < 400 + except Exception: + result = False + + _url_validation_cache[url] = (result, now) + + # Evict expired entries when cache grows large + if len(_url_validation_cache) > 512: + cutoff = now - _URL_CACHE_TTL + expired = [k for k, v in _url_validation_cache.items() if v[1] < cutoff] + for k in expired: + del _url_validation_cache[k] + + return result + + +def _pick_best_image_from_epg_props(epg_props): + """Select the highest-quality poster/cover image from EPG custom_properties.""" + try: + images = epg_props.get("images") or [] + if not isinstance(images, list): + return None + size_order = {"xxl": 6, "xl": 5, "l": 4, "m": 3, "s": 2, "xs": 1} + def score(img): + t = (img.get("type") or "").lower() + size = (img.get("size") or "").lower() + return (2 if t in ("poster", "cover") else 1, size_order.get(size, 0)) + best = None + for im in images: + if not isinstance(im, dict): + continue + url = im.get("url") + if not url: + continue + if best is None or score(im) > score(best): + best = im + return best.get("url") if best else None + except Exception: + return None + + +def _match_epg_program_by_timeslot(channel_epg_data, rec_start, rec_end): + """Find an EPG program that covers at least 80% of the recording window. + + Queries all programs overlapping the recording, calculates overlap for + each, and returns the best match only if it covers >= 80% of the + recording duration. Recordings spanning multiple programs with no + dominant show return None (displayed as "Custom Recording"). + Returns a dict with id, title, sub_title, and description, or None. + """ + if not channel_epg_data or not rec_start or not rec_end: + return None + try: + candidates = channel_epg_data.programs.filter( + start_time__lt=rec_end, + end_time__gt=rec_start, + ).only("id", "title", "sub_title", "description", "start_time", "end_time") + + rec_duration = (rec_end - rec_start).total_seconds() + if rec_duration <= 0: + return None + + best = None + best_overlap = 0 + for prog in candidates: + overlap_start = max(rec_start, prog.start_time) + overlap_end = min(rec_end, prog.end_time) + overlap = (overlap_end - overlap_start).total_seconds() + if overlap > best_overlap: + best_overlap = overlap + best = prog + + if best and (best_overlap / rec_duration) >= 0.8: + return { + "id": best.id, + "title": best.title or "", + "sub_title": best.sub_title or "", + "description": best.description or "", + } + except Exception: + pass + return None + + +def _db_retry(fn, max_retries=3, base_interval=1, label="DB operation"): + """Execute fn() with exponential backoff retry on transient DB errors. + + Follows the same backoff pattern as RedisClient.get_client(). + Resets stale connections between attempts so the ORM reconnects. + """ + for attempt in range(max_retries): + try: + return fn() + except OperationalError: + if attempt + 1 >= max_retries: + raise + wait = base_interval * (2 ** attempt) + logger.warning( + f"{label}: failed, retrying in {wait}s " + f"({attempt + 1}/{max_retries})..." + ) + close_old_connections() + time.sleep(wait) + + # PostgreSQL btree index has a limit of ~2704 bytes (1/3 of 8KB page size) # We use 2000 as a safe maximum to account for multibyte characters def validate_logo_url(logo_url, max_length=2000): @@ -983,7 +1116,7 @@ def evaluate_series_rules_impl(tvg_id: str | None = None): programs_qs = ProgramData.objects.filter( epg=epg, - start_time__gte=now, + end_time__gt=now, start_time__lte=horizon, ) if series_title: @@ -993,7 +1126,7 @@ def evaluate_series_rules_impl(tvg_id: str | None = None): if series_title and not programs: all_progs = ProgramData.objects.filter( epg=epg, - start_time__gte=now, + end_time__gt=now, start_time__lte=horizon, ).only("id", "title", "start_time", "end_time", "custom_properties", "tvg_id") programs = [p for p in all_progs if normalize_name(p.title) == norm_series] @@ -1271,14 +1404,15 @@ def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_d except Exception: logger.warning("Invalid or unsupported time zone '%s'; falling back to Server default", tz_name) tz = timezone.get_current_timezone() - start_limit = rule.start_date or now.date() + local_today = now.astimezone(tz).date() + start_limit = rule.start_date or local_today end_limit = rule.end_date horizon = now + timedelta(days=horizon_days) - start_window = max(start_limit, now.date()) + start_window = max(start_limit, local_today) if drop_existing and end_limit: end_window = end_limit else: - end_window = horizon.date() + end_window = horizon.astimezone(tz).date() if end_limit and end_limit < end_window: end_window = end_limit if end_window < start_window: @@ -1477,6 +1611,28 @@ def _build_output_paths(channel, program, start_time, end_time): rel_path = rel_path[2:] final_path = rel_path if rel_path.startswith('/') else os.path.join(library_root, rel_path) final_path = os.path.normpath(final_path) + + # Avoid overwriting an existing file from a different recording. + # Check BOTH .mkv and .ts — a pre-restart TS segment may exist at + # the same base name even when the MKV is a 0-byte placeholder. + base, ext = os.path.splitext(final_path) + counter = 1 + while True: + candidate_base = final_path[:-len(ext)] # strip extension + ts_candidate = candidate_base + '.ts' + try: + mkv_occupied = os.stat(final_path).st_size > 0 + except OSError: + mkv_occupied = False + try: + ts_occupied = os.stat(ts_candidate).st_size > 0 + except OSError: + ts_occupied = False + if not mkv_occupied and not ts_occupied: + break + counter += 1 + final_path = f"{base}_{counter}{ext}" + # Ensure directory exists os.makedirs(os.path.dirname(final_path), exist_ok=True) @@ -1486,6 +1642,33 @@ def _build_output_paths(channel, program, start_time, end_time): return final_path, temp_ts_path, os.path.basename(final_path) +def build_dvr_candidates(): + """Build ordered list of candidate base URLs for DVR TS streaming. + + Reads environment variables to determine which URLs to try: + - DISPATCHARR_INTERNAL_TS_BASE_URL: explicit override (first priority) + - DISPATCHARR_PORT: the external port (default 9191) + - DISPATCHARR_ENV/DISPATCHARR_DEBUG/REDIS_HOST: dev-mode detection + - DISPATCHARR_INTERNAL_API_BASE: override for the docker service URL + """ + explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') + dispatcharr_port = os.environ.get('DISPATCHARR_PORT', '9191') + is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ + (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ + (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) + candidates = [] + if explicit: + candidates.append(explicit) + if is_dev: + # Debug container typically exposes API on 5656 (uwsgi internal port) + candidates.extend(['http://127.0.0.1:5656', f'http://127.0.0.1:{dispatcharr_port}']) + # Docker service name fallback — use DISPATCHARR_PORT so modular mode works with custom ports + candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', f'http://web:{dispatcharr_port}')) + # Last-resort localhost ports + candidates.extend(['http://localhost:5656', f'http://localhost:{dispatcharr_port}']) + return candidates + + @shared_task def run_recording(recording_id, channel_id, start_time_str, end_time_str): """ @@ -1497,14 +1680,46 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): - Attempts to capture stream stats from TS proxy (codec, resolution, fps, etc.) - Attempts to capture a poster (via program.custom_properties) and store a Logo reference """ + from .models import Recording, Logo + + # --- Idempotency guard (prevents duplicate recordings from task redelivery) --- + # Fail closed: if the DB is unreachable, abort rather than risk a duplicate + # task overwriting a valid recording. + try: + rec_check = Recording.objects.filter(id=recording_id).only("custom_properties").first() + if not rec_check: + logger.info( + f"run_recording called for recording {recording_id} but it no longer exists — skipping." + ) + return + status = (rec_check.custom_properties or {}).get("status", "") + if status in ("recording", "completed", "stopped"): + logger.warning( + f"run_recording called for recording {recording_id} but status " + f"is already '{status}' - skipping duplicate execution." + ) + return + except Exception as e: + logger.error( + f"Idempotency guard DB check failed for recording {recording_id} " + f"({type(e).__name__}: {e}) — aborting to prevent potential duplicate." + ) + return + + # --- Clean up the one-off PeriodicTask that dispatched this task --- + try: + from apps.channels.signals import revoke_task, _dvr_task_name + revoke_task(_dvr_task_name(recording_id)) + except Exception as e: + logger.debug(f"PeriodicTask cleanup failed (non-fatal): {e}") + channel = Channel.objects.get(id=channel_id) start_time = datetime.fromisoformat(start_time_str) end_time = datetime.fromisoformat(end_time_str) duration_seconds = int((end_time - start_time).total_seconds()) - # Build output paths from templates - # We need program info; will refine after we load Recording cp below + # Build output paths from templates (refined after loading Recording cp below) filename = None final_path = None temp_ts_path = None @@ -1536,8 +1751,17 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # Try to resolve the Recording row up front recording_obj = None try: - from .models import Recording, Logo recording_obj = Recording.objects.get(id=recording_id) + # If the stop endpoint already wrote "stopped" before the task started, + # honor it instead of overwriting with "recording". + _pre_cp = recording_obj.custom_properties or {} + if _pre_cp.get("status") == "stopped": + logger.info( + f"run_recording {recording_id}: 'stopped' found in DB before stream started " + f"— task exits without connecting." + ) + return + # Prime custom_properties with file info/status cp = recording_obj.custom_properties or {} cp.update({ @@ -1550,223 +1774,26 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # Determine program info (may include id for deeper details) program = cp.get("program") or {} + + # Enrich empty program dicts (manual recordings) from EPG time-slot data. + if isinstance(program, dict) and not program.get("user_edited") and not program.get("id") and not program.get("title"): + epg_match = _match_epg_program_by_timeslot( + channel.epg_data, recording_obj.start_time, recording_obj.end_time, + ) + if epg_match: + program.update(epg_match) + cp["program"] = program + final_path, temp_ts_path, filename = _build_output_paths(channel, program, start_time, end_time) cp["file_name"] = filename cp["file_path"] = final_path cp["_temp_file_path"] = temp_ts_path - # Resolve poster the same way VODs do: - # 1) Prefer image(s) from EPG Program custom_properties (images/icon) - # 2) Otherwise reuse an existing VOD logo matching title (Movie/Series) - # 3) Otherwise save any direct poster URL from provided program fields - program = (cp.get("program") or {}) if isinstance(cp, dict) else {} - - def pick_best_image_from_epg_props(epg_props): - try: - images = epg_props.get("images") or [] - if not isinstance(images, list): - return None - # Prefer poster/cover and larger sizes - size_order = {"xxl": 6, "xl": 5, "l": 4, "m": 3, "s": 2, "xs": 1} - def score(img): - t = (img.get("type") or "").lower() - size = (img.get("size") or "").lower() - return ( - 2 if t in ("poster", "cover") else 1, - size_order.get(size, 0) - ) - best = None - for im in images: - if not isinstance(im, dict): - continue - url = im.get("url") - if not url: - continue - if best is None or score(im) > score(best): - best = im - return best.get("url") if best else None - except Exception: - return None - - poster_logo_id = None - poster_url = None - - # Try EPG Program custom_properties by ID - try: - from apps.epg.models import ProgramData - prog_id = program.get("id") - if prog_id: - epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() - if epg_program and epg_program.custom_properties: - epg_props = epg_program.custom_properties or {} - poster_url = pick_best_image_from_epg_props(epg_props) - if not poster_url: - icon = epg_props.get("icon") - if isinstance(icon, str) and icon: - poster_url = icon - except Exception as e: - logger.debug(f"EPG image lookup failed: {e}") - - # Fallback: reuse VOD Logo by matching title - if not poster_url and not poster_logo_id: - try: - from apps.vod.models import Movie, Series - title = program.get("title") or channel.name - vod_logo = None - movie = Movie.objects.filter(name__iexact=title).select_related("logo").first() - if movie and movie.logo: - vod_logo = movie.logo - if not vod_logo: - series = Series.objects.filter(name__iexact=title).select_related("logo").first() - if series and series.logo: - vod_logo = series.logo - if vod_logo: - poster_logo_id = vod_logo.id - except Exception as e: - logger.debug(f"VOD logo fallback failed: {e}") - - # External metadata lookups (TMDB/OMDb) when EPG/VOD didn't provide an image - if not poster_url and not poster_logo_id: - try: - tmdb_key = os.environ.get('TMDB_API_KEY') - omdb_key = os.environ.get('OMDB_API_KEY') - title = (program.get('title') or channel.name or '').strip() - year = None - imdb_id = None - - # Try to derive year and imdb from EPG program custom_properties - try: - from apps.epg.models import ProgramData - prog_id = program.get('id') - epg_program = ProgramData.objects.filter(id=prog_id).only('custom_properties').first() if prog_id else None - if epg_program and epg_program.custom_properties: - d = epg_program.custom_properties.get('date') - if d and len(str(d)) >= 4: - year = str(d)[:4] - imdb_id = epg_program.custom_properties.get('imdb.com_id') or imdb_id - except Exception: - pass - - # TMDB: by IMDb ID - if not poster_url and tmdb_key and imdb_id: - try: - url = f"https://api.themoviedb.org/3/find/{quote(imdb_id)}?api_key={tmdb_key}&external_source=imdb_id" - resp = requests.get(url, timeout=5) - if resp.ok: - data = resp.json() or {} - picks = [] - for k in ('movie_results', 'tv_results', 'tv_episode_results', 'tv_season_results'): - lst = data.get(k) or [] - picks.extend(lst) - poster_path = None - for item in picks: - if item.get('poster_path'): - poster_path = item['poster_path'] - break - if poster_path: - poster_url = f"https://image.tmdb.org/t/p/w780{poster_path}" - except Exception: - pass - - # TMDB: by title (and year if available) - if not poster_url and tmdb_key and title: - try: - q = quote(title) - extra = f"&year={year}" if year else "" - url = f"https://api.themoviedb.org/3/search/multi?api_key={tmdb_key}&query={q}{extra}" - resp = requests.get(url, timeout=5) - if resp.ok: - data = resp.json() or {} - results = data.get('results') or [] - results.sort(key=lambda x: float(x.get('popularity') or 0), reverse=True) - for item in results: - if item.get('poster_path'): - poster_url = f"https://image.tmdb.org/t/p/w780{item['poster_path']}" - break - except Exception: - pass - - # OMDb fallback - if not poster_url and omdb_key: - try: - if imdb_id: - url = f"https://www.omdbapi.com/?apikey={omdb_key}&i={quote(imdb_id)}" - elif title: - yy = f"&y={year}" if year else "" - url = f"https://www.omdbapi.com/?apikey={omdb_key}&t={quote(title)}{yy}" - else: - url = None - if url: - resp = requests.get(url, timeout=5) - if resp.ok: - data = resp.json() or {} - p = data.get('Poster') - if p and p != 'N/A': - poster_url = p - except Exception: - pass - except Exception as e: - logger.debug(f"External poster lookup failed: {e}") - - # Keyless fallback providers (no API keys required) - if not poster_url and not poster_logo_id: - try: - title = (program.get('title') or channel.name or '').strip() - if title: - # 1) TVMaze (TV shows) - singlesearch by title - try: - url = f"https://api.tvmaze.com/singlesearch/shows?q={quote(title)}" - resp = requests.get(url, timeout=5) - if resp.ok: - data = resp.json() or {} - img = (data.get('image') or {}) - p = img.get('original') or img.get('medium') - if p: - poster_url = p - except Exception: - pass - - # 2) iTunes Search API (movies or tv shows) - if not poster_url: - try: - for media in ('movie', 'tvShow'): - url = f"https://itunes.apple.com/search?term={quote(title)}&media={media}&limit=1" - resp = requests.get(url, timeout=5) - if resp.ok: - data = resp.json() or {} - results = data.get('results') or [] - if results: - art = results[0].get('artworkUrl100') - if art: - # Scale up to 600x600 by convention - poster_url = art.replace('100x100', '600x600') - break - except Exception: - pass - except Exception as e: - logger.debug(f"Keyless poster lookup failed: {e}") - - # Last: check direct fields on provided program object - if not poster_url and not poster_logo_id: - for key in ("poster", "cover", "cover_big", "image", "icon"): - val = program.get(key) - if isinstance(val, dict): - candidate = val.get("url") - if candidate: - poster_url = candidate - break - elif isinstance(val, str) and val: - poster_url = val - break - - # Create or assign Logo - if not poster_logo_id and poster_url and len(poster_url) <= 1000: - try: - logo, _ = Logo.objects.get_or_create(url=poster_url, defaults={"name": program.get("title") or channel.name}) - poster_logo_id = logo.id - except Exception as e: - logger.debug(f"Unable to persist poster to Logo: {e}") - + # Resolve poster art via the shared pipeline (EPG → VOD → TMDB/OMDb → + # TVMaze/iTunes → direct program fields → Logo table → channel logo). + poster_logo_id, poster_url = _resolve_poster_for_program( + channel.name, program, channel_logo_id=channel.logo_id, + ) if poster_logo_id: cp["poster_logo_id"] = poster_logo_id if poster_url and "poster_url" not in cp: @@ -1780,8 +1807,38 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): except Exception: pass - recording_obj.custom_properties = cp + # Re-read from DB to preserve concurrent changes (e.g., artwork + # prefetch may have saved poster/rating info while resolving). + recording_obj.refresh_from_db() + fresh_cp = recording_obj.custom_properties or {} + + # If the stop endpoint set "stopped" while resolving, honor it. + if fresh_cp.get("status") == "stopped": + logger.info( + f"run_recording {recording_id}: 'stopped' found after metadata " + f"prep — task exits without streaming." + ) + return + + # Merge only the keys explicitly set into the fresh copy + for key in ("status", "started_at", "file_url", "output_file_url", + "file_name", "file_path", "_temp_file_path", + "program", "poster_logo_id", "poster_url"): + if key in cp: + fresh_cp[key] = cp[key] + recording_obj.custom_properties = fresh_cp recording_obj.save(update_fields=["custom_properties"]) + + # Notify frontends so the tile picks up poster/metadata immediately + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_updated", + "recording_id": recording_id, + }) + except Exception: + pass except Exception as e: logger.debug(f"Unable to prime Recording metadata: {e}") interrupted = False @@ -1790,22 +1847,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): from requests.exceptions import ReadTimeout, ConnectionError as ReqConnectionError, ChunkedEncodingError - # Determine internal base URL(s) for TS streaming - # Prefer explicit override, then try common ports for debug and docker - explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') - is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ - (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ - (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) - candidates = [] - if explicit: - candidates.append(explicit) - if is_dev: - # Debug container typically exposes API on 5656 - candidates.extend(['http://127.0.0.1:5656', 'http://127.0.0.1:9191']) - # Docker service name fallback - candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', 'http://web:9191')) - # Last-resort localhost ports - candidates.extend(['http://localhost:5656', 'http://localhost:9191']) + candidates = build_dvr_candidates() chosen_base = None last_error = None @@ -1813,118 +1855,287 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): interrupted = False interrupted_reason = None - # We'll attempt each base until we receive some data - for base in candidates: + def _check_recording_cancelled(rid): + """Check if a recording was stopped by user or deleted. + + Returns (should_exit, is_interrupted, reason) where should_exit + indicates the stream loop must terminate. + """ try: - test_url = f"{base.rstrip('/')}/proxy/ts/stream/{channel.uuid}" - logger.info(f"DVR: trying TS base {base} -> {test_url}") + rec = Recording.objects.filter(id=rid).only("custom_properties").first() + if rec is None: + return True, True, "recording_deleted" + if (rec.custom_properties or {}).get("status") == "stopped": + return True, False, "stopped_by_user" + except Exception: + pass + return False, False, None - with requests.get( - test_url, - headers={ - 'User-Agent': 'Dispatcharr-DVR', - }, - stream=True, - timeout=(10, 15), - ) as response: - response.raise_for_status() + # --- Retry / reconnection constants --- + # Stream reconnection: retry the same TS proxy base on transient + # connectivity loss. Counter resets when data resumes. + _dvr_max_reconnects = 5 + _dvr_reconnect_delay = 2.0 # seconds + # DB save retry: exponential backoff (1s, 2s, 4s) for transient errors. + _dvr_db_max_retries = 3 + _dvr_db_retry_interval = 1 # seconds (base for exponential backoff) + # FFmpeg remux retry: covers transient I/O errors. + _dvr_remux_max_retries = 2 + _dvr_remux_retry_interval = 2 # seconds (base for exponential backoff) - # Open the file and start copying; if we get any data within a short window, accept this base - got_any_data = False - test_window = 3.0 # seconds to detect first bytes - window_start = time.time() + for base in candidates: + test_url = f"{base.rstrip('/')}/proxy/ts/stream/{channel.uuid}" + logger.info(f"DVR recording {recording_id}: trying TS base {base}") - with open(temp_ts_path, 'wb') as file: - started_at = time.time() - for chunk in response.iter_content(chunk_size=8192): - if not chunk: - # keep-alives may be empty; continue - if not got_any_data and (time.time() - window_start) > test_window: - break - continue - # We have data - got_any_data = True - chosen_base = base - # Fall through to full recording loop using this same response/connection - file.write(chunk) - bytes_written += len(chunk) - elapsed = time.time() - started_at - if elapsed > duration_seconds: - break - # Continue draining the stream - for chunk2 in response.iter_content(chunk_size=8192): - if not chunk2: + _reconnects = 0 + _file_mode = 'wb' + _stream_started_at = None + _done = False + + while True: # Reconnection loop for this base + try: + with requests.get( + test_url, + headers={ + 'User-Agent': f'Dispatcharr-DVR/recording-{recording_id}', + }, + stream=True, + timeout=(10, 15), + ) as response: + response.raise_for_status() + + _test_window = 3.0 + _window_start = time.time() + _stop_poll_interval = 2.0 + _last_stop_poll = time.time() + + with open(temp_ts_path, _file_mode) as file: + if _stream_started_at is None: + _stream_started_at = time.time() + + for chunk in response.iter_content(chunk_size=8192): + if not chunk: + if not chosen_base and (time.time() - _window_start) > _test_window: + break continue - file.write(chunk2) - bytes_written += len(chunk2) - elapsed = time.time() - started_at + + if not chosen_base: + chosen_base = base + + # Data received after reconnect — connection restored + if _reconnects > 0: + logger.info( + f"DVR recording {recording_id}: " + f"stream resumed after reconnect" + ) + _reconnects = 0 + + file.write(chunk) + bytes_written += len(chunk) + + elapsed = time.time() - _stream_started_at if elapsed > duration_seconds: break - break # exit outer for-loop once we switched to full drain - # If we wrote any bytes, treat as success and stop trying candidates + # Periodic DB poll: stop, delete, end_time extension + _now = time.time() + if _now - _last_stop_poll >= _stop_poll_interval: + _last_stop_poll = _now + try: + _sc = Recording.objects.filter( + id=recording_id + ).only("custom_properties", "end_time").first() + if _sc is None: + logger.info( + f"DVR recording {recording_id}: " + f"deleted — exiting stream loop" + ) + interrupted = False + break + if (_sc.custom_properties or {}).get("status") == "stopped": + logger.info( + f"DVR recording {recording_id}: " + f"stop requested — exiting stream loop" + ) + break + try: + new_end = _sc.end_time + if new_end is not None: + from django.utils import timezone as _tz + if _tz.is_naive(new_end): + new_end = _tz.make_aware(new_end) + _ref = start_time + if _tz.is_naive(_ref): + _ref = _tz.make_aware(_ref) + new_duration = int( + (new_end - _ref).total_seconds() + ) + if new_duration > duration_seconds: + logger.info( + f"DVR recording {recording_id}: " + f"end_time extended to {new_end}, " + f"new duration {new_duration}s" + ) + duration_seconds = new_duration + except Exception: + pass + except Exception: + pass + + # iter_content exhausted or loop exited normally + if bytes_written > 0: + logger.info( + f"DVR recording {recording_id}: " + f"stream complete, {bytes_written} bytes written" + ) + _done = True + else: + last_error = f"no_data_from_{base}" + logger.warning( + f"DVR recording {recording_id}: no data from " + f"{base} within {_test_window}s, trying next base" + ) + try: + if os.path.exists(temp_ts_path) and os.path.getsize(temp_ts_path) == 0: + os.remove(temp_ts_path) + except FileNotFoundError: + pass + break # Exit reconnection loop + + except (ReadTimeout, ReqConnectionError, ChunkedEncodingError) as e: if bytes_written > 0: - logger.info(f"DVR: selected TS base {base}; wrote initial {bytes_written} bytes") + # Active stream lost — check cancellation before reconnecting + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + if reason == "stopped_by_user": + logger.info( + f"DVR recording {recording_id}: " + f"stopped by user — ending stream" + ) + _done = True + break + + _reconnects += 1 + if _reconnects <= _dvr_max_reconnects: + logger.warning( + f"DVR recording {recording_id}: connection lost " + f"({type(e).__name__}), reconnecting " + f"({_reconnects}/{_dvr_max_reconnects}) " + f"in {_dvr_reconnect_delay}s..." + ) + time.sleep(_dvr_reconnect_delay) + _file_mode = 'ab' + continue + + logger.error( + f"DVR recording {recording_id}: max reconnects " + f"({_dvr_max_reconnects}) exceeded — ending recording" + ) + interrupted = True + interrupted_reason = ( + f"stream_interrupted: max reconnects exceeded ({e})" + ) + _done = True break - else: - last_error = f"no_data_from_{base}" - logger.warning(f"DVR: no data received from {base} within {test_window}s, trying next base") - # Clean up empty temp file - try: - if os.path.exists(temp_ts_path) and os.path.getsize(temp_ts_path) == 0: - os.remove(temp_ts_path) - except Exception: - pass - except Exception as e: - last_error = str(e) - logger.warning(f"DVR: attempt failed for base {base}: {e}") + + # No data received yet — retry same base before moving on + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + _done = True + break + _reconnects += 1 + if _reconnects <= _dvr_max_reconnects: + logger.warning( + f"DVR recording {recording_id}: initial connection " + f"to {base} failed ({type(e).__name__}), retrying " + f"({_reconnects}/{_dvr_max_reconnects}) " + f"in {_dvr_reconnect_delay}s..." + ) + time.sleep(_dvr_reconnect_delay) + continue + last_error = str(e) + logger.warning( + f"DVR recording {recording_id}: base {base} exhausted " + f"retries ({_dvr_max_reconnects}): {e}" + ) + break + + except Exception as e: + last_error = str(e) + logger.warning(f"DVR recording {recording_id}: base {base} failed: {e}") + if bytes_written > 0: + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit and reason == "stopped_by_user": + interrupted = False + logger.info( + f"DVR recording {recording_id}: " + f"stopped by user — ending stream" + ) + else: + interrupted = True + interrupted_reason = f"stream_interrupted: {e}" + _done = True + break + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + _done = True + break + + if _done: + break if chosen_base is None and bytes_written == 0: interrupted = True interrupted_reason = f"no_stream_data: {last_error or 'all_bases_failed'}" - else: - # If we ended before reaching planned duration, record reason - actual_elapsed = 0 + + # If no bytes were written at all, check whether this was a deliberate stop or a + # genuine failure. The exception handler above already sets interrupted=False when + # it detects "stopped" status, but do not override that decision here. + if bytes_written == 0 and not interrupted: + _deliberately_stopped = False try: - actual_elapsed = os.path.getsize(temp_ts_path) and (duration_seconds) # Best effort; we streamed until duration or disconnect above + _rc = Recording.objects.filter(id=recording_id).only("custom_properties").first() + if _rc and (_rc.custom_properties or {}).get("status") == "stopped": + _deliberately_stopped = True except Exception: pass - # We cannot compute accurate elapsed here; fine to leave as is - pass - # If no bytes were written at all, mark detail - if bytes_written == 0 and not interrupted: - interrupted = True - interrupted_reason = f"no_stream_data: {last_error or 'unknown'}" + if not _deliberately_stopped: + interrupted = True + interrupted_reason = f"no_stream_data: {last_error or 'unknown'}" - # Update DB status immediately so the UI reflects the change on the event below - try: - if recording_obj is None: - from .models import Recording - recording_obj = Recording.objects.get(id=recording_id) - cp_now = recording_obj.custom_properties or {} - cp_now.update({ - "status": "interrupted" if interrupted else "completed", - "ended_at": str(datetime.now()), - "file_name": filename or cp_now.get("file_name"), - "file_path": final_path or cp_now.get("file_path"), - }) - if interrupted and interrupted_reason: - cp_now["interrupted_reason"] = interrupted_reason - recording_obj.custom_properties = cp_now - recording_obj.save(update_fields=["custom_properties"]) - except Exception as e: - logger.debug(f"Failed to update immediate recording status: {e}") + # Update DB status immediately so the UI reflects the change on the event below + try: + if recording_obj is None: + recording_obj = Recording.objects.get(id=recording_id) + cp_now = recording_obj.custom_properties or {} + cp_now.update({ + "status": "interrupted", + "ended_at": str(datetime.now()), + "file_name": filename or cp_now.get("file_name"), + "file_path": final_path or cp_now.get("file_path"), + "interrupted_reason": interrupted_reason, + }) + recording_obj.custom_properties = cp_now + recording_obj.save(update_fields=["custom_properties"]) + except Exception as e: + logger.debug(f"Failed to update immediate recording status: {e}") - async_to_sync(channel_layer.group_send)( - "updates", - { - "type": "update", - "data": {"success": True, "type": "recording_ended", "channel": channel.name} - }, - ) - # After the loop, the file and response are closed automatically. - logger.info(f"Finished recording for channel {channel.name}") + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_ended", "channel": channel.name} + }, + ) + # After the loop, the file and response are closed automatically. + logger.info(f"Finished recording for channel {channel.name}") # Log system event for recording end try: @@ -1940,118 +2151,293 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): except Exception as e: logger.error(f"Could not log recording end event: {e}") - # Remux TS to MKV container - remux_success = False + # If the Recording was deleted (cancelled by user), skip post-processing + recording_cancelled = not Recording.objects.filter(id=recording_id).exists() + if recording_cancelled: + logger.info(f"Recording {recording_id} was cancelled — skipping remux and metadata.") + # Clean up all artifacts for the cancelled recording, + # including any pre-restart .ts segments from server recovery. + # Use the in-memory recording_obj since the DB row is already deleted. + _cancel_cleanup = [temp_ts_path, final_path] + _cancel_cp = (recording_obj.custom_properties or {}) if recording_obj else {} + _cancel_cleanup.extend(_cancel_cp.get("_pre_restart_ts_paths", [])) + for _cleanup_path in _cancel_cleanup: + if not _cleanup_path: + continue + try: + os.remove(_cleanup_path) + logger.info(f"Cleaned up cancelled recording artifact: {_cleanup_path}") + except FileNotFoundError: + pass + except Exception: + pass + return + + # Concatenate pre-restart .ts segments with the current segment. + # Instead of creating an intermediate combined.ts and then remuxing to + # MKV (which loses timestamp boundary info and causes playback freezes + # at the splice point), go directly from the concat list → MKV. + # This lets ffmpeg's MKV muxer see each segment boundary and write + # correct cue points / clusters for seamless seeking. + _concat_did_remux = False try: - if temp_ts_path and os.path.exists(temp_ts_path): - # First attempt: Direct TS to MKV remux - result = subprocess.run([ - "ffmpeg", "-y", - "-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS - "-err_detect", "ignore_err", # Ignore minor stream errors - "-i", temp_ts_path, - "-map", "0", # Map all streams - "-c", "copy", - final_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + _rec_obj_for_concat = Recording.objects.filter(id=recording_id).only("custom_properties").first() + _concat_cp = (_rec_obj_for_concat.custom_properties or {}) if _rec_obj_for_concat else {} + pre_restart_segments = _concat_cp.get("_pre_restart_ts_paths", []) + # Filter to segments that still exist on disk and have data + def _has_data(p): + try: + return os.stat(p).st_size > 0 + except OSError: + return False + pre_restart_segments = [p for p in pre_restart_segments if p and _has_data(p)] + if pre_restart_segments and temp_ts_path and os.path.exists(temp_ts_path): + all_segments = pre_restart_segments + [temp_ts_path] + concat_list_path = temp_ts_path + ".concat.txt" + try: + with open(concat_list_path, "w") as cl: + for seg in all_segments: + cl.write(f"file '{seg}'\n") - # Check if FFmpeg succeeded (return code 0) and output file is valid - if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: - remux_success = True - logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}") - else: - # Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps - logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV") - - # Clean up partial/failed MKV - try: - if os.path.exists(final_path): - os.remove(final_path) - except Exception: - pass - - # Step 1: TS → MP4 (MP4 container handles broken timestamps better) - temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4" - result_mp4 = subprocess.run([ - "ffmpeg", "-y", - "-fflags", "+genpts+igndts+discardcorrupt", - "-err_detect", "ignore_err", - "-i", temp_ts_path, - "-map", "0", - "-c", "copy", - temp_mp4_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0: - logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV") - - # Step 2: MP4 → MKV (clean timestamps from MP4) - result_mkv = subprocess.run([ + # Direct concat → MKV in a single pass. + # -reset_timestamps 1 tells the concat demuxer to reset + # timestamps at each segment boundary, eliminating the + # discontinuity that causes playback to freeze at the + # splice point. + concat_result = subprocess.run( + [ "ffmpeg", "-y", - "-i", temp_mp4_path, + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-f", "concat", "-safe", "0", + "-segment_time_metadata", "1", + "-i", concat_list_path, + "-reset_timestamps", "1", "-map", "0", "-c", "copy", - final_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + final_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if concat_result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + _concat_did_remux = True + # Clean up individual TS segments (including current) + for seg in all_segments: + try: + os.remove(seg) + except OSError: + pass + logger.info( + f"DVR recording {recording_id}: concat→MKV succeeded — " + f"{len(all_segments)} segments → {os.path.basename(final_path)} " + f"({os.path.getsize(final_path):,} bytes)" + ) + else: + logger.warning( + f"DVR recording {recording_id}: direct concat→MKV failed " + f"(rc={concat_result.returncode}), falling back to " + f"normal remux with current segment only. " + f"stderr: {(concat_result.stderr or '')[:500]}" + ) + finally: + try: + os.remove(concat_list_path) + except OSError: + pass + # Clear the pre-restart paths from custom_properties + if _rec_obj_for_concat: + _ccp = _rec_obj_for_concat.custom_properties or {} + _ccp.pop("_pre_restart_ts_paths", None) + _ccp.pop("interrupted_reason", None) + _rec_obj_for_concat.custom_properties = _ccp + _rec_obj_for_concat.save(update_fields=["custom_properties"]) + except Exception as e: + logger.warning( + f"DVR recording {recording_id}: segment concatenation error " + f"({type(e).__name__}: {e}), proceeding with current segment only." + ) - if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: - remux_success = True - logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}") - else: - logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})") + # Remux TS to MKV container with retry for transient I/O errors + # (Skip if concat already produced the final MKV directly.) + remux_success = _concat_did_remux + existing_mkv_size = 0 + try: + if final_path and os.path.exists(final_path): + existing_mkv_size = os.path.getsize(final_path) + except OSError: + pass + for _remux_attempt in range(_dvr_remux_max_retries): + if remux_success: + break + try: + if temp_ts_path and os.path.exists(temp_ts_path): + # First attempt: Direct TS to MKV remux + result = subprocess.run([ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS + "-err_detect", "ignore_err", # Ignore minor stream errors + "-i", temp_ts_path, + "-map", "0", # Map all streams + "-c", "copy", + final_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - # Clean up temp MP4 + # Check if FFmpeg succeeded (return code 0) and output file is valid + if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + remux_success = True + logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}") + else: + # Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps + logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV") + + # Clean up partial/failed MKV try: - if os.path.exists(temp_mp4_path): - os.remove(temp_mp4_path) + if os.path.exists(final_path): + os.remove(final_path) except Exception: pass + + # Step 1: TS → MP4 (MP4 container handles broken timestamps better) + temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4" + result_mp4 = subprocess.run([ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-i", temp_ts_path, + "-map", "0", + "-c", "copy", + temp_mp4_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0: + logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV") + + # Step 2: MP4 → MKV (clean timestamps from MP4) + result_mkv = subprocess.run([ + "ffmpeg", "-y", + "-i", temp_mp4_path, + "-map", "0", + "-c", "copy", + final_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + remux_success = True + logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}") + else: + logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})") + + # Clean up temp MP4 + try: + if os.path.exists(temp_mp4_path): + os.remove(temp_mp4_path) + except Exception: + pass + else: + logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})") + + # Sanity-check the remuxed file. Two checks: + # 1. If a pre-existing MKV was overwritten, reject a + # file that is drastically smaller (duplicate-task + # overwrite protection). + # 2. If the MKV is smaller than the .ts source, the + # remux likely produced a corrupt or truncated file. + if remux_success: + try: + new_size = os.path.getsize(final_path) + ts_size = os.path.getsize(temp_ts_path) if temp_ts_path and os.path.exists(temp_ts_path) else 0 + reject = False + if existing_mkv_size > 0 and new_size < existing_mkv_size * 0.5: + logger.error( + f"DVR recording {recording_id}: new MKV " + f"({new_size:,} bytes) is less than 50%% of " + f"the previous MKV ({existing_mkv_size:,} bytes) " + f"— refusing to overwrite. Keeping .ts for " + f"manual recovery." + ) + reject = True + elif ts_size > 0 and new_size < ts_size * 0.1: + logger.error( + f"DVR recording {recording_id}: remuxed MKV " + f"({new_size:,} bytes) is less than 10%% of " + f"the source TS ({ts_size:,} bytes) — likely " + f"corrupt. Keeping .ts for manual recovery." + ) + reject = True + if reject: + remux_success = False + try: + os.remove(final_path) + except OSError: + pass + except OSError: + pass + + # Clean up temp TS file only on successful remux + if remux_success: + try: + os.remove(temp_ts_path) + logger.debug(f"Cleaned up temp TS file: {temp_ts_path}") + except Exception as e: + logger.warning(f"Failed to remove temp TS file: {e}") else: - logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})") + # Keep TS file for debugging/manual recovery if remux failed + logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}") + # Clean up any partial MKV + try: + if os.path.exists(final_path): + os.remove(final_path) + logger.debug(f"Cleaned up partial MKV file: {final_path}") + except Exception: + pass + break # Completed (success or deterministic failure) - # Clean up temp TS file only on successful remux - if remux_success: - try: - os.remove(temp_ts_path) - logger.debug(f"Cleaned up temp TS file: {temp_ts_path}") - except Exception as e: - logger.warning(f"Failed to remove temp TS file: {e}") + except (OSError, subprocess.SubprocessError) as e: + # Clean up partial output before potential retry + try: + if os.path.exists(final_path): + os.remove(final_path) + except Exception: + pass + if _remux_attempt + 1 < _dvr_remux_max_retries: + _wait = _dvr_remux_retry_interval * (2 ** _remux_attempt) + logger.warning( + f"DVR recording {recording_id}: remux failed " + f"({type(e).__name__}), retrying in {_wait}s " + f"({_remux_attempt + 1}/{_dvr_remux_max_retries})..." + ) + time.sleep(_wait) else: - # Keep TS file for debugging/manual recovery if remux failed - logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}") - # Clean up any partial MKV - try: - if os.path.exists(final_path): - os.remove(final_path) - logger.debug(f"Cleaned up partial MKV file: {final_path}") - except Exception: - pass - - except Exception as e: - logger.warning(f"MKV remux failed with exception: {e}") - # Clean up any partial files on exception - try: - if os.path.exists(final_path): - os.remove(final_path) - except Exception: - pass + logger.warning( + f"DVR recording {recording_id}: remux failed " + f"after {_dvr_remux_max_retries} attempts: {e}. " + f"Keeping .ts for manual recovery: {temp_ts_path}" + ) # Persist final metadata to Recording (status, ended_at, and stream stats if available) try: if recording_obj is None: - from .models import Recording recording_obj = Recording.objects.get(id=recording_id) + # Re-read from DB to get the latest status (stop endpoint may have set it) + recording_obj.refresh_from_db() cp = recording_obj.custom_properties or {} - cp.update({ - "ended_at": str(datetime.now()), - }) - if interrupted: + cp["ended_at"] = str(datetime.now()) + + # Final status priority: stopped > completed > interrupted. + # "stopped" is set by the stop endpoint before stream teardown, so + # refresh_from_db() above guarantees it is visible here. + db_status_now = cp.get("status", "") + if db_status_now == "stopped": + # Deliberate user stop — preserve; do not overwrite with "completed". + cp.pop("interrupted_reason", None) + elif not interrupted: + cp["status"] = "completed" + cp.pop("interrupted_reason", None) + else: cp["status"] = "interrupted" if interrupted_reason: cp["interrupted_reason"] = interrupted_reason - else: - cp["status"] = "completed" cp["bytes_written"] = bytes_written cp["remux_success"] = remux_success @@ -2112,8 +2498,37 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # Removed: local thumbnail generation. We rely on EPG/VOD/TMDB/OMDb/keyless providers only. - recording_obj.custom_properties = cp - recording_obj.save(update_fields=["custom_properties"]) + # Final cancellation guard: destroy() may have deleted the record while + # remuxing. If it's gone now, skip saving "interrupted" status and + # skip the notification — destroy() already sent recording_cancelled. + if not Recording.objects.filter(id=recording_id).exists(): + logger.info( + f"Recording {recording_id} was deleted during post-processing — skipping final save." + ) + return + + def _save_final_metadata(): + recording_obj.custom_properties = cp + recording_obj.save(update_fields=["custom_properties"]) + + _db_retry( + _save_final_metadata, + max_retries=_dvr_db_max_retries, + base_interval=_dvr_db_retry_interval, + label=f"DVR recording {recording_id}: metadata save", + ) + + # Notify frontends so the UI refreshes immediately (e.g. "Stopped" → "Completed") + try: + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_ended", "channel": channel.name}, + }, + ) + except Exception: + pass except Exception as e: logger.debug(f"Unable to finalize Recording metadata: {e}") @@ -2143,42 +2558,190 @@ def recover_recordings_on_startup(): redis = RedisClient.get_client() if redis: lock_key = "dvr:recover_lock" - # Set lock with 60s TTL; only first winner proceeds - if not redis.set(lock_key, "1", ex=60, nx=True): + # Set lock with 10-minute TTL; must be long enough for Phase 2 + # ffmpeg remux operations on large files. + if not redis.set(lock_key, "1", ex=600, nx=True): return "Recovery already in progress" now = timezone.now() - # Resume in-window recordings - active = Recording.objects.filter(start_time__lte=now, end_time__gt=now) + # Resume in-window recordings. DB queries and saves use _db_retry + # to tolerate transient connection errors common during startup. + active = _db_retry( + lambda: list(Recording.objects.filter( + start_time__lte=now, end_time__gt=now + )), + label="DVR recovery: fetching active recordings", + ) for rec in active: try: cp = rec.custom_properties or {} + current_status = cp.get("status", "") + + # Skip recordings that are already in a terminal state. + # "completed" / "stopped" — user stopped or it finished normally; do NOT + # overwrite the status and re-schedule (that would cause the + # Interrupted → In-Progress → Previously-Recorded ghost cycle). + # NOTE: "recording" is NOT skipped — this function runs on + # worker_ready, meaning all previous workers are dead. A + # recording stuck in "recording" status is from a crashed + # worker and must be recovered. + if current_status in ("completed", "stopped"): + logger.info( + f"recover_recordings_on_startup: skipping recording {rec.id} " + f"(status={current_status!r}, already in terminal/active state)." + ) + continue + # Mark interrupted due to restart; will flip to 'recording' when task starts cp["status"] = "interrupted" cp["interrupted_reason"] = "server_restarted" - rec.custom_properties = cp - rec.save(update_fields=["custom_properties"]) - # Start recording for remaining window + # Preserve the pre-restart .ts segment path so run_recording + # can concatenate it with the resumed segment later. + old_ts = cp.get("_temp_file_path") + if old_ts and os.path.exists(old_ts) and os.path.getsize(old_ts) > 0: + prior_segments = cp.get("_pre_restart_ts_paths", []) + prior_segments.append(old_ts) + cp["_pre_restart_ts_paths"] = prior_segments + logger.info( + f"recover_recordings_on_startup: recording {rec.id} — " + f"preserving pre-restart TS segment: {old_ts}" + ) + + rec.custom_properties = cp + _db_retry( + lambda r=rec: r.save(update_fields=["custom_properties"]), + label=f"DVR recovery: recording {rec.id} status update", + ) + + # Revoke the old PeriodicTask so Celery Beat doesn't also + # fire run_recording for this recording (would be a duplicate). + old_task_id = rec.task_id + if old_task_id: + try: + revoke_task(old_task_id) + except Exception: + pass + + # Start recording for remaining window. Use a deterministic + # task_id so duplicate dispatches (e.g. from a second recovery + # attempt) are deduplicated by Celery/Redis. + recovery_task_id = f"dvr-recover-{rec.id}" run_recording.apply_async( - args=[rec.id, rec.channel_id, str(now), str(rec.end_time)], eta=now + args=[rec.id, rec.channel_id, str(now), str(rec.end_time)], + eta=now, + task_id=recovery_task_id, ) except Exception as e: logger.warning(f"Failed to resume recording {rec.id}: {e}") - # Ensure future recordings are scheduled - upcoming = Recording.objects.filter(start_time__gt=now, end_time__gt=now) + # Finalize expired recordings that were active when the server crashed + # but whose end_time has now passed. Remux the partial .ts and mark + # as interrupted so the user can watch whatever was captured. + expired = _db_retry( + lambda: list(Recording.objects.filter( + end_time__lte=now, + custom_properties__status="recording", + )), + label="DVR recovery: fetching expired recordings", + ) + for rec in expired: + try: + cp = rec.custom_properties or {} + ts_path = cp.get("_temp_file_path") + mkv_path = cp.get("file_path") + + if ts_path and os.path.exists(ts_path) and os.path.getsize(ts_path) > 0 and mkv_path: + logger.info( + f"recover_recordings_on_startup: recording {rec.id} expired " + f"during downtime — remuxing partial TS ({os.path.getsize(ts_path):,} bytes)" + ) + os.makedirs(os.path.dirname(mkv_path), exist_ok=True) + result = subprocess.run( + [ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-i", ts_path, "-map", "0", "-c", "copy", mkv_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if result.returncode == 0 and os.path.exists(mkv_path) and os.path.getsize(mkv_path) > 0: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = True + try: + os.remove(ts_path) + except OSError: + pass + logger.info(f"recover_recordings_on_startup: recording {rec.id} remuxed successfully") + else: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = False + logger.warning(f"recover_recordings_on_startup: recording {rec.id} remux failed, keeping .ts") + else: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = False + + rec.custom_properties = cp + _db_retry( + lambda r=rec: r.save(update_fields=["custom_properties"]), + label=f"DVR recovery: recording {rec.id} expired status update", + ) + except Exception as e: + logger.warning(f"Failed to finalize expired recording {rec.id}: {e}") + + # Ensure future recordings are scheduled. + # With ClockedSchedule, PeriodicTasks survive restarts in the DB. + # Only recreate if the PeriodicTask is missing (safety net). + from django_celery_beat.models import PeriodicTask as _PT + from apps.channels.signals import _dvr_task_name + + upcoming = _db_retry( + lambda: list(Recording.objects.filter( + start_time__gt=now, end_time__gt=now + )), + label="DVR recovery: fetching upcoming recordings", + ) + + # Batch-fetch existing PeriodicTask names to avoid N+1 queries + task_names = {_dvr_task_name(r.id) for r in upcoming} + existing_tasks = set(_db_retry( + lambda: list(_PT.objects.filter(name__in=task_names).values_list("name", flat=True)), + label="DVR recovery: fetching existing periodic tasks", + )) if task_names else set() + for rec in upcoming: try: - # Schedule task at start_time + task_name = _dvr_task_name(rec.id) + if task_name in existing_tasks: + if rec.task_id != task_name: + rec.task_id = task_name + _db_retry( + lambda r=rec: r.save(update_fields=["task_id"]), + label=f"DVR recovery: recording {rec.id} task_id update", + ) + continue + # PeriodicTask missing - recreate it task_id = schedule_recording_task(rec) if task_id: rec.task_id = task_id - rec.save(update_fields=["task_id"]) + _db_retry( + lambda r=rec: r.save(update_fields=["task_id"]), + label=f"DVR recovery: recording {rec.id} task_id update", + ) except Exception as e: logger.warning(f"Failed to schedule recording {rec.id}: {e}") + # Release the lock early so a subsequent restart can recover + # immediately. The 10-minute TTL is only a safety net in case + # recovery itself crashes before reaching this point. + if redis: + redis.delete(lock_key) + return "Recovery complete" except Exception as e: logger.error(f"Error during DVR recovery: {e}") @@ -2273,34 +2836,43 @@ def comskip_process_recording(recording_id: int): cmd.extend([f"--ini={ini_path}"]) break cmd.append(file_path) - subprocess.run( + result = subprocess.run( cmd, - check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) - except subprocess.CalledProcessError as e: - stderr_tail = (e.stderr or "").strip().splitlines() - stderr_tail = stderr_tail[-5:] if stderr_tail else [] - detail = { - "status": "error", - "reason": "comskip_failed", - "returncode": e.returncode, - } - if e.returncode and e.returncode < 0: - try: - detail["signal"] = signal.Signals(-e.returncode).name - except Exception: - detail["signal"] = f"signal_{-e.returncode}" - if stderr_tail: - detail["stderr"] = "\n".join(stderr_tail) - if selected_ini: - detail["ini_path"] = selected_ini - cp["comskip"] = detail - _persist_custom_properties() - _ws('error', {"reason": "comskip_failed", "returncode": e.returncode}) - return "comskip_failed" + # comskip exit codes: 0 = commercials found, 1 = no commercials detected. + # Negative codes indicate killed by signal; anything else is a real error. + if result.returncode == 1: + # No commercials detected — not an error. + cp["comskip"] = {"status": "completed", "skipped": True} + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() + _ws('skipped', {"reason": "no_commercials_detected"}) + return "no_commercials" + elif result.returncode != 0: + stderr_tail = (result.stderr or "").strip().splitlines() + stderr_tail = stderr_tail[-5:] if stderr_tail else [] + detail = { + "status": "error", + "reason": "comskip_failed", + "returncode": result.returncode, + } + if result.returncode < 0: + try: + detail["signal"] = signal.Signals(-result.returncode).name + except Exception: + detail["signal"] = f"signal_{-result.returncode}" + if stderr_tail: + detail["stderr"] = "\n".join(stderr_tail) + if selected_ini: + detail["ini_path"] = selected_ini + cp["comskip"] = detail + _persist_custom_properties() + _ws('error', {"reason": "comskip_failed", "returncode": result.returncode}) + return "comskip_failed" except Exception as e: cp["comskip"] = {"status": "error", "reason": f"comskip_failed: {e}"} _persist_custom_properties() @@ -2424,14 +2996,32 @@ def comskip_process_recording(recording_id: int): _persist_custom_properties() _ws('error', {"reason": str(e)}) return f"error:{e}" -def _resolve_poster_for_program(channel_name, program): - """Internal helper that attempts to resolve a poster URL and/or Logo id. +def _resolve_poster_for_program(channel_name, program, channel_logo_id=None): + """Resolve poster URL and/or Logo id for a recording program. + + Callers should enrich the program dict via _match_epg_program_by_timeslot + before invoking this function so that EPG data is already available. + + Pipeline: EPG images → VOD logo → TMDB/OMDb → TVMaze/iTunes → + direct program fields → Logo table → Logo creation → channel logo. Returns (poster_logo_id, poster_url) where either may be None. """ poster_logo_id = None poster_url = None + epg_props = None - # Try EPG Program images first + _title = ((program.get("title") if isinstance(program, dict) else None) or "").strip() or None + + # Guard: if the "title" is really just the channel name (common when EPG + # has no real program data), don't use it for external API searches — + # those queries produce false-positive artwork from unrelated shows. + _title_is_channel_name = False + if _title and channel_name: + def _norm_channel(s): + return s.lower().replace("*", "").replace("-", " ").strip() + _title_is_channel_name = _norm_channel(_title) == _norm_channel(channel_name) + + # Stage 1: EPG Program images/icon (with URL validation) try: from apps.epg.models import ProgramData prog_id = program.get("id") if isinstance(program, dict) else None @@ -2439,46 +3029,26 @@ def _resolve_poster_for_program(channel_name, program): epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() if epg_program and epg_program.custom_properties: epg_props = epg_program.custom_properties or {} - - def pick_best_image_from_epg_props(epg_props): - images = epg_props.get("images") or [] - if not isinstance(images, list): - return None - size_order = {"xxl": 6, "xl": 5, "l": 4, "m": 3, "s": 2, "xs": 1} - def score(img): - t = (img.get("type") or "").lower() - size = (img.get("size") or "").lower() - return (2 if t in ("poster", "cover") else 1, size_order.get(size, 0)) - best = None - for im in images: - if not isinstance(im, dict): - continue - url = im.get("url") - if not url: - continue - if best is None or score(im) > score(best): - best = im - return best.get("url") if best else None - - poster_url = pick_best_image_from_epg_props(epg_props) + poster_url = _pick_best_image_from_epg_props(epg_props) + if poster_url and not _validate_url(poster_url): + poster_url = None if not poster_url: icon = epg_props.get("icon") - if isinstance(icon, str) and icon: + if isinstance(icon, str) and icon and _validate_url(icon): poster_url = icon except Exception: pass - # VOD logo fallback by title - if not poster_url and not poster_logo_id: + # Stage 2: VOD logo fallback by title + if not poster_url and not poster_logo_id and _title and not _title_is_channel_name: try: from apps.vod.models import Movie, Series - title = (program.get("title") if isinstance(program, dict) else None) or channel_name vod_logo = None - movie = Movie.objects.filter(name__iexact=title).select_related("logo").first() + movie = Movie.objects.filter(name__iexact=_title).select_related("logo").first() if movie and movie.logo: vod_logo = movie.logo if not vod_logo: - series = Series.objects.filter(name__iexact=title).select_related("logo").first() + series = Series.objects.filter(name__iexact=_title).select_related("logo").first() if series and series.logo: vod_logo = series.logo if vod_logo: @@ -2486,63 +3056,151 @@ def _resolve_poster_for_program(channel_name, program): except Exception: pass - # Keyless providers (TVMaze & iTunes) - if not poster_url and not poster_logo_id: + # Stage 3: TMDB/OMDb (keyed APIs) + if not poster_url and not poster_logo_id and _title and not _title_is_channel_name: try: - title = (program.get('title') if isinstance(program, dict) else None) or channel_name - if title: - # TVMaze + tmdb_key = os.environ.get('TMDB_API_KEY') + omdb_key = os.environ.get('OMDB_API_KEY') + title = _title + year = None + imdb_id = None + + # Derive year and imdb_id from cached EPG data + if epg_props: + d = epg_props.get('date') + if d and len(str(d)) >= 4: + year = str(d)[:4] + imdb_id = epg_props.get('imdb.com_id') + + # TMDB: by IMDb ID + if not poster_url and tmdb_key and imdb_id: try: - url = f"https://api.tvmaze.com/singlesearch/shows?q={quote(title)}" + url = f"https://api.themoviedb.org/3/find/{quote(imdb_id)}?api_key={tmdb_key}&external_source=imdb_id" resp = requests.get(url, timeout=5) if resp.ok: data = resp.json() or {} - img = (data.get('image') or {}) - p = img.get('original') or img.get('medium') - if p: - poster_url = p + picks = [] + for k in ('movie_results', 'tv_results', 'tv_episode_results', 'tv_season_results'): + picks.extend(data.get(k) or []) + for item in picks: + if item.get('poster_path'): + poster_url = f"https://image.tmdb.org/t/p/w780{item['poster_path']}" + break + except Exception: + pass + + # TMDB: by title (and year if available) + if not poster_url and tmdb_key and title: + try: + q = quote(title) + extra = f"&year={year}" if year else "" + url = f"https://api.themoviedb.org/3/search/multi?api_key={tmdb_key}&query={q}{extra}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + results.sort(key=lambda x: float(x.get('popularity') or 0), reverse=True) + for item in results: + if item.get('poster_path'): + poster_url = f"https://image.tmdb.org/t/p/w780{item['poster_path']}" + break + except Exception: + pass + + # OMDb fallback + if not poster_url and omdb_key: + try: + if imdb_id: + url = f"https://www.omdbapi.com/?apikey={omdb_key}&i={quote(imdb_id)}" + elif title: + yy = f"&y={year}" if year else "" + url = f"https://www.omdbapi.com/?apikey={omdb_key}&t={quote(title)}{yy}" + else: + url = None + if url: + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + p = data.get('Poster') + if p and p != 'N/A': + poster_url = p except Exception: pass - # iTunes - if not poster_url: - try: - for media in ('movie', 'tvShow'): - url = f"https://itunes.apple.com/search?term={quote(title)}&media={media}&limit=1" - resp = requests.get(url, timeout=5) - if resp.ok: - data = resp.json() or {} - results = data.get('results') or [] - if results: - art = results[0].get('artworkUrl100') - if art: - poster_url = art.replace('100x100', '600x600') - break - except Exception: - pass except Exception: pass - # Fallback: search existing Logo entries by name if we still have nothing - if not poster_logo_id and not poster_url: + # Stage 4: Keyless providers (TVMaze & iTunes) + if not poster_url and not poster_logo_id and _title and not _title_is_channel_name: + try: + title = _title + # TVMaze + try: + url = f"https://api.tvmaze.com/singlesearch/shows?q={quote(title)}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + img = (data.get('image') or {}) + p = img.get('original') or img.get('medium') + if p: + poster_url = p + except Exception: + pass + # iTunes + if not poster_url: + try: + for media in ('movie', 'tvShow'): + url = f"https://itunes.apple.com/search?term={quote(title)}&media={media}&limit=1" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + if results: + art = results[0].get('artworkUrl100') + if art: + poster_url = art.replace('100x100', '600x600') + break + except Exception: + pass + except Exception: + pass + + # Stage 5: Direct fields on program object (with URL validation) + if not poster_url and not poster_logo_id and isinstance(program, dict): + for key in ("poster", "cover", "cover_big", "image", "icon"): + val = program.get(key) + if isinstance(val, dict): + candidate = val.get("url") + if candidate and _validate_url(candidate): + poster_url = candidate + break + elif isinstance(val, str) and val and _validate_url(val): + poster_url = val + break + + # Stage 6: Search existing Logo entries by program title + if not poster_logo_id and not poster_url and _title and not _title_is_channel_name: try: from .models import Logo - title = (program.get("title") if isinstance(program, dict) else None) or channel_name - existing = Logo.objects.filter(name__iexact=title).first() + existing = Logo.objects.filter(name__iexact=_title).first() if existing: poster_logo_id = existing.id poster_url = existing.url except Exception: pass - # Save to Logo if URL available + # Stage 7: Persist to Logo table if URL available if not poster_logo_id and poster_url and len(poster_url) <= 1000: try: from .models import Logo - logo, _ = Logo.objects.get_or_create(url=poster_url, defaults={"name": (program.get("title") if isinstance(program, dict) else None) or channel_name}) + logo, _ = Logo.objects.get_or_create(url=poster_url, defaults={"name": _title or channel_name}) poster_logo_id = logo.id except Exception: pass + # Stage 8: Fall back to channel logo + if not poster_logo_id and not poster_url and channel_logo_id: + poster_logo_id = channel_logo_id + return poster_logo_id, poster_url @@ -2553,8 +3211,28 @@ def prefetch_recording_artwork(recording_id): from .models import Recording rec = Recording.objects.get(id=recording_id) cp = rec.custom_properties or {} + + # Bail out if the recording is already active or finished — run_recording + # handles poster resolution itself, and saving here can race with status updates. + current_status = cp.get("status", "") + if current_status in ("recording", "completed", "stopped", "interrupted"): + return "skipped: status is " + current_status + program = cp.get("program") or {} - poster_logo_id, poster_url = _resolve_poster_for_program(rec.channel.name, program) + + # Enrich empty program dicts (manual recordings) from EPG time-slot data. + # Persists matched title/description for display in the recording card. + if isinstance(program, dict) and not program.get("user_edited") and not program.get("id") and not program.get("title"): + epg_match = _match_epg_program_by_timeslot( + rec.channel.epg_data, rec.start_time, rec.end_time, + ) + if epg_match: + program.update(epg_match) + cp["program"] = program + + poster_logo_id, poster_url = _resolve_poster_for_program( + rec.channel.name, program, channel_logo_id=rec.channel.logo_id, + ) updated = False if poster_logo_id and cp.get("poster_logo_id") != poster_logo_id: cp["poster_logo_id"] = poster_logo_id @@ -2593,7 +3271,15 @@ def prefetch_recording_artwork(recording_id): pass if updated: - rec.custom_properties = cp + # Re-read from DB to avoid overwriting status changes made by + # the stop endpoint or run_recording's final metadata save. + rec.refresh_from_db() + fresh_cp = rec.custom_properties or {} + for key in ("program", "poster_logo_id", "poster_url", "rating", + "rating_system", "season", "episode", "onscreen_episode"): + if key in cp: + fresh_cp[key] = cp[key] + rec.custom_properties = fresh_cp rec.save(update_fields=["custom_properties"]) try: from core.utils import send_websocket_update diff --git a/apps/channels/tests/test_db_retry.py b/apps/channels/tests/test_db_retry.py new file mode 100644 index 00000000..e65d929a --- /dev/null +++ b/apps/channels/tests/test_db_retry.py @@ -0,0 +1,278 @@ +"""Tests for DVR retry logic. + +Covers: + - _db_retry(): exponential backoff, max retries, connection reset + - Final metadata save retry in run_recording post-processing + - Initial TS proxy connection retry (per-base retry on retriable errors) + - recover_recordings_on_startup DB retry wrappers +""" +from datetime import timedelta +from unittest.mock import MagicMock, patch, call + +from django.db import OperationalError +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel, Recording +from apps.channels.tasks import _db_retry + + +# --------------------------------------------------------------------------- +# _db_retry unit tests +# --------------------------------------------------------------------------- + +class DbRetryTests(TestCase): + """Tests for the _db_retry() exponential backoff helper.""" + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_succeeds_on_first_attempt(self, _close, _sleep): + """No retry needed when fn succeeds immediately.""" + result = _db_retry(lambda: "ok", max_retries=3) + self.assertEqual(result, "ok") + _sleep.assert_not_called() + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_retries_on_operational_error_then_succeeds(self, mock_close, mock_sleep): + """Retry succeeds on second attempt after OperationalError.""" + call_count = {"n": 0} + + def flaky(): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("connection reset") + return "recovered" + + result = _db_retry(flaky, max_retries=3, base_interval=1) + self.assertEqual(result, "recovered") + self.assertEqual(call_count["n"], 2) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_raises_after_max_retries_exhausted(self, mock_close, mock_sleep): + """Raises OperationalError after all retries fail.""" + def always_fail(): + raise OperationalError("db gone") + + with self.assertRaises(OperationalError): + _db_retry(always_fail, max_retries=3, base_interval=1) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_exponential_backoff_timing(self, mock_close, mock_sleep): + """Sleep durations follow exponential backoff: 1s, 2s, 4s.""" + call_count = {"n": 0} + + def fail_twice(): + call_count["n"] += 1 + if call_count["n"] <= 2: + raise OperationalError("retry me") + return "done" + + _db_retry(fail_twice, max_retries=3, base_interval=1) + mock_sleep.assert_has_calls([call(1), call(2)]) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_close_old_connections_called_between_retries(self, mock_close, mock_sleep): + """Stale DB connections are reset before each retry attempt.""" + call_count = {"n": 0} + + def fail_once(): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("stale conn") + return "ok" + + _db_retry(fail_once, max_retries=3) + mock_close.assert_called_once() + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_non_operational_error_not_retried(self, mock_close, mock_sleep): + """Non-OperationalError exceptions propagate immediately.""" + def raise_value_error(): + raise ValueError("not a DB error") + + with self.assertRaises(ValueError): + _db_retry(raise_value_error, max_retries=3) + mock_sleep.assert_not_called() + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_returns_fn_return_value(self, mock_close, mock_sleep): + """Return value of fn() is passed through.""" + result = _db_retry(lambda: {"key": "value"}, max_retries=3) + self.assertEqual(result, {"key": "value"}) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_single_retry_allowed(self, mock_close, mock_sleep): + """max_retries=1 means no retry — fail immediately.""" + with self.assertRaises(OperationalError): + _db_retry( + lambda: (_ for _ in ()).throw(OperationalError("fail")), + max_retries=1, + ) + mock_sleep.assert_not_called() + + +# --------------------------------------------------------------------------- +# Final metadata save retry integration tests +# --------------------------------------------------------------------------- + +class FinalMetadataSaveRetryTests(TestCase): + """The final recording metadata save must retry on transient DB errors.""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=95, name="Retry Test Channel" + ) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_metadata_save_uses_db_retry(self, _ws): + """Verify recording metadata is saved via _db_retry (retries on OperationalError).""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + # Directly call _db_retry to save metadata as run_recording does + cp = rec.custom_properties.copy() + cp["status"] = "completed" + cp["ended_at"] = str(now) + cp["bytes_written"] = 1024 + + def _save(): + rec.custom_properties = cp + rec.save(update_fields=["custom_properties"]) + + _db_retry(_save, max_retries=3, base_interval=1, label="test save") + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], "completed") + self.assertEqual(rec.custom_properties["bytes_written"], 1024) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_metadata_survives_transient_save_failure(self, _ws): + """Simulate OperationalError on first save, success on retry.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + cp = {"status": "completed", "bytes_written": 2048} + call_count = {"n": 0} + _real_save = rec.save + + def patched_save(**kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("connection reset by peer") + return _real_save(**kwargs) + + with patch.object(rec, "save", side_effect=patched_save): + with patch("apps.channels.tasks.time.sleep"): + with patch("apps.channels.tasks.close_old_connections"): + def _save(): + rec.custom_properties = cp + rec.save(update_fields=["custom_properties"]) + _db_retry(_save, max_retries=3, base_interval=1, label="test") + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], "completed") + + +# --------------------------------------------------------------------------- +# Initial connection retry tests +# --------------------------------------------------------------------------- + +class InitialConnectionRetryTests(TestCase): + """Verify that the DVR task's reconnection logic retries the same + base URL before falling back to the next candidate.""" + + def test_reconnect_max_constant_exists_in_run_recording(self): + """run_recording must define a max-reconnect limit to prevent + infinite retries on the same broken base URL.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + # The reconnection counter pattern must be present + self.assertIn("reconnect", source.lower(), + "run_recording must contain reconnection logic") + + +# --------------------------------------------------------------------------- +# recover_recordings_on_startup retry tests +# --------------------------------------------------------------------------- + +class RecoveryRetryTests(TestCase): + """DB operations in recover_recordings_on_startup must use _db_retry.""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=97, name="Recovery Retry Channel" + ) + + @patch("apps.channels.tasks.run_recording.apply_async") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_recovery_save_retries_on_operational_error(self, _ws, mock_async): + """Recovery status update uses _db_retry — survives one OperationalError.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=30), + end_time=now + timedelta(minutes=30), + custom_properties={}, + ) + # Simulate what recovery does: mark interrupted, then save with retry + cp = rec.custom_properties or {} + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted" + rec.custom_properties = cp + + call_count = {"n": 0} + _real_save = Recording.save + + def patched_save(self_rec, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("db temporarily unavailable") + return _real_save(self_rec, **kwargs) + + with patch.object(Recording, "save", patched_save): + with patch("apps.channels.tasks.time.sleep"): + with patch("apps.channels.tasks.close_old_connections"): + _db_retry( + lambda: rec.save(update_fields=["custom_properties"]), + max_retries=3, + label="test recovery", + ) + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("status"), "interrupted") + self.assertEqual(rec.custom_properties.get("interrupted_reason"), "server_restarted") + + def test_db_retry_fetches_recording_list(self): + """_db_retry correctly returns query results for recording list fetch.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=30), + end_time=now + timedelta(minutes=30), + custom_properties={}, + ) + result = _db_retry( + lambda: list(Recording.objects.filter( + start_time__lte=now, end_time__gt=now + )), + label="test query", + ) + self.assertGreaterEqual(len(result), 1) + ids = [r.id for r in result] + self.assertIn(rec.id, ids) diff --git a/apps/channels/tests/test_dvr_port_resolution.py b/apps/channels/tests/test_dvr_port_resolution.py new file mode 100644 index 00000000..c7373959 --- /dev/null +++ b/apps/channels/tests/test_dvr_port_resolution.py @@ -0,0 +1,59 @@ +import os +from django.test import SimpleTestCase +from unittest.mock import patch + +from apps.channels.tasks import build_dvr_candidates + + +class DVRPortResolutionTests(SimpleTestCase): + """ + Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT + environment variable instead of hardcoding port 9191. + """ + + @patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True) + def test_default_port_uses_9191(self): + """Without DISPATCHARR_PORT set, candidates default to 9191.""" + candidates = build_dvr_candidates() + self.assertIn('http://web:9191', candidates) + self.assertIn('http://localhost:9191', candidates) + + @patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True) + def test_custom_port_reflected_in_candidates(self): + """DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references.""" + candidates = build_dvr_candidates() + self.assertIn('http://web:8080', candidates) + self.assertIn('http://localhost:8080', candidates) + self.assertNotIn('http://web:9191', candidates) + self.assertNotIn('http://localhost:9191', candidates) + + @patch.dict(os.environ, { + 'DISPATCHARR_PORT': '7777', + 'DISPATCHARR_ENV': 'dev', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_dev_mode_includes_5656_and_custom_port(self): + """Dev mode includes both uwsgi internal port (5656) and custom port.""" + candidates = build_dvr_candidates() + self.assertIn('http://127.0.0.1:5656', candidates) + self.assertIn('http://127.0.0.1:7777', candidates) + + @patch.dict(os.environ, { + 'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_explicit_override_is_first(self): + """DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate.""" + candidates = build_dvr_candidates() + self.assertEqual(candidates[0], 'http://custom:1234') + + @patch.dict(os.environ, { + 'DISPATCHARR_PORT': '3000', + 'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_internal_api_base_overrides_web_fallback(self): + """DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default.""" + candidates = build_dvr_candidates() + self.assertIn('http://myhost:4000', candidates) + self.assertNotIn('http://web:3000', candidates) diff --git a/apps/channels/tests/test_epg_matching.py b/apps/channels/tests/test_epg_matching.py new file mode 100644 index 00000000..fee7eeab --- /dev/null +++ b/apps/channels/tests/test_epg_matching.py @@ -0,0 +1,180 @@ +"""Tests for the _match_epg_program_by_timeslot() helper in tasks.py. + +Covers: + - Exact time-slot match returns program dict + - 80% overlap threshold: at boundary, above, and below + - Multiple overlapping programs: dominant vs. evenly split + - Edge cases: None inputs, zero-duration recording, no EPG data + - Returned dict structure (id, title, sub_title, description) +""" +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel +from apps.epg.models import EPGSource, EPGData, ProgramData +from apps.channels.tasks import _match_epg_program_by_timeslot + + +class EpgMatchingSetupMixin: + """Shared setup for EPG matching tests.""" + + def setUp(self): + self.source = EPGSource.objects.create(name="Test Source") + self.epg = EPGData.objects.create( + tvg_id="test.channel", name="Test Channel EPG", epg_source=self.source, + ) + self.channel = Channel.objects.create( + channel_number=50, name="EPG Match Channel", epg_data=self.epg, + ) + self.base = timezone.now().replace(second=0, microsecond=0) + + def _prog(self, offset_min, duration_min, title="Test Show", **kwargs): + """Create a ProgramData starting offset_min from self.base.""" + start = self.base + timedelta(minutes=offset_min) + end = start + timedelta(minutes=duration_min) + return ProgramData.objects.create( + epg=self.epg, start_time=start, end_time=end, title=title, **kwargs, + ) + + +class ExactMatchTests(EpgMatchingSetupMixin, TestCase): + """Recording window exactly matches an EPG program.""" + + def test_exact_match_returns_program_dict(self): + prog = self._prog(0, 60, title="News at 9", sub_title="Top Stories", + description="Evening news broadcast") + result = _match_epg_program_by_timeslot( + self.epg, prog.start_time, prog.end_time, + ) + self.assertIsNotNone(result) + self.assertEqual(result["id"], prog.id) + self.assertEqual(result["title"], "News at 9") + self.assertEqual(result["sub_title"], "Top Stories") + self.assertEqual(result["description"], "Evening news broadcast") + + def test_missing_optional_fields_returned_as_empty_strings(self): + prog = self._prog(0, 30, title="Minimal Show") + result = _match_epg_program_by_timeslot( + self.epg, prog.start_time, prog.end_time, + ) + self.assertIsNotNone(result) + self.assertEqual(result["sub_title"], "") + self.assertEqual(result["description"], "") + + +class OverlapThresholdTests(EpgMatchingSetupMixin, TestCase): + """80% overlap threshold boundary tests.""" + + def test_exactly_80_percent_overlap_returns_match(self): + """Program covers exactly 80% of the recording window.""" + # Program: 0-60min, Recording: 0-75min → overlap = 60/75 = 80% + prog = self._prog(0, 60, title="Borderline Show") + rec_start = self.base + rec_end = self.base + timedelta(minutes=75) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Borderline Show") + + def test_below_80_percent_returns_none(self): + """Program covers 79% of the recording — below threshold.""" + # Program: 0-60min, Recording: 0-76min → overlap = 60/76 ≈ 78.9% + prog = self._prog(0, 60, title="Too Short") + rec_start = self.base + rec_end = self.base + timedelta(minutes=76) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNone(result) + + def test_above_80_percent_returns_match(self): + """Program covers 90% of the recording.""" + # Program: 0-60min, Recording: 0-66min → overlap = 60/66 ≈ 90.9% + prog = self._prog(0, 60, title="Good Match") + rec_start = self.base + rec_end = self.base + timedelta(minutes=66) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Good Match") + + +class MultipleProgramTests(EpgMatchingSetupMixin, TestCase): + """Recording spans multiple EPG programs.""" + + def test_dominant_program_returned(self): + """Recording spans 2 programs; one covers 85%, the other 15%.""" + # Show A: 0-60min, Show B: 60-120min + # Recording: 9-69min → A overlap=51/60=85%, B overlap=9/60=15% + self._prog(0, 60, title="Show A") + self._prog(60, 60, title="Show B") + rec_start = self.base + timedelta(minutes=9) + rec_end = self.base + timedelta(minutes=69) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Show A") + + def test_evenly_split_returns_none(self): + """Recording spans 2 equal programs — neither reaches 80%.""" + # Show A: 0-60min, Show B: 60-120min + # Recording: 30-90min → each covers 50% + self._prog(0, 60, title="Show A") + self._prog(60, 60, title="Show B") + rec_start = self.base + timedelta(minutes=30) + rec_end = self.base + timedelta(minutes=90) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNone(result) + + def test_three_programs_one_dominant(self): + """Recording spans 3 programs; middle one is dominant.""" + # A: 0-30min, B: 30-90min, C: 90-120min + # Recording: 25-95min (70min window) → B overlap=60/70≈85.7% + self._prog(0, 30, title="Show A") + self._prog(30, 60, title="Show B") + self._prog(90, 30, title="Show C") + rec_start = self.base + timedelta(minutes=25) + rec_end = self.base + timedelta(minutes=95) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Show B") + + +class EdgeCaseTests(EpgMatchingSetupMixin, TestCase): + """Edge cases and error handling.""" + + def test_none_epg_data_returns_none(self): + result = _match_epg_program_by_timeslot(None, self.base, self.base + timedelta(hours=1)) + self.assertIsNone(result) + + def test_none_start_time_returns_none(self): + result = _match_epg_program_by_timeslot(self.epg, None, self.base + timedelta(hours=1)) + self.assertIsNone(result) + + def test_none_end_time_returns_none(self): + result = _match_epg_program_by_timeslot(self.epg, self.base, None) + self.assertIsNone(result) + + def test_zero_duration_returns_none(self): + """Recording with start == end should return None.""" + result = _match_epg_program_by_timeslot(self.epg, self.base, self.base) + self.assertIsNone(result) + + def test_negative_duration_returns_none(self): + """Recording with end before start should return None.""" + result = _match_epg_program_by_timeslot( + self.epg, self.base + timedelta(hours=1), self.base, + ) + self.assertIsNone(result) + + def test_no_overlapping_programs_returns_none(self): + """No EPG programs in the recording window.""" + self._prog(0, 60, title="Earlier Show") + rec_start = self.base + timedelta(hours=5) + rec_end = rec_start + timedelta(hours=1) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNone(result) + + def test_empty_epg_no_programs_returns_none(self): + """EPGData exists but has no programs.""" + result = _match_epg_program_by_timeslot( + self.epg, self.base, self.base + timedelta(hours=1), + ) + self.assertIsNone(result) diff --git a/apps/channels/tests/test_recording_extend.py b/apps/channels/tests/test_recording_extend.py new file mode 100644 index 00000000..a083b6d3 --- /dev/null +++ b/apps/channels/tests/test_recording_extend.py @@ -0,0 +1,235 @@ +"""Tests for the Extend In-Progress Recording feature. + +Covers: + - extend() API endpoint (happy path and validation) + - pre_save signal guard: end_time change must NOT revoke a live recording + - pre_save signal guard: end_time change MUST still revoke an upcoming recording + - TOCTOU edge cases (extend on a completed/stopped/nonexistent recording) +""" +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording +from apps.channels.api_views import RecordingViewSet + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="extend_test_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +# --------------------------------------------------------------------------- +# Extend endpoint tests +# --------------------------------------------------------------------------- + +class ExtendEndpointTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/extend/""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=88, name="Extend Test Channel" + ) + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _extend(self, rec, extra_minutes): + request = self.factory.post( + f"/api/channels/recordings/{rec.id}/extend/", + {"extra_minutes": extra_minutes}, + format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "extend"}) + return view(request, pk=rec.id) + + def _make_rec(self, status="recording"): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": status}, + ) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_updates_end_time_in_db(self, _ws): + rec = self._make_rec() + original_end = rec.end_time + response = self._extend(rec, 30) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data.get("success")) + rec.refresh_from_db() + expected = original_end + timedelta(minutes=30) + delta = abs((rec.end_time - expected).total_seconds()) + self.assertLess(delta, 1, "end_time was not extended by the correct amount") + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_stacks_multiple_extensions(self, _ws): + """Calling extend() twice adds both increments.""" + rec = self._make_rec() + original_end = rec.end_time + self._extend(rec, 15) + self._extend(rec, 30) + rec.refresh_from_db() + expected = original_end + timedelta(minutes=45) + delta = abs((rec.end_time - expected).total_seconds()) + self.assertLess(delta, 1) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_does_not_clear_task_id(self, _ws): + """The running Celery task must survive the DB save.""" + rec = self._make_rec() + rec.task_id = "dvr-recording-999" + rec.save(update_fields=["task_id"]) + self._extend(rec, 30) + rec.refresh_from_db() + self.assertEqual(rec.task_id, "dvr-recording-999") + + def test_extend_returns_400_if_finished(self): + """Cannot extend a completed, stopped, or interrupted recording.""" + for bad_status in ("completed", "stopped", "interrupted"): + with self.subTest(status=bad_status): + rec = self._make_rec(status=bad_status) + response = self._extend(rec, 30) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.data.get("success")) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_succeeds_before_task_sets_status(self, _ws): + """Extend must work when status is empty (task hasn't started yet).""" + rec = self._make_rec(status="") + response = self._extend(rec, 15) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + expected = rec.end_time # already extended + self.assertTrue(response.data.get("success")) + + @patch("apps.channels.signals.revoke_task") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke): + """Extend uses .update() to bypass pre_save — revoke_task must never fire.""" + rec = self._make_rec(status="") + rec.task_id = "dvr-recording-500" + rec.save(update_fields=["task_id"]) + self._extend(rec, 15) + self._extend(rec, 30) + mock_revoke.assert_not_called() + rec.refresh_from_db() + self.assertEqual(rec.task_id, "dvr-recording-500") + + def test_extend_returns_400_for_zero_minutes(self): + response = self._extend(self._make_rec(), 0) + self.assertEqual(response.status_code, 400) + + def test_extend_returns_400_for_negative_minutes(self): + response = self._extend(self._make_rec(), -15) + self.assertEqual(response.status_code, 400) + + def test_extend_returns_400_for_non_numeric_minutes(self): + rec = self._make_rec() + request = self.factory.post( + f"/api/channels/recordings/{rec.id}/extend/", + {"extra_minutes": "lots"}, + format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "extend"}) + response = view(request, pk=rec.id) + self.assertEqual(response.status_code, 400) + + def test_extend_returns_404_for_nonexistent_recording(self): + request = self.factory.post( + "/api/channels/recordings/999999/extend/", + {"extra_minutes": 30}, + format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "extend"}) + response = view(request, pk=999999) + self.assertEqual(response.status_code, 404) + + +# --------------------------------------------------------------------------- +# pre_save signal guard tests +# --------------------------------------------------------------------------- + +class PreSaveExtendGuardTests(TestCase): + """The pre_save signal must NOT revoke a live recording when end_time changes, + but MUST still revoke a scheduled (upcoming) recording as before.""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=77, name="Signal Guard Channel" + ) + + def _make_rec(self, status="", task_id="dvr-recording-42"): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + task_id=task_id, + custom_properties={"status": status} if status else {}, + ) + + @patch("apps.channels.signals.revoke_task") + def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke): + """When status='recording', extending end_time must not call revoke_task.""" + rec = self._make_rec(status="recording", task_id="dvr-recording-42") + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + mock_revoke.assert_not_called() + + @patch("apps.channels.signals.revoke_task") + def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke): + """task_id must not be cleared for a live recording's end_time change.""" + rec = self._make_rec(status="recording", task_id="dvr-recording-42") + original_task_id = rec.task_id + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + rec.refresh_from_db() + self.assertEqual(rec.task_id, original_task_id) + + @patch("apps.channels.signals.revoke_task") + def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke): + """The guard must NOT apply to upcoming recordings — existing behavior preserved.""" + rec = self._make_rec(status="", task_id="dvr-recording-77") + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + mock_revoke.assert_called_once_with("dvr-recording-77") + + @patch("apps.channels.signals.revoke_task") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke): + """pre_save reads status from DB (old object), not from the instance being saved.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + task_id="dvr-recording-66", + custom_properties={"status": "recording"}, + ) + # Simulate: DB status changes to 'completed' behind the instance's back + Recording.objects.filter(pk=rec.pk).update( + custom_properties={"status": "completed"} + ) + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + # revoke_task should be called because DB status is "completed", not "recording" + mock_revoke.assert_called_once_with("dvr-recording-66") diff --git a/apps/channels/tests/test_recording_metadata.py b/apps/channels/tests/test_recording_metadata.py new file mode 100644 index 00000000..d4db6a2d --- /dev/null +++ b/apps/channels/tests/test_recording_metadata.py @@ -0,0 +1,379 @@ +"""Tests for recording metadata endpoints and logo proxy negative cache. + +Covers: + - update_metadata endpoint: title/description, user_edited flag, validation + - refresh_artwork endpoint: returns immediately, background thread behavior + - Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears +""" +import time as time_mod +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording, Logo +from apps.channels.api_views import RecordingViewSet, LogoViewSet + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="metadata_test_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +# --------------------------------------------------------------------------- +# update_metadata endpoint +# --------------------------------------------------------------------------- + +class UpdateMetadataTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/update-metadata/""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _update(self, rec, data): + request = self.factory.post( + f"/api/channels/recordings/{rec.id}/update-metadata/", + data, format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "update_metadata"}) + return view(request, pk=rec.id) + + def _make_rec(self, custom_properties=None): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties=custom_properties or {}, + ) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_update_title_only(self, _ws): + rec = self._make_rec() + response = self._update(rec, {"title": "My Show"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + program = rec.custom_properties["program"] + self.assertEqual(program["title"], "My Show") + self.assertTrue(program["user_edited"]) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_update_description_only(self, _ws): + rec = self._make_rec({"program": {"title": "Existing Title"}}) + response = self._update(rec, {"description": "A great episode"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + program = rec.custom_properties["program"] + self.assertEqual(program["description"], "A great episode") + self.assertEqual(program["title"], "Existing Title") + self.assertTrue(program["user_edited"]) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_update_both_fields(self, _ws): + rec = self._make_rec() + response = self._update(rec, {"title": "New Title", "description": "New Desc"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + program = rec.custom_properties["program"] + self.assertEqual(program["title"], "New Title") + self.assertEqual(program["description"], "New Desc") + self.assertTrue(program["user_edited"]) + + def test_no_fields_returns_400(self): + rec = self._make_rec() + response = self._update(rec, {}) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.data.get("success")) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_whitespace_trimmed(self, _ws): + rec = self._make_rec() + response = self._update(rec, {"title": " Padded Title "}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title") + + def test_whitespace_only_title_returns_400(self): + """Whitespace-only title and description should be rejected.""" + rec = self._make_rec() + response = self._update(rec, {"title": " ", "description": " "}) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.data.get("success")) + + def test_whitespace_only_title_with_valid_description(self): + """Whitespace-only title is ignored; valid description is accepted.""" + rec = self._make_rec({"program": {"title": "Original"}}) + with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None): + response = self._update(rec, {"title": " ", "description": "Valid desc"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + # Title should remain unchanged since the whitespace-only value is not applied + self.assertEqual(rec.custom_properties["program"]["title"], "Original") + self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc") + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_creates_program_dict_when_absent(self, _ws): + """Recording with no program dict gets one created.""" + rec = self._make_rec({"status": "completed"}) + response = self._update(rec, {"title": "Brand New"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + self.assertIn("program", rec.custom_properties) + self.assertEqual(rec.custom_properties["program"]["title"], "Brand New") + + def test_returns_404_for_nonexistent(self): + request = self.factory.post( + "/api/channels/recordings/99999/update-metadata/", + {"title": "Ghost"}, format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "update_metadata"}) + self.assertEqual(view(request, pk=99999).status_code, 404) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_sends_websocket_event(self, mock_ws): + rec = self._make_rec() + self._update(rec, {"title": "WS Test"}) + mock_ws.assert_called_once() + payload = mock_ws.call_args[0][2] + self.assertEqual(payload["type"], "recording_updated") + self.assertEqual(payload["recording_id"], rec.id) + + @patch("core.utils.send_websocket_update", side_effect=Exception("WS down")) + def test_ws_failure_does_not_fail_request(self, _ws): + """WebSocket errors are silenced — the save still succeeds.""" + rec = self._make_rec() + response = self._update(rec, {"title": "Resilient"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["program"]["title"], "Resilient") + + +# --------------------------------------------------------------------------- +# refresh_artwork endpoint +# --------------------------------------------------------------------------- + +class RefreshArtworkTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/refresh-artwork/""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _refresh(self, rec): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + return view(request, pk=rec.id) + + def _make_rec(self, custom_properties=None): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties=custom_properties or {}, + ) + + @patch("threading.Thread") + def test_returns_200_immediately(self, mock_thread): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + response = self._refresh(rec) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data.get("success")) + + @patch("threading.Thread") + def test_spawns_background_thread(self, mock_thread): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + self._refresh(rec) + mock_thread.assert_called_once() + self.assertTrue(mock_thread.call_args[1].get("daemon", False)) + mock_thread.return_value.start.assert_called_once() + + def test_returns_404_for_nonexistent(self): + request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + self.assertEqual(view(request, pk=99999).status_code, 404) + + @patch("django.db.close_old_connections") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_no_downgrade_to_channel_logo(self, _ws, _close): + """When the pipeline returns the channel's own logo, existing poster is preserved.""" + logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png") + self.channel.logo = logo + self.channel.save() + rec = self._make_rec({ + "poster_logo_id": 999, # existing real poster + "poster_url": "https://tmdb.com/real-poster.jpg", + }) + + with patch("apps.channels.tasks._resolve_poster_for_program", + return_value=(logo.id, None)): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/") + force_authenticate(request, user=self.user) + + # Run synchronously by intercepting the thread + captured_fn = None + def capture_thread(*args, **kwargs): + nonlocal captured_fn + captured_fn = kwargs.get("target") or args[0] + mock = MagicMock() + mock.start = lambda: captured_fn(*kwargs.get("args", ())) + return mock + + with patch("threading.Thread", side_effect=capture_thread): + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + view(request, pk=rec.id) + + rec.refresh_from_db() + # Existing poster should be preserved — not downgraded to channel logo + self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999) + self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg") + + @patch("django.db.close_old_connections") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_upgrade_from_no_poster(self, _ws, _close): + """When a recording has no poster and the pipeline finds one, it gets updated.""" + rec = self._make_rec({"program": {"title": "Some Show", "id": 42}}) + + with patch("apps.channels.tasks._resolve_poster_for_program", + return_value=(555, "https://tmdb.com/new-poster.jpg")): + captured_fn = None + def capture_thread(*args, **kwargs): + nonlocal captured_fn + captured_fn = kwargs.get("target") or args[0] + mock = MagicMock() + mock.start = lambda: captured_fn(*kwargs.get("args", ())) + return mock + + with patch("threading.Thread", side_effect=capture_thread): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + view(request, pk=rec.id) + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555) + self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg") + + +# --------------------------------------------------------------------------- +# Logo proxy negative cache +# --------------------------------------------------------------------------- + +class LogoNegativeCacheTests(TestCase): + """Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache().""" + + def setUp(self): + from apps.channels import api_views + self._failures = api_views._logo_fetch_failures + self._failures.clear() + self.factory = APIRequestFactory() + self.user = _make_admin() + + def _fetch_logo(self, logo): + request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/") + force_authenticate(request, user=self.user) + view = LogoViewSet.as_view({"get": "cache"}) + return view(request, pk=logo.id) + + def test_failed_url_cached_on_non_200(self): + """Non-200 response adds URL to negative cache.""" + logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png") + mock_resp = MagicMock(status_code=404) + with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 404) + self.assertIn("https://dead-cdn.com/logo.png", self._failures) + + def test_cached_failure_returns_404_immediately(self): + """Subsequent request for a cached-failed URL returns 404 without making a request.""" + logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png") + self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300 + + with patch("apps.channels.api_views.requests.get") as mock_get: + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 404) + mock_get.assert_not_called() + + def test_expired_cache_entry_allows_retry(self): + """After TTL expires, a new request is made.""" + logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png") + self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired + + mock_resp = MagicMock(status_code=200) + mock_resp.headers = {"Content-Type": "image/png"} + mock_resp.iter_content = MagicMock(return_value=[b"img"]) + with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 200) + + def test_success_clears_previous_failure(self): + """A successful fetch removes the URL from the failure cache.""" + url = "https://recovered.com/logo.png" + logo = Logo.objects.create(name="Recovered", url=url) + self._failures[url] = time_mod.monotonic() - 1 # expired + + mock_resp = MagicMock(status_code=200) + mock_resp.headers = {"Content-Type": "image/png"} + mock_resp.iter_content = MagicMock(return_value=[b"img"]) + with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + self._fetch_logo(logo) + self.assertNotIn(url, self._failures) + + def test_request_exception_cached(self): + """Network errors are cached the same as non-200 responses.""" + import requests + logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png") + with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 404) + self.assertIn("https://timeout.com/logo.png", self._failures) + + def test_eviction_when_cache_exceeds_256(self): + """Stale entries are evicted when the cache grows past 256.""" + now = time_mod.monotonic() + # Fill with 257 expired entries + for i in range(257): + self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired + + logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png") + import requests + with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + self._fetch_logo(logo) + + # Expired entries should be evicted + old_entries = [k for k in self._failures if k.startswith("https://old-")] + self.assertEqual(len(old_entries), 0) + # New failure entry should exist + self.assertIn("https://trigger-evict.com/logo.png", self._failures) diff --git a/apps/channels/tests/test_recording_pipeline.py b/apps/channels/tests/test_recording_pipeline.py new file mode 100644 index 00000000..b1362422 --- /dev/null +++ b/apps/channels/tests/test_recording_pipeline.py @@ -0,0 +1,524 @@ +"""Tests for recent DVR fixes. + +Covers: + 1. Collision avoidance: _build_output_paths checks both .mkv and .ts files + 2. Logo guard: _resolve_poster_for_program skips external APIs when title ≈ channel name + 3. Recording status lifecycle: status transitions visible via API + 4. Concat flags: error-tolerant ffmpeg flags used for segment concatenation + 5. Recovery skip-list: "recording" status NOT in terminal skip list +""" +import os +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="dvr_fixes_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +def _make_channel(name="Test Channel", number=100): + return Channel.objects.create(channel_number=number, name=name) + + +def _make_recording(channel, **overrides): + now = timezone.now() + defaults = { + "channel": channel, + "start_time": now - timedelta(hours=1), + "end_time": now + timedelta(hours=1), + "custom_properties": {}, + } + defaults.update(overrides) + return Recording.objects.create(**defaults) + + +# ========================================================================= +# 1. Collision avoidance — _build_output_paths +# ========================================================================= + +class CollisionAvoidanceTests(TestCase): + """_build_output_paths must increment the filename counter when + EITHER the .mkv OR the .ts file already exists with size > 0.""" + + def _call(self, channel, program, start, end): + from apps.channels.tasks import _build_output_paths + return _build_output_paths(channel, program, start, end) + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_no_collision_when_nothing_exists(self, _tv, _fb): + """Fresh path — no files exist, counter stays at 1.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + raise OSError("No such file") + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + # Should NOT have a _2 suffix + self.assertNotIn("_2", final) + self.assertTrue(final.endswith(".mkv")) + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb): + """Pre-restart scenario: MKV is 0-byte placeholder, TS has real data. + The old code only checked MKV size, so it would reuse the path. + The fix also checks TS, so it must increment.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + if "_2" in path: + raise OSError("No such file") + result = MagicMock() + if path.endswith('.mkv'): + result.st_size = 0 # MKV is 0-byte placeholder + elif path.endswith('.ts'): + result.st_size = 5000000 # TS has real data from pre-restart + else: + result.st_size = 0 + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + # Must have incremented to _2 + self.assertIn("_2", final, "Should increment counter when TS file has data") + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_collision_when_mkv_has_data(self, _tv, _fb): + """Standard collision: MKV file has data, should increment.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + if "_2" in path: + raise OSError("No such file") + result = MagicMock() + if path.endswith('.mkv'): + result.st_size = 1000000 # MKV has data + else: + result.st_size = 0 + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + self.assertIn("_2", final, "Should increment counter when MKV file has data") + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_no_collision_when_both_zero_bytes(self, _tv, _fb): + """Both MKV and TS exist but are 0 bytes — no collision.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + result = MagicMock() + result.st_size = 0 # All files empty + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + self.assertNotIn("_2", final, "Should NOT increment when all files are empty") + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_collision_increments_to_3_when_2_also_occupied(self, _tv, _fb): + """When both base and _2 are occupied, should go to _3.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + if "_3" in path: + raise OSError("No such file") + result = MagicMock() + if path.endswith('.ts'): + result.st_size = 5000000 + else: + result.st_size = 0 + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied") + + +# ========================================================================= +# 2. Logo guard — _resolve_poster_for_program +# ========================================================================= + +class LogoGuardTests(TestCase): + """When the program title matches the channel name, external API + searches (VOD, TMDB, OMDb, TVMaze, iTunes) must be skipped.""" + + def _call(self, channel_name, program, channel_logo_id=None): + from apps.channels.tasks import _resolve_poster_for_program + return _resolve_poster_for_program(channel_name, program, channel_logo_id) + + @patch("apps.channels.tasks.requests.get") + def test_channel_name_as_title_skips_external_apis(self, mock_get): + """Title = 'USA A&E SD*', channel = 'USA A&E SD*' → no external calls.""" + program = {"title": "USA A&E SD*"} + logo_id, url = self._call("USA A&E SD*", program, channel_logo_id=42) + + # Should NOT have called any external APIs + mock_get.assert_not_called() + # Should fall back to channel logo + self.assertEqual(logo_id, 42) + self.assertIsNone(url) + + @patch("apps.channels.tasks.requests.get") + def test_channel_name_normalized_match(self, mock_get): + """Title = 'fox news', channel = 'FOX-News*' → normalized match, skip APIs.""" + program = {"title": "fox news"} + logo_id, url = self._call("FOX-News*", program, channel_logo_id=99) + + mock_get.assert_not_called() + self.assertEqual(logo_id, 99) + + @patch("apps.channels.tasks.requests.get") + def test_real_title_still_searched(self, mock_get): + """Title = 'Breaking Bad' on channel 'AMC' → should try external APIs.""" + # Mock TVMaze returning a result + mock_resp = MagicMock(ok=True, status_code=200) + mock_resp.json.return_value = { + "image": {"original": "https://tvmaze.com/breaking-bad.jpg"} + } + mock_get.return_value = mock_resp + + program = {"title": "Breaking Bad"} + logo_id, url = self._call("AMC", program) + + # Should have made at least one external API call + self.assertTrue(mock_get.called, "Should search external APIs for real titles") + self.assertIsNotNone(url) + + @patch("apps.channels.tasks.requests.get") + def test_no_title_skips_to_channel_logo(self, mock_get): + """No title at all → falls through to channel logo, no API calls.""" + program = {} + logo_id, url = self._call("SomeChannel", program, channel_logo_id=55) + + mock_get.assert_not_called() + self.assertEqual(logo_id, 55) + + @patch("apps.channels.tasks.requests.get") + def test_epg_image_still_used_even_when_title_is_channel_name(self, mock_get): + """Even when title = channel name, Stage 1 (EPG images) should still work.""" + from apps.epg.models import ProgramData, EPGSource, EPGData + + # Create an EPG source + EPGData entry + program with an icon URL + epg_source = EPGSource.objects.create(source_type="xmltv", name="Test EPG") + epg_data = EPGData.objects.create(tvg_id="test.ch", epg_source=epg_source) + prog = ProgramData.objects.create( + epg=epg_data, + title="Test Channel HD", + start_time=timezone.now() - timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1), + custom_properties={"icon": "https://epg-cdn.com/test-icon.png"}, + ) + + program = {"title": "Test Channel HD", "id": prog.id} + + # Mock _validate_url to return True for the icon URL + with patch("apps.channels.tasks._validate_url", return_value=True): + logo_id, url = self._call("Test Channel HD", program, channel_logo_id=10) + + # EPG icon should still be used (Stage 1 doesn't depend on title guard) + self.assertEqual(url, "https://epg-cdn.com/test-icon.png") + mock_get.assert_not_called() + + +# ========================================================================= +# 3. Recording status lifecycle via API +# ========================================================================= + +class RecordingStatusLifecycleTests(TestCase): + """Verify recording status transitions and that terminal recordings + are properly filterable (supports the red-dot fix in guideUtils).""" + + def setUp(self): + self.channel = _make_channel("Status Test Channel", 200) + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _list_recordings(self): + from apps.channels.api_views import RecordingViewSet + request = self.factory.get("/api/channels/recordings/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"get": "list"}) + return view(request) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_stopped_recording_has_terminal_status(self, _ws): + """After stop, custom_properties.status = 'stopped'.""" + from apps.channels.api_views import RecordingViewSet + + rec = _make_recording(self.channel, custom_properties={ + "status": "recording", + "program": {"id": 1, "title": "Live Show"}, + }) + + request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "stop"}) + + with patch("apps.channels.signals.revoke_task"): + response = view(request, pk=rec.id) + + self.assertIn(response.status_code, [200, 204]) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("status"), "stopped") + + def test_listing_includes_status_in_custom_properties(self): + """API listing returns custom_properties with status field.""" + _make_recording(self.channel, custom_properties={ + "status": "recording", + "program": {"id": 1, "title": "Recording Show"}, + }) + _make_recording(self.channel, custom_properties={ + "status": "stopped", + "program": {"id": 2, "title": "Stopped Show"}, + }) + + response = self._list_recordings() + self.assertEqual(response.status_code, 200) + + statuses = [r["custom_properties"].get("status") for r in response.data] + self.assertIn("recording", statuses) + self.assertIn("stopped", statuses) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_delete_recording_removes_from_listing(self, _ws): + """Deleting a recording removes it from the listing entirely.""" + from apps.channels.api_views import RecordingViewSet + + rec = _make_recording(self.channel, custom_properties={ + "status": "stopped", + "program": {"id": 3, "title": "To Delete"}, + }) + rec_id = rec.id + + request = self.factory.delete(f"/api/channels/recordings/{rec_id}/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"delete": "destroy"}) + + with patch("apps.channels.signals.revoke_task"): + response = view(request, pk=rec_id) + + self.assertIn(response.status_code, [200, 204]) + self.assertFalse(Recording.objects.filter(id=rec_id).exists()) + + +# ========================================================================= +# 4. Concat flags — error-tolerant ffmpeg +# ========================================================================= + +class ConcatFlagsTests(TestCase): + """Verify that the finalize phase uses error-tolerant ffmpeg flags + when concatenating pre-restart segments.""" + + def test_concat_command_includes_error_tolerant_flags(self): + """Inspect the source code to confirm error-tolerant flags are present. + This is a static analysis test — no ffmpeg execution needed.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + # The concat subprocess.run call must include these flags + self.assertIn("+genpts+igndts+discardcorrupt", source, + "Concat must use +genpts+igndts+discardcorrupt fflags") + self.assertIn("ignore_err", source, + "Concat must use -err_detect ignore_err") + self.assertIn("-f", source) + self.assertIn("concat", source) + + def test_concat_goes_directly_to_mkv(self): + """Concat must produce MKV directly (not intermediate .ts) to + preserve timestamp boundaries and avoid playback freeze at splice.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + # Must contain reset_timestamps for proper segment boundary handling + self.assertIn("reset_timestamps", source, + "Concat must use -reset_timestamps 1 for seamless seeking") + # Must write directly to final_path (MKV), not an intermediate .ts + self.assertIn("_concat_did_remux", source, + "Concat path must set flag to skip separate remux step") + + def test_segment_time_metadata_present(self): + """Verify concat uses -segment_time_metadata for boundary awareness.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + self.assertIn("segment_time_metadata", source, + "Concat must use -segment_time_metadata 1 for segment boundary handling") + + +# ========================================================================= +# 5. Recovery skip-list +# ========================================================================= + +class RecoverySkipListTests(TestCase): + """Verify that the recovery function does NOT skip 'recording' status, + since that's the exact status recordings have when the server crashes.""" + + def test_recording_status_not_in_skip_list(self): + """Inspect recover_recordings_on_startup to ensure 'recording' is + NOT treated as a terminal/skip state.""" + import inspect + from apps.channels.tasks import recover_recordings_on_startup + source = inspect.getsource(recover_recordings_on_startup) + + # Find the skip condition line + # It should be: if current_status in ("completed", "stopped"): + # NOT: if current_status in ("completed", "stopped", "recording"): + lines = source.split('\n') + skip_line = None + for line in lines: + if 'current_status in' in line and ('completed' in line or 'stopped' in line): + skip_line = line.strip() + break + + self.assertIsNotNone(skip_line, "Should find the skip-list condition") + self.assertNotIn('"recording"', skip_line, + "Skip list must NOT contain 'recording' — " + "that's the status of crashed mid-stream recordings that need recovery") + + @patch("core.utils.RedisClient") + @patch("apps.channels.tasks.run_recording") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_recovery_processes_recording_status(self, _ws, mock_run, mock_redis_cls): + """A recording with status='recording' should be recovered, not skipped.""" + mock_redis_conn = MagicMock() + mock_redis_conn.set.return_value = True # Acquire lock + mock_redis_cls.get_client.return_value = mock_redis_conn + + channel = _make_channel("Recovery Test", 300) + now = timezone.now() + rec = _make_recording(channel, custom_properties={ + "status": "recording", + "program": {"title": "Crashed Show"}, + }, end_time=now + timedelta(hours=2)) + + from apps.channels.tasks import recover_recordings_on_startup + + with patch("apps.channels.signals.revoke_task"): + result = recover_recordings_on_startup() + + # The recording should have been dispatched for recovery + self.assertTrue(mock_run.apply_async.called, + "Recording with status='recording' should be dispatched for recovery") + + @patch("core.utils.RedisClient") + @patch("apps.channels.tasks.run_recording") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_recovery_skips_stopped_recordings(self, _ws, mock_run, mock_redis_cls): + """A recording with status='stopped' should be skipped by recovery.""" + mock_redis_conn = MagicMock() + mock_redis_conn.set.return_value = True + mock_redis_cls.get_client.return_value = mock_redis_conn + + channel = _make_channel("Recovery Skip Test", 301) + now = timezone.now() + rec = _make_recording(channel, custom_properties={ + "status": "stopped", + "program": {"title": "Finished Show"}, + }, end_time=now + timedelta(hours=2)) + + from apps.channels.tasks import recover_recordings_on_startup + with patch("apps.channels.signals.revoke_task"): + recover_recordings_on_startup() + + # Should NOT have dispatched a recovery task + mock_run.apply_async.assert_not_called() + + +# ========================================================================= +# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId) +# ========================================================================= + +class MapRecordingsByProgramIdTests(TestCase): + """These test the BACKEND side — confirming that recording status + is preserved in the API response so the frontend can filter on it. + + The actual frontend filtering is covered by frontend/src/pages/__tests__/DVR.test.jsx + and the guideUtils code, but we verify the data contract here.""" + + def test_recording_custom_properties_status_persisted(self): + """Recording status in custom_properties survives save/load cycle.""" + channel = _make_channel("Red Dot Test", 400) + rec = _make_recording(channel, custom_properties={ + "status": "stopped", + "program": {"id": 42, "title": "A Show"}, + }) + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], "stopped") + + def test_terminal_statuses_are_well_defined(self): + """Verify the terminal status set matches what the frontend uses.""" + # These are the statuses that should NOT show a red dot in the Guide + terminal = {"stopped", "completed", "interrupted", "failed"} + channel = _make_channel("Terminal Status Test", 410) + + # Verify each status is a valid recording status + for status in terminal: + rec = _make_recording(channel, custom_properties={ + "status": status, + "program": {"id": 100, "title": "Test"}, + }) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], status) diff --git a/apps/channels/tests/test_recording_scheduling.py b/apps/channels/tests/test_recording_scheduling.py new file mode 100644 index 00000000..c615c151 --- /dev/null +++ b/apps/channels/tests/test_recording_scheduling.py @@ -0,0 +1,585 @@ +"""Tests for DVR recording scheduling with ClockedSchedule. + +Uses ClockedSchedule instead of apply_async with countdown because Redis +visibility_timeout (default 3600s) causes task redelivery for long countdowns, +leading to duplicate recordings. +""" +from datetime import timedelta +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.utils import timezone +from django_celery_beat.models import ClockedSchedule, PeriodicTask + +from apps.channels.models import Channel, Recording +from apps.channels.signals import ( + schedule_recording_task, + revoke_task, + _dvr_task_name, +) + + +class ScheduleRecordingTaskTests(TestCase): + """Tests for schedule_recording_task().""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.run_recording") + def test_future_recording_creates_periodic_task(self, mock_run_recording): + """Recordings in the future create a ClockedSchedule + PeriodicTask.""" + future_time = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future_time, + end_time=future_time + timedelta(hours=1), + ) + + task_id = schedule_recording_task(rec, eta=future_time) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + + pt = PeriodicTask.objects.get(name=expected_name) + self.assertTrue(pt.one_off) + self.assertTrue(pt.enabled) + self.assertEqual(pt.task, "apps.channels.tasks.run_recording") + self.assertIsNotNone(pt.clocked) + + # apply_async should not have been called + mock_run_recording.apply_async.assert_not_called() + + @patch("apps.channels.signals.run_recording") + def test_immediate_recording_creates_periodic_task(self, mock_run_recording): + """Recordings starting now also use ClockedSchedule for consistency.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now, + end_time=now + timedelta(hours=1), + ) + + task_id = schedule_recording_task(rec, eta=now) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + self.assertTrue(PeriodicTask.objects.filter(name=expected_name).exists()) + + @patch("apps.channels.signals.run_recording") + def test_past_start_time_clamps_to_now(self, mock_run_recording): + """Recordings with past start_time get clamped to now.""" + past_time = timezone.now() - timedelta(minutes=5) + rec = Recording.objects.create( + channel=self.channel, + start_time=past_time, + end_time=timezone.now() + timedelta(hours=1), + ) + + task_id = schedule_recording_task(rec, eta=past_time) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + pt = PeriodicTask.objects.get(name=expected_name) + # Clocked time should be >= now + self.assertGreaterEqual(pt.clocked.clocked_time, past_time) + + @patch("apps.channels.signals.run_recording") + def test_reschedule_updates_existing_periodic_task(self, mock_run_recording): + """Calling schedule_recording_task twice updates the existing PeriodicTask.""" + future_time = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future_time, + end_time=future_time + timedelta(hours=1), + ) + + schedule_recording_task(rec, eta=future_time) + + # Reschedule with a different time + new_eta = future_time + timedelta(hours=1) + schedule_recording_task(rec, eta=new_eta) + + # Should still be exactly one PeriodicTask + task_name = f"dvr-recording-{rec.id}" + self.assertEqual(PeriodicTask.objects.filter(name=task_name).count(), 1) + + @patch("apps.channels.signals.run_recording") + def test_naive_eta_is_made_aware(self, mock_run_recording): + """A naive (timezone-unaware) eta is made timezone-aware.""" + from datetime import datetime + naive_eta = datetime(2030, 6, 15, 14, 0, 0) + rec = Recording.objects.create( + channel=self.channel, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=2), + ) + + task_id = schedule_recording_task(rec, eta=naive_eta) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + pt = PeriodicTask.objects.get(name=expected_name) + self.assertTrue(timezone.is_aware(pt.clocked.clocked_time)) + + +class RevokeTaskTests(TestCase): + """Tests for revoke_task().""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + def test_revoke_deletes_periodic_task_and_clocked_schedule(self): + """revoke_task deletes the PeriodicTask and orphaned ClockedSchedule.""" + eta = timezone.now() + timedelta(hours=5) + clocked = ClockedSchedule.objects.create(clocked_time=eta) + PeriodicTask.objects.create( + name="dvr-recording-10", + task="apps.channels.tasks.run_recording", + clocked=clocked, + one_off=True, + enabled=True, + ) + + revoke_task("dvr-recording-10") + + self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists()) + self.assertFalse(ClockedSchedule.objects.filter(id=clocked.id).exists()) + + def test_revoke_keeps_shared_clocked_schedule(self): + """ClockedSchedule is kept if another PeriodicTask still references it.""" + eta = timezone.now() + timedelta(hours=5) + clocked = ClockedSchedule.objects.create(clocked_time=eta) + PeriodicTask.objects.create( + name="dvr-recording-10", + task="apps.channels.tasks.run_recording", + clocked=clocked, + one_off=True, + ) + PeriodicTask.objects.create( + name="dvr-recording-11", + task="apps.channels.tasks.run_recording", + clocked=clocked, + one_off=True, + ) + + revoke_task("dvr-recording-10") + + self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists()) + self.assertTrue(ClockedSchedule.objects.filter(id=clocked.id).exists()) + + @patch("apps.channels.signals.AsyncResult") + def test_revoke_falls_back_to_async_result_for_legacy_ids(self, mock_async_result): + """revoke_task falls back to AsyncResult.revoke() for old-style UUIDs.""" + revoke_task("550e8400-e29b-41d4-a716-446655440000") + + mock_async_result.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000") + mock_async_result.return_value.revoke.assert_called_once() + + def test_revoke_none_is_noop(self): + """revoke_task(None) does nothing.""" + revoke_task(None) # Should not raise + + def test_revoke_empty_string_is_noop(self): + """revoke_task('') does nothing.""" + revoke_task("") # Should not raise + + +class DvrTaskNameTests(TestCase): + """Tests for the naming convention helper.""" + + def test_task_name_format(self): + self.assertEqual(_dvr_task_name(42), "dvr-recording-42") + + def test_task_name_fits_in_charfield(self): + name = _dvr_task_name(999999999) + self.assertLessEqual(len(name), 255) + + +class SignalIntegrationTests(TestCase): + """Integration tests for the post_save / post_delete signal handlers.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_save_creates_periodic_task_for_future_recording(self, mock_artwork): + """Saving a future Recording creates a PeriodicTask via post_save signal.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + ) + + rec.refresh_from_db() + task_name = f"dvr-recording-{rec.id}" + self.assertEqual(rec.task_id, task_name) + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_delete_removes_periodic_task(self, mock_artwork): + """Deleting a Recording removes its PeriodicTask.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + ) + + rec.refresh_from_db() + task_name = rec.task_id + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + + rec.delete() + self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists()) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_bulk_delete_cleans_up_all_periodic_tasks(self, mock_artwork): + """Bulk deleting recordings cleans up all their PeriodicTasks.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec_ids = [] + for i in range(5): + rec = Recording.objects.create( + channel=self.channel, + start_time=future + timedelta(hours=i), + end_time=future + timedelta(hours=i + 1), + ) + rec_ids.append(rec.id) + + for rid in rec_ids: + self.assertTrue( + PeriodicTask.objects.filter(name=f"dvr-recording-{rid}").exists() + ) + + Recording.objects.filter(channel=self.channel).delete() + + self.assertEqual( + PeriodicTask.objects.filter(name__startswith="dvr-recording-").count(), 0 + ) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_save_schedules_currently_playing_recording(self, mock_artwork): + """A recording with past start_time but future end_time schedules immediately.""" + mock_artwork.apply_async.return_value = MagicMock() + + past_start = timezone.now() - timedelta(minutes=30) + future_end = timezone.now() + timedelta(minutes=30) + rec = Recording.objects.create( + channel=self.channel, + start_time=past_start, + end_time=future_end, + ) + + rec.refresh_from_db() + task_name = f"dvr-recording-{rec.id}" + self.assertEqual(rec.task_id, task_name) + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_save_skips_fully_past_recording(self, mock_artwork): + """A recording with both start_time and end_time in the past is not scheduled.""" + mock_artwork.apply_async.return_value = MagicMock() + + past_start = timezone.now() - timedelta(hours=2) + past_end = timezone.now() - timedelta(hours=1) + rec = Recording.objects.create( + channel=self.channel, + start_time=past_start, + end_time=past_end, + ) + + rec.refresh_from_db() + self.assertIsNone(rec.task_id) + self.assertFalse( + PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists() + ) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_pre_save_revokes_on_time_change(self, mock_artwork): + """Changing a recording's start_time revokes the old task and creates a new one.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + ) + + rec.refresh_from_db() + old_task_name = rec.task_id + self.assertTrue(PeriodicTask.objects.filter(name=old_task_name).exists()) + + # Change the start time — pre_save clears task_id, post_save reschedules + new_future = future + timedelta(hours=3) + rec.start_time = new_future + rec.end_time = new_future + timedelta(hours=1) + rec.save() + + rec.refresh_from_db() + # Old PeriodicTask should be deleted; new one should exist + self.assertIsNotNone(rec.task_id) + self.assertTrue( + PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists() + ) + + +class IdempotencyGuardTests(TestCase): + """Tests for the idempotency guard in run_recording().""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + @patch("apps.channels.tasks.get_channel_layer") + def test_skips_if_already_recording(self, mock_layer): + """run_recording returns early if status is already 'recording'.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now, + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording", "started_at": str(now)}, + ) + + from apps.channels.tasks import run_recording as run_rec_task + result = run_rec_task(rec.id, self.channel.id, str(now), str(now + timedelta(hours=1))) + + self.assertIsNone(result) + # get_channel_layer should not have been called (returned before) + mock_layer.assert_not_called() + + @patch("apps.channels.tasks.get_channel_layer") + def test_skips_if_already_completed(self, mock_layer): + """run_recording returns early if status is already 'completed'.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + custom_properties={"status": "completed"}, + ) + + from apps.channels.tasks import run_recording as run_rec_task + result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time)) + + self.assertIsNone(result) + mock_layer.assert_not_called() + + @patch("apps.channels.tasks.get_channel_layer") + def test_skips_if_already_stopped(self, mock_layer): + """run_recording returns early if status is already 'stopped' (user stopped it early).""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": "stopped", "stopped_at": str(now)}, + ) + + from apps.channels.tasks import run_recording as run_rec_task + result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time)) + + self.assertIsNone(result) + mock_layer.assert_not_called() + + +class ArtworkPrefetchSignalGuardTests(TestCase): + """Tests that the post_save signal does not schedule artwork prefetch when + the recording is in an active or terminal state.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_not_scheduled_when_status_recording(self, mock_artwork): + """post_save must NOT schedule artwork prefetch when status='recording' + to prevent a race that overwrites the running task's status updates.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + + # Simulate a save that run_recording itself might do mid-recording + rec.custom_properties = {"status": "recording", "file_path": "/data/recordings/test.mkv"} + rec.save(update_fields=["custom_properties"]) + + # apply_async was not called for the "recording" save + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_not_scheduled_when_status_completed(self, mock_artwork): + """post_save must NOT schedule artwork prefetch when status='completed'.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={"status": "completed"}, + ) + + rec.custom_properties = {"status": "completed"} + rec.save(update_fields=["custom_properties"]) + + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_not_scheduled_when_status_stopped(self, mock_artwork): + """post_save must NOT schedule artwork prefetch when status='stopped'.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={"status": "stopped"}, + ) + + rec.custom_properties = {"status": "stopped"} + rec.save(update_fields=["custom_properties"]) + + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_scheduled_for_new_upcoming_recording(self, mock_artwork): + """post_save SHOULD schedule artwork prefetch for a newly created upcoming recording.""" + mock_artwork.apply_async.return_value = MagicMock() + future = timezone.now() + timedelta(hours=2) + Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={}, # no status yet — should trigger prefetch + ) + + self.assertTrue(mock_artwork.apply_async.called) + + +class DestroyDvrClientIsolationTests(TestCase): + """Tests that deleting a recording only stops DVR clients when the + recording is actively streaming — never for completed/upcoming recordings + that could share a channel with an unrelated in-progress recording.""" + + def setUp(self): + from django.contrib.auth import get_user_model + from rest_framework.test import APIRequestFactory, force_authenticate + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + User = get_user_model() + self.user = User.objects.create_user( + username="dvr_test_admin", password="pass", + user_level=User.UserLevel.ADMIN, + ) + self.factory = APIRequestFactory() + self.force_authenticate = force_authenticate + + def _delete_recording(self, rec): + from apps.channels.api_views import RecordingViewSet + request = self.factory.delete(f"/api/channels/recordings/{rec.id}/") + self.force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"delete": "destroy"}) + return view(request, pk=rec.id) + + @patch("apps.channels.api_views._stop_dvr_clients") + def test_destroy_completed_recording_does_not_stop_dvr_clients(self, mock_stop): + """Deleting a completed recording must NOT call _stop_dvr_clients.""" + rec = Recording.objects.create( + channel=self.channel, + start_time=timezone.now() - timedelta(hours=2), + end_time=timezone.now() - timedelta(hours=1), + custom_properties={"status": "completed", "file_path": "/data/recordings/test.mkv"}, + ) + self._delete_recording(rec) + mock_stop.assert_not_called() + + @patch("apps.channels.api_views._stop_dvr_clients") + def test_destroy_upcoming_recording_does_not_stop_dvr_clients(self, mock_stop): + """Deleting an upcoming (scheduled) recording must NOT call _stop_dvr_clients.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={}, + ) + self._delete_recording(rec) + mock_stop.assert_not_called() + + @patch("apps.channels.api_views._stop_dvr_clients") + def test_destroy_active_recording_does_stop_dvr_clients(self, mock_stop): + """Deleting an in-progress recording MUST call _stop_dvr_clients.""" + mock_stop.return_value = 1 + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=5), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + self._delete_recording(rec) + mock_stop.assert_called_once_with(str(self.channel.uuid), recording_id=rec.id) + + +class PeriodicTaskCleanupOnExecutionTests(TestCase): + """Tests for PeriodicTask cleanup when run_recording starts.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.prefetch_recording_artwork") + @patch("apps.channels.tasks.get_channel_layer") + def test_periodic_task_cleaned_up_on_execution(self, mock_layer, mock_artwork): + """When run_recording executes, it deletes its own PeriodicTask.""" + mock_layer.return_value = MagicMock() + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={}, + ) + + # post_save signal should have created the PeriodicTask + task_name = f"dvr-recording-{rec.id}" + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + pt = PeriodicTask.objects.get(name=task_name) + clocked_id = pt.clocked_id + + from apps.channels.tasks import run_recording as run_rec_task + # This will proceed past guards, clean up the PeriodicTask, then + # eventually fail on the actual stream connection (expected) + try: + run_rec_task(rec.id, self.channel.id, str(future), str(future + timedelta(hours=1))) + except Exception: + pass + + self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists()) + self.assertFalse(ClockedSchedule.objects.filter(id=clocked_id).exists()) diff --git a/apps/channels/tests/test_recording_stop_cancel.py b/apps/channels/tests/test_recording_stop_cancel.py new file mode 100644 index 00000000..e4b22be4 --- /dev/null +++ b/apps/channels/tests/test_recording_stop_cancel.py @@ -0,0 +1,356 @@ +"""Tests for the DVR Stop/Cancel feature set. + +Covers: + - stop() endpoint + - destroy() was_in_progress field in recording_cancelled WebSocket event + - signals.py update_fields re-entrancy guard + - run_recording race guard before status write + - _stop_dvr_clients() DVR client isolation +""" +from datetime import timedelta +from unittest.mock import MagicMock, AsyncMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording +from apps.channels.api_views import RecordingViewSet, _stop_dvr_clients + + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="stop_test_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +def _async_channel_layer_mock(): + layer = MagicMock() + layer.group_send = AsyncMock() + return layer + + +class StopEndpointTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/stop/""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=99, name="Stop Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _stop(self, rec): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "stop"}) + return view(request, pk=rec.id) + + def _make_rec(self, status="recording"): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": status}, + ) + + @patch("core.utils.send_websocket_update") + @patch("threading.Thread") + def test_stop_writes_status_to_db_before_returning(self, mock_thread, mock_ws): + """DB write is synchronous — run_recording polls for this.""" + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + response = self._stop(rec) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data.get("success")) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("status"), "stopped") + + @patch("core.utils.send_websocket_update") + @patch("threading.Thread") + def test_stop_writes_stopped_at_timestamp(self, mock_thread, mock_ws): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + self._stop(rec) + rec.refresh_from_db() + self.assertIn("stopped_at", rec.custom_properties) + + def test_stop_calls_stop_dvr_clients_in_background(self): + """stop() spawns a background thread whose target calls _stop_dvr_clients.""" + rec = self._make_rec() + + with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop, \ + patch("core.utils.send_websocket_update"), \ + patch("threading.Thread") as mock_thread: + mock_thread.return_value.start = MagicMock() + self._stop(rec) + + # Verify a daemon thread was spawned + mock_thread.assert_called_once() + thread_kwargs = mock_thread.call_args[1] + self.assertTrue(thread_kwargs.get("daemon"), "Thread must be daemon") + + # Execute the captured target with DB connection close patched out + target = thread_kwargs["target"] + with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop2, \ + patch("apps.channels.signals.revoke_task", side_effect=Exception("skip")), \ + patch("django.db.connection") as mock_conn: + target() + + self.assertTrue(mock_stop2.called) + args, kwargs = mock_stop2.call_args + actual_rec_id = kwargs.get("recording_id") or (args[1] if len(args) > 1 else None) + self.assertEqual(actual_rec_id, rec.id) + + def test_stop_returns_404_for_nonexistent(self): + request = self.factory.post("/api/channels/recordings/99999/stop/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "stop"}) + self.assertEqual(view(request, pk=99999).status_code, 404) + + @patch("core.utils.send_websocket_update") + @patch("threading.Thread") + def test_stop_idempotent_on_already_stopped(self, mock_thread, mock_ws): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec(status="stopped") + self.assertEqual(self._stop(rec).status_code, 200) + + +class CancelDestroyWasInProgressTests(TestCase): + """was_in_progress field in the recording_cancelled WebSocket event.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=98, name="Cancel Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _delete(self, rec): + request = self.factory.delete(f"/api/channels/recordings/{rec.id}/") + force_authenticate(request, user=self.user) + return RecordingViewSet.as_view({"delete": "destroy"})(request, pk=rec.id) + + @patch("apps.channels.api_views._stop_dvr_clients", return_value=1) + @patch("core.utils.send_websocket_update") + def test_in_progress_sends_was_in_progress_true(self, mock_ws, _): + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=10), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + self._delete(rec) + payload = mock_ws.call_args[0][2] + self.assertEqual(payload["type"], "recording_cancelled") + self.assertTrue(payload["was_in_progress"]) + + @patch("core.utils.send_websocket_update") + def test_completed_sends_was_in_progress_false(self, mock_ws): + rec = Recording.objects.create( + channel=self.channel, + start_time=timezone.now() - timedelta(hours=2), + end_time=timezone.now() - timedelta(hours=1), + custom_properties={"status": "completed"}, + ) + self._delete(rec) + self.assertFalse(mock_ws.call_args[0][2]["was_in_progress"]) + + +class SignalUpdateFieldsReentrancyGuardTests(TestCase): + """update_fields guard in schedule_task_on_save prevents redundant WS events.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=97, name="Signal Guard Channel") + + def _create_upcoming(self): + future = timezone.now() + timedelta(hours=2) + return Recording.objects.create( + channel=self.channel, start_time=future, + end_time=future + timedelta(hours=1), custom_properties={}, + ) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_custom_properties_save_skips_artwork(self, mock_artwork): + rec = self._create_upcoming() + mock_artwork.reset_mock() + rec.custom_properties = {"poster_url": "https://example.com/p.jpg"} + rec.save(update_fields=["custom_properties"]) + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_task_id_save_skips_artwork(self, mock_artwork): + rec = self._create_upcoming() + mock_artwork.reset_mock() + rec.task_id = "dvr-recording-999" + rec.save(update_fields=["task_id"]) + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_combined_metadata_save_skips_artwork(self, mock_artwork): + rec = self._create_upcoming() + mock_artwork.reset_mock() + rec.task_id = "dvr-recording-1000" + rec.custom_properties = {"poster_url": "x"} + rec.save(update_fields=["custom_properties", "task_id"]) + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_creation_dispatches_artwork(self, mock_artwork): + mock_artwork.apply_async.return_value = MagicMock() + self._create_upcoming() + self.assertTrue(mock_artwork.apply_async.called) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_scheduling_field_update_dispatches_artwork(self, mock_artwork): + """save(update_fields=['start_time']) is not a metadata save — dispatch runs.""" + mock_artwork.apply_async.return_value = MagicMock() + rec = self._create_upcoming() + mock_artwork.reset_mock() + future = timezone.now() + timedelta(hours=3) + rec.start_time = future + rec.end_time = future + timedelta(hours=1) + rec.save(update_fields=["start_time", "end_time"]) + mock_artwork.apply_async.assert_called() + + +class RunRecordingRaceGuardTests(TestCase): + """Race guard: stop() fires between idempotency check and status write.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=96, name="Race Guard Channel") + + def test_race_guard_exits_when_stopped_at_db_read(self): + """If Recording.objects.get() shows 'stopped', the task must exit + without writing 'recording' to the DB.""" + from apps.channels.tasks import run_recording as run_rec + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=1), + end_time=now + timedelta(hours=1), + custom_properties={}, + ) + mock_layer = _async_channel_layer_mock() + original_get = Recording.objects.get + + def patched_get(*args, **kwargs): + obj = original_get(*args, **kwargs) + if kwargs.get("id") == rec.id or (args and args[0] == rec.id): + obj.custom_properties = {"status": "stopped"} + return obj + + with patch("apps.channels.tasks.get_channel_layer", return_value=mock_layer), \ + patch("core.utils.log_system_event", side_effect=Exception("skip")), \ + patch.object(Recording.objects, "get", side_effect=patched_get): + result = run_rec( + rec.id, self.channel.id, str(rec.start_time), str(rec.end_time), + ) + + self.assertIsNone(result) + rec.refresh_from_db() + self.assertNotEqual( + rec.custom_properties.get("status"), "recording", + "Race guard failed: task overwrote 'stopped' with 'recording'", + ) + + def test_idempotency_guard_catches_stopped_before_channel_layer(self): + """When status='stopped' at the idempotency check, get_channel_layer is never called.""" + from apps.channels.tasks import run_recording as run_rec + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=5), + end_time=now + timedelta(hours=1), + custom_properties={"status": "stopped"}, + ) + with patch("apps.channels.tasks.get_channel_layer") as mock_get_layer: + result = run_rec( + rec.id, self.channel.id, str(rec.start_time), str(rec.end_time), + ) + self.assertIsNone(result) + mock_get_layer.assert_not_called() + + +class StopDvrClientsTests(TestCase): + """_stop_dvr_clients() DVR client isolation.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel") + self._redis = "core.utils.RedisClient" + self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client" + self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel" + + def _mock_redis(self, client_ids, ua_map): + r = MagicMock() + r.smembers.return_value = {c.encode() for c in client_ids} + def hget_side(key, field): + ks = key if isinstance(key, str) else key.decode("utf-8", errors="replace") + for cid, ua in ua_map.items(): + if cid in ks: + return ua.encode() if isinstance(ua, str) else ua + return b"" + r.hget.side_effect = hget_side + return r + + def test_returns_zero_when_redis_none(self): + with patch(self._redis) as rc: + rc.get_client.return_value = None + self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0) + + def test_stops_only_matching_client_when_recording_id_given(self): + r = self._mock_redis( + ["client-a", "client-b"], + {"client-a": "Dispatcharr-DVR/recording-42", + "client-b": "Dispatcharr-DVR/recording-99"}, + ) + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + result = _stop_dvr_clients(str(self.channel.uuid), recording_id=42) + self.assertEqual(result, 1) + stopped = [c[0][1] for c in sc.call_args_list] + self.assertIn("client-a", stopped) + self.assertNotIn("client-b", stopped) + + def test_stops_all_dvr_clients_without_recording_id(self): + r = self._mock_redis( + ["client-a", "client-b"], + {"client-a": "Dispatcharr-DVR/recording-42", + "client-b": "Dispatcharr-DVR/recording-99"}, + ) + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + result = _stop_dvr_clients(str(self.channel.uuid)) + self.assertEqual(result, 2) + + def test_skips_non_dvr_clients(self): + r = self._mock_redis( + ["viewer", "dvr-client"], + {"viewer": "Mozilla/5.0", "dvr-client": "Dispatcharr-DVR/recording-1"}, + ) + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + result = _stop_dvr_clients(str(self.channel.uuid)) + self.assertEqual(result, 1) + stopped = [c[0][1] for c in sc.call_args_list] + self.assertNotIn("viewer", stopped) + + def test_returns_zero_for_empty_channel(self): + r = MagicMock() + r.smembers.return_value = set() + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0) + sc.assert_not_called() + + def test_never_calls_stop_channel(self): + """Must not stop the whole channel proxy — only individual clients.""" + r = self._mock_redis(["dvr-1"], {"dvr-1": "Dispatcharr-DVR/recording-1"}) + with patch(self._redis) as rc, patch(self._sc), patch(self._sch) as sch: + rc.get_client.return_value = r + _stop_dvr_clients(str(self.channel.uuid)) + sch.assert_not_called() diff --git a/apps/channels/tests/test_ts_proxy_keepalive.py b/apps/channels/tests/test_ts_proxy_keepalive.py new file mode 100644 index 00000000..db388b3d --- /dev/null +++ b/apps/channels/tests/test_ts_proxy_keepalive.py @@ -0,0 +1,324 @@ +"""Tests for ts_proxy keepalive and stats-update behavior. + +Covers: + - stream_generator._should_send_keepalive() owner vs non-owner worker paths + - stream_generator._should_send_keepalive() Redis last_data health check + - client_manager._do_stats_update() error handling and WebSocket dispatch + - client_manager.remove_client() non-blocking stats update + - Keepalive/DVR-timeout timing invariants +""" +import threading +import time +from unittest.mock import MagicMock, patch + +from django.test import TestCase + + +# --------------------------------------------------------------------------- +# _should_send_keepalive: owner worker path +# --------------------------------------------------------------------------- + +class OwnerWorkerKeepaliveTests(TestCase): + """Owner worker has a stream_manager; keepalive logic uses it directly.""" + + def _make_generator(self, healthy, at_buffer_head, consecutive_empty): + from apps.proxy.ts_proxy.stream_generator import StreamGenerator + gen = StreamGenerator.__new__(StreamGenerator) + gen.channel_id = "00000000-0000-0000-0000-000000000001" + gen.client_id = "test-client" + + buffer = MagicMock() + buffer.index = 10 if at_buffer_head else 100 + gen.local_index = 10 + gen.buffer = buffer + + stream_manager = MagicMock() + stream_manager.healthy = healthy + gen.stream_manager = stream_manager + + gen.consecutive_empty = consecutive_empty + return gen + + def test_owner_healthy_returns_false(self): + """Owner worker, healthy stream -> no keepalive.""" + gen = self._make_generator(healthy=True, at_buffer_head=True, consecutive_empty=10) + self.assertFalse(gen._should_send_keepalive(gen.local_index)) + + def test_owner_unhealthy_at_head_returns_true(self): + """Owner worker, unhealthy stream, at buffer head -> send keepalive.""" + gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=10) + self.assertTrue(gen._should_send_keepalive(gen.local_index)) + + def test_owner_unhealthy_not_at_head_returns_false(self): + """Owner worker, unhealthy stream, but NOT at buffer head -> no keepalive.""" + gen = self._make_generator(healthy=False, at_buffer_head=False, consecutive_empty=10) + self.assertFalse(gen._should_send_keepalive(gen.local_index)) + + def test_owner_insufficient_consecutive_empty_returns_false(self): + """Owner worker, unhealthy, at head but consecutive_empty < 5 -> no keepalive.""" + gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=3) + self.assertFalse(gen._should_send_keepalive(gen.local_index)) + + def test_owner_exactly_5_consecutive_empty_returns_true(self): + """consecutive_empty == 5 is the minimum threshold.""" + gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=5) + self.assertTrue(gen._should_send_keepalive(gen.local_index)) + + +# --------------------------------------------------------------------------- +# _should_send_keepalive: non-owner worker path +# --------------------------------------------------------------------------- + +class NonOwnerWorkerKeepaliveTests(TestCase): + """Non-owner worker has stream_manager=None; health determined from Redis.""" + + def _make_generator(self, consecutive_empty=10): + from apps.proxy.ts_proxy.stream_generator import StreamGenerator + gen = StreamGenerator.__new__(StreamGenerator) + gen.channel_id = "00000000-0000-0000-0000-000000000002" + gen.client_id = "test-client-nonowner" + + buffer = MagicMock() + buffer.index = 10 + gen.local_index = 10 + gen.buffer = buffer + + gen.stream_manager = None # non-owner worker + gen.consecutive_empty = consecutive_empty + return gen + + def _mock_proxy_server(self, last_data_value): + """Return a mock ProxyServer with a redis_client pre-configured.""" + server = MagicMock() + redis_client = MagicMock() + server.redis_client = redis_client + redis_client.get.return_value = last_data_value + return server + + def test_non_owner_fresh_data_returns_false(self): + """Non-owner, last_data < 10s ago -> stream healthy -> no keepalive.""" + gen = self._make_generator() + fresh_ts = str(time.time() - 2.0).encode() + server = self._mock_proxy_server(fresh_ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result, "Fresh data should NOT trigger keepalive") + + def test_non_owner_stale_data_returns_true(self): + """Non-owner, last_data >= 10s ago -> stream unhealthy -> send keepalive.""" + gen = self._make_generator() + stale_ts = str(time.time() - 12.0).encode() + server = self._mock_proxy_server(stale_ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertTrue(result, "Stale data (12s) should trigger keepalive") + + def test_non_owner_exactly_at_timeout_returns_true(self): + """Data age exactly equal to CONNECTION_TIMEOUT (10s) -> send keepalive.""" + gen = self._make_generator() + ts = str(time.time() - 10.0).encode() + server = self._mock_proxy_server(ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertTrue(result, "Data at exactly timeout threshold should trigger keepalive") + + def test_non_owner_no_redis_key_returns_true(self): + """Non-owner, last_data key missing from Redis -> assume unhealthy.""" + gen = self._make_generator() + server = self._mock_proxy_server(None) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertTrue(result, "Missing last_data key should trigger keepalive") + + def test_non_owner_redis_client_none_returns_false(self): + """Non-owner, redis_client is None (disconnected) -> conservative, no keepalive.""" + gen = self._make_generator() + server = MagicMock() + server.redis_client = None + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result, "No redis_client -> conservative, no keepalive") + + def test_non_owner_redis_exception_returns_false(self): + """Non-owner, Redis raises an exception -> conservative, no keepalive.""" + gen = self._make_generator() + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.side_effect = Exception("Redis error") + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result, "Redis error -> conservative, no keepalive") + + def test_non_owner_not_at_buffer_head_returns_false(self): + """Non-owner, NOT at buffer head -> no keepalive regardless of Redis.""" + gen = self._make_generator() + gen.buffer.index = 100 # far ahead of local_index=10 + server = self._mock_proxy_server(None) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result) + + def test_non_owner_insufficient_consecutive_empty_returns_false(self): + """Non-owner, at head, but consecutive_empty < 5 -> no keepalive.""" + gen = self._make_generator(consecutive_empty=2) + stale_ts = str(time.time() - 30.0).encode() + server = self._mock_proxy_server(stale_ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result) + + +# --------------------------------------------------------------------------- +# _do_stats_update: error handling and WebSocket dispatch +# --------------------------------------------------------------------------- + +class DoStatsUpdateTests(TestCase): + """_do_stats_update runs the actual Redis scan + WebSocket call.""" + + def _make_client_manager(self): + from apps.proxy.ts_proxy.client_manager import ClientManager + cm = ClientManager.__new__(ClientManager) + cm.channel_id = "00000000-0000-0000-0000-000000000004" + cm._heartbeat_running = False + return cm + + def test_do_stats_update_calls_send_websocket_update(self): + """_do_stats_update must call send_websocket_update with channel_stats.""" + cm = self._make_client_manager() + + mock_redis = MagicMock() + mock_redis.scan.return_value = (0, []) + + with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \ + patch("redis.Redis.from_url", return_value=mock_redis): + cm._do_stats_update() + + mock_ws.assert_called_once() + event_type = mock_ws.call_args[0][1] + self.assertEqual(event_type, "update") + payload = mock_ws.call_args[0][2] + self.assertEqual(payload["type"], "channel_stats") + + def test_do_stats_update_does_not_raise_on_redis_error(self): + """Redis failure must be swallowed (logged), not propagated.""" + cm = self._make_client_manager() + + with patch("redis.Redis.from_url", side_effect=Exception("Redis down")): + try: + cm._do_stats_update() + except Exception as e: + self.fail(f"_do_stats_update raised an exception: {e}") + + def test_do_stats_update_scans_channel_client_keys(self): + """Must scan for ts_proxy:channel:*:clients pattern.""" + cm = self._make_client_manager() + + mock_redis = MagicMock() + mock_redis.scan.return_value = (0, []) + + with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \ + patch("redis.Redis.from_url", return_value=mock_redis): + cm._do_stats_update() + + scan_call = mock_redis.scan.call_args + self.assertIn("ts_proxy:channel:*:clients", str(scan_call)) + + +# --------------------------------------------------------------------------- +# Integration: remove_client must not block on WebSocket +# --------------------------------------------------------------------------- + +class ClientRemoveIntegrationTests(TestCase): + """When remove_client() fires, _trigger_stats_update must not block.""" + + def test_remove_client_does_not_block_on_websocket(self): + """remove_client() must return quickly even if WebSocket is slow.""" + from apps.proxy.ts_proxy.client_manager import ClientManager + + cm = ClientManager.__new__(ClientManager) + cm.channel_id = "00000000-0000-0000-0000-000000000005" + cm._heartbeat_running = False + cm.clients = {"test-client-1"} + cm.last_heartbeat_time = {"test-client-1": time.time()} + cm.last_active_time = time.time() + cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients" + cm.client_ttl = 60 + cm.worker_id = "worker-1" + cm.proxy_server = MagicMock() + cm.proxy_server.am_i_owner.return_value = False + cm.lock = threading.Lock() + + mock_redis = MagicMock() + mock_redis.hgetall.return_value = {b"ip_address": b"127.0.0.1"} + mock_redis.scard.return_value = 1 + cm.redis_client = mock_redis + + slow_ws_called = threading.Event() + + def slow_websocket(*args, **kwargs): + time.sleep(2.0) + slow_ws_called.set() + + start = time.time() + with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket): + cm.remove_client("test-client-1") + elapsed = time.time() - start + + self.assertLess(elapsed, 1.0, + f"remove_client() blocked for {elapsed:.2f}s waiting for WebSocket " + f"(should dispatch to background thread and return immediately)") + + +# --------------------------------------------------------------------------- +# DVR timeout threshold vs keepalive timing +# --------------------------------------------------------------------------- + +class KeepaliveTimingTests(TestCase): + """Verify that keepalive threshold gives sufficient margin before DVR timeout.""" + + def test_keepalive_threshold_less_than_dvr_timeout(self): + """CONNECTION_TIMEOUT (keepalive trigger) must be < DVR read timeout (15s).""" + from apps.proxy.config import TSConfig as Config + connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10) + dvr_read_timeout = 15 # hard-coded in run_recording: timeout=(10, 15) + self.assertLess( + connection_timeout, + dvr_read_timeout, + f"CONNECTION_TIMEOUT ({connection_timeout}s) must be < DVR timeout ({dvr_read_timeout}s) " + f"so keepalives fire before DVR times out", + ) + + def test_keepalive_interval_is_short(self): + """KEEPALIVE_INTERVAL must be short enough to send multiple keepalives in the gap.""" + from apps.proxy.config import TSConfig as Config + interval = getattr(Config, "KEEPALIVE_INTERVAL", 0.5) + connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10) + remaining_window = 15 - connection_timeout + self.assertGreater( + remaining_window / interval, + 3, + f"KEEPALIVE_INTERVAL ({interval}s) is too long: only " + f"{remaining_window/interval:.1f} keepalives would fit in the " + f"{remaining_window}s window before DVR timeout", + ) diff --git a/apps/channels/tests/test_validate_url.py b/apps/channels/tests/test_validate_url.py new file mode 100644 index 00000000..e52c79fe --- /dev/null +++ b/apps/channels/tests/test_validate_url.py @@ -0,0 +1,169 @@ +"""Tests for the _validate_url() helper in tasks.py. + +Covers: + - Rejection of None, empty, and non-string inputs + - Non-HTTP URLs pass through without network requests + - HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail) + - Network errors (timeout, connection) treated as failures + - Per-worker result cache: hits, expiry, eviction +""" +from unittest.mock import patch, MagicMock + +from django.test import TestCase + +from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL + + +class ValidateUrlInputTests(TestCase): + """Input validation — no network requests should be made.""" + + def setUp(self): + _url_validation_cache.clear() + + def test_none_returns_false(self): + self.assertFalse(_validate_url(None)) + + def test_empty_string_returns_false(self): + self.assertFalse(_validate_url("")) + + def test_non_string_returns_false(self): + self.assertFalse(_validate_url(123)) + self.assertFalse(_validate_url(["http://example.com"])) + + @patch("apps.channels.tasks.requests.head") + def test_non_http_url_returns_true_without_request(self, mock_head): + """file:// and other non-HTTP schemes skip validation.""" + self.assertTrue(_validate_url("file:///local/path.jpg")) + self.assertTrue(_validate_url("/data/images/poster.jpg")) + mock_head.assert_not_called() + + +class ValidateUrlNetworkTests(TestCase): + """HTTP(S) URL validation via HEAD request.""" + + def setUp(self): + _url_validation_cache.clear() + + @patch("apps.channels.tasks.requests.head") + def test_200_returns_true(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + self.assertTrue(_validate_url("https://example.com/poster.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_302_redirect_returns_true(self, mock_head): + mock_head.return_value = MagicMock(status_code=302) + self.assertTrue(_validate_url("https://example.com/redirect")) + + @patch("apps.channels.tasks.requests.head") + def test_404_returns_false(self, mock_head): + mock_head.return_value = MagicMock(status_code=404) + self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_500_returns_false(self, mock_head): + mock_head.return_value = MagicMock(status_code=500) + self.assertFalse(_validate_url("https://broken.com/error")) + + @patch("apps.channels.tasks.requests.head") + def test_timeout_returns_false(self, mock_head): + import requests + mock_head.side_effect = requests.Timeout("timed out") + self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_connection_error_returns_false(self, mock_head): + import requests + mock_head.side_effect = requests.ConnectionError("refused") + self.assertFalse(_validate_url("https://unreachable.com/poster.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_custom_timeout_passed_to_head(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + _validate_url("https://example.com/img.jpg", timeout=10) + mock_head.assert_called_once_with( + "https://example.com/img.jpg", timeout=10, allow_redirects=True + ) + + @patch("apps.channels.tasks.requests.get") + @patch("apps.channels.tasks.requests.head") + def test_405_falls_back_to_get(self, mock_head, mock_get): + """When HEAD returns 405, fall back to a ranged GET request.""" + mock_head.return_value = MagicMock(status_code=405) + mock_resp = MagicMock(status_code=200) + mock_get.return_value = mock_resp + self.assertTrue(_validate_url("https://no-head.com/poster.jpg")) + mock_get.assert_called_once() + mock_resp.close.assert_called_once() + + @patch("apps.channels.tasks.requests.get") + @patch("apps.channels.tasks.requests.head") + def test_405_fallback_get_also_fails(self, mock_head, mock_get): + """When HEAD returns 405 and GET also fails, return False.""" + mock_head.return_value = MagicMock(status_code=405) + mock_get.return_value = MagicMock(status_code=403) + self.assertFalse(_validate_url("https://blocked.com/poster.jpg")) + + +class ValidateUrlCacheTests(TestCase): + """Per-worker result caching.""" + + def setUp(self): + _url_validation_cache.clear() + + @patch("apps.channels.tasks.requests.head") + def test_cache_hit_avoids_second_request(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + url = "https://cached.com/poster.jpg" + self.assertTrue(_validate_url(url)) + self.assertTrue(_validate_url(url)) + mock_head.assert_called_once() + + @patch("apps.channels.tasks.requests.head") + def test_cache_hit_returns_false_for_failed_url(self, mock_head): + mock_head.return_value = MagicMock(status_code=404) + url = "https://dead.com/missing.jpg" + self.assertFalse(_validate_url(url)) + self.assertFalse(_validate_url(url)) + mock_head.assert_called_once() + + @patch("apps.channels.tasks.time.monotonic") + @patch("apps.channels.tasks.requests.head") + def test_cache_expiry_triggers_new_request(self, mock_head, mock_time): + """After TTL expires, a new HEAD request is made.""" + mock_head.return_value = MagicMock(status_code=200) + url = "https://expiring.com/poster.jpg" + + mock_time.return_value = 1000.0 + self.assertTrue(_validate_url(url)) + self.assertEqual(mock_head.call_count, 1) + + # Within TTL — cache hit + mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1 + self.assertTrue(_validate_url(url)) + self.assertEqual(mock_head.call_count, 1) + + # Past TTL — new request + mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1 + self.assertTrue(_validate_url(url)) + self.assertEqual(mock_head.call_count, 2) + + @patch("apps.channels.tasks.time.monotonic") + @patch("apps.channels.tasks.requests.head") + def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time): + """Expired entries are evicted when cache grows past 512 entries.""" + mock_head.return_value = MagicMock(status_code=200) + + # Fill cache with 513 entries at time 0 + mock_time.return_value = 0.0 + for i in range(513): + _url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0) + + # Advance past TTL and add one more — triggers eviction + mock_time.return_value = _URL_CACHE_TTL + 1 + _validate_url("https://trigger-eviction.com/img.jpg") + + # All 513 old entries expired and should be evicted + remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")] + self.assertEqual(len(remaining), 0) + # The new entry should remain + self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache) diff --git a/apps/connect/__init__.py b/apps/connect/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/api_urls.py b/apps/connect/api_urls.py new file mode 100644 index 00000000..664c437b --- /dev/null +++ b/apps/connect/api_urls.py @@ -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 diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py new file mode 100644 index 00000000..739e8e32 --- /dev/null +++ b/apps/connect/api_views.py @@ -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 diff --git a/apps/connect/apps.py b/apps/connect/apps.py new file mode 100644 index 00000000..db063fa4 --- /dev/null +++ b/apps/connect/apps.py @@ -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' diff --git a/apps/connect/handlers/__init__.py b/apps/connect/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/api.py b/apps/connect/handlers/api.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/base.py b/apps/connect/handlers/base.py new file mode 100644 index 00000000..d61b7c53 --- /dev/null +++ b/apps/connect/handlers/base.py @@ -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 diff --git a/apps/connect/handlers/script.py b/apps/connect/handlers/script.py new file mode 100644 index 00000000..b6aef79c --- /dev/null +++ b/apps/connect/handlers/script.py @@ -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, + } diff --git a/apps/connect/handlers/webhook.py b/apps/connect/handlers/webhook.py new file mode 100644 index 00000000..511f0a0e --- /dev/null +++ b/apps/connect/handlers/webhook.py @@ -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} diff --git a/apps/connect/migrations/0001_initial.py b/apps/connect/migrations/0001_initial.py new file mode 100644 index 00000000..cd8f6b42 --- /dev/null +++ b/apps/connect/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/apps/connect/migrations/0002_alter_eventsubscription_event.py b/apps/connect/migrations/0002_alter_eventsubscription_event.py new file mode 100644 index 00000000..90eee90b --- /dev/null +++ b/apps/connect/migrations/0002_alter_eventsubscription_event.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_connect', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='eventsubscription', + name='event', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100), + ), + ] diff --git a/apps/connect/migrations/__init__.py b/apps/connect/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/models.py b/apps/connect/models.py new file mode 100644 index 00000000..0b33a85e --- /dev/null +++ b/apps/connect/models.py @@ -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) diff --git a/apps/connect/serializers.py b/apps/connect/serializers.py new file mode 100644 index 00000000..832fc14b --- /dev/null +++ b/apps/connect/serializers.py @@ -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", + ] diff --git a/apps/connect/utils.py b/apps/connect/utils.py new file mode 100644 index 00000000..c3a0975f --- /dev/null +++ b/apps/connect/utils.py @@ -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) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 00f7403f..5858fa0e 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -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): @@ -40,6 +42,17 @@ class EPGSourceViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + from django.db.models import Exists, OuterRef + from apps.channels.models import Channel + return EPGSource.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).annotate( + has_channels=Exists( + Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk')) + ) + ) + def list(self, request, *args, **kwargs): logger.debug("Listing all EPG sources.") return super().list(request, *args, **kwargs) @@ -439,43 +452,32 @@ class CurrentProgramsAPIView(APIView): request=inline_serializer( name="CurrentProgramsRequest", fields={ - "channel_ids": serializers.ListField( - child=serializers.IntegerField(), + "channel_uuids": serializers.ListField( + child=serializers.CharField(), required=False, allow_null=True, - help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.", ), }, ), responses={200: ProgramDataSerializer(many=True)}, ) def post(self, request, format=None): - # Get channel IDs from request body - channel_ids = request.data.get('channel_ids', None) - # Import Channel model from apps.channels.models import Channel # Build query for channels with EPG data query = Channel.objects.filter(epg_data__isnull=False) - # Filter by specific channel IDs if provided - if channel_ids is not None: - if not isinstance(channel_ids, list): + channel_uuids = request.data.get('channel_uuids', None) + + if channel_uuids is not None: + if not isinstance(channel_uuids, list): return Response( - {"error": "channel_ids must be an array of integers or null"}, + {"error": "channel_uuids must be an array of strings or null"}, status=status.HTTP_400_BAD_REQUEST ) - - try: - channel_ids = [int(id) for id in channel_ids] - except (ValueError, TypeError): - return Response( - {"error": "channel_ids must contain valid integers"}, - status=status.HTTP_400_BAD_REQUEST - ) - - query = query.filter(id__in=channel_ids) + query = query.filter(uuid__in=channel_uuids) # Get channels with EPG data channels = query.select_related('epg_data') @@ -495,9 +497,8 @@ class CurrentProgramsAPIView(APIView): ).first() if program: - # Serialize program and add channel_id for easy mapping program_data = ProgramDataSerializer(program).data - program_data['channel_id'] = channel.id + program_data['channel_uuid'] = str(channel.uuid) current_programs.append(program_data) return Response(current_programs, status=status.HTTP_200_OK) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index e4d5f466..4b4780ea 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -5,6 +5,7 @@ from apps.channels.models import Channel class EPGSourceSerializer(serializers.ModelSerializer): epg_data_count = serializers.SerializerMethodField() + has_channels = serializers.BooleanField(read_only=True, default=False) read_only_fields = ['created_at', 'updated_at'] url = serializers.CharField( required=False, @@ -12,6 +13,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,19 +26,57 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'is_active', 'file_path', 'refresh_interval', + 'cron_expression', 'priority', 'status', 'last_message', 'created_at', 'updated_at', 'custom_properties', - 'epg_data_count' + 'epg_data_count', + 'has_channels', ] def get_epg_data_count(self, obj): """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 diff --git a/apps/epg/signals.py b/apps/epg/signals.py index e41d3aaf..97992ef3 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -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): diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 97552171..9dc597d3 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -24,7 +24,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from .models import EPGSource, EPGData, ProgramData -from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event +from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event logger = logging.getLogger(__name__) @@ -146,12 +146,15 @@ def refresh_all_epg_data(): return "EPG data refreshed." -@shared_task +@shared_task(time_limit=1800, soft_time_limit=1700) def refresh_epg_data(source_id): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return + lock_renewer = TaskLockRenewer('refresh_epg_data', source_id) + lock_renewer.start() + source = None try: # Try to get the EPG source @@ -168,6 +171,7 @@ def refresh_epg_data(source_id): logger.info(f"No orphaned task found for EPG source {source_id}") # Release the lock and exit + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -176,6 +180,7 @@ def refresh_epg_data(source_id): # The source exists but is not active, just skip processing if not source.is_active: logger.info(f"EPG source {source_id} is not active. Skipping.") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -184,6 +189,7 @@ def refresh_epg_data(source_id): # Skip refresh for dummy EPG sources - they don't need refreshing if source.source_type == 'dummy': logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) gc.collect() return @@ -194,6 +200,7 @@ def refresh_epg_data(source_id): fetch_success = fetch_xmltv(source) if not fetch_success: logger.error(f"Failed to fetch XMLTV for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -202,6 +209,7 @@ def refresh_epg_data(source_id): parse_channels_success = parse_channels_only(source) if not parse_channels_success: logger.error(f"Failed to parse channels for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -234,6 +242,7 @@ def refresh_epg_data(source_id): source = None # Force garbage collection before releasing the lock gc.collect() + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) @@ -1126,12 +1135,15 @@ def parse_channels_only(source): -@shared_task +@shared_task(time_limit=3600, soft_time_limit=3500) def parse_programs_for_tvg_id(epg_id): if not acquire_task_lock('parse_epg_programs', epg_id): logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") return "Task already running" + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + source_file = None program_parser = None programs_to_create = [] @@ -1161,11 +1173,13 @@ def parse_programs_for_tvg_id(epg_id): # Skip program parsing for dummy EPG sources - they don't have program data files if epg_source.source_type == 'dummy': logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return if not Channel.objects.filter(epg_data=epg).exists(): logger.info(f"No channels matched to EPG {epg.tvg_id}") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1207,6 +1221,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, cannot parse programs" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1217,6 +1232,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, file missing after download" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1232,6 +1248,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"No URL provided, cannot fetch EPG data" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1379,7 +1396,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source = None # Add comprehensive cleanup before releasing lock cleanup_memory(log_usage=should_log_memory, force_collection=True) - # Memory tracking after processing + # Memory tracking after processing if process: try: mem_after = process.memory_info().rss / 1024 / 1024 @@ -1389,6 +1406,7 @@ def parse_programs_for_tvg_id(epg_id): process = None epg = None programs_processed = None + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 73331f7a..34f3cd77 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -8,6 +8,7 @@ from apps.accounts.permissions import ( ) from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes +from django.db import transaction from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache @@ -37,7 +38,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): @@ -262,38 +265,50 @@ class M3UAccountViewSet(viewsets.ModelViewSet): category_settings = request.data.get("category_settings", []) try: - for setting in group_settings: - group_id = setting.get("channel_group") - enabled = setting.get("enabled", True) - auto_sync = setting.get("auto_channel_sync", False) - sync_start = setting.get("auto_sync_channel_start") - custom_properties = setting.get("custom_properties", {}) - - if group_id: - ChannelGroupM3UAccount.objects.update_or_create( - channel_group_id=group_id, + with transaction.atomic(): + group_objects = [ + ChannelGroupM3UAccount( + channel_group_id=setting["channel_group"], m3u_account=account, - defaults={ - "enabled": enabled, - "auto_channel_sync": auto_sync, - "auto_sync_channel_start": sync_start, - "custom_properties": custom_properties, - }, + enabled=setting.get("enabled", True), + auto_channel_sync=setting.get("auto_channel_sync", False), + auto_sync_channel_start=setting.get("auto_sync_channel_start"), + custom_properties=setting.get("custom_properties", {}), + ) + for setting in group_settings + if setting.get("channel_group") + ] + + if group_objects: + ChannelGroupM3UAccount.objects.bulk_create( + group_objects, + update_conflicts=True, + unique_fields=["channel_group", "m3u_account"], + update_fields=[ + "enabled", + "auto_channel_sync", + "auto_sync_channel_start", + "custom_properties", + ], ) - for setting in category_settings: - category_id = setting.get("id") - enabled = setting.get("enabled", True) - custom_properties = setting.get("custom_properties", {}) - - if category_id: - M3UVODCategoryRelation.objects.update_or_create( - category_id=category_id, + category_objects = [ + M3UVODCategoryRelation( + category_id=setting["id"], m3u_account=account, - defaults={ - "enabled": enabled, - "custom_properties": custom_properties, - }, + enabled=setting.get("enabled", True), + custom_properties=setting.get("custom_properties", {}), + ) + for setting in category_settings + if setting.get("id") + ] + + if category_objects: + M3UVODCategoryRelation.objects.bulk_create( + category_objects, + update_conflicts=True, + unique_fields=["m3u_account", "category"], + update_fields=["enabled", "custom_properties"], ) return Response({"message": "Group settings updated successfully"}) diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index a607dc07..22c4057a 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -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") diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index d014ac92..3de67c90 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -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): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index f929a208..5259cefc 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -23,6 +23,7 @@ from core.utils import ( RedisClient, acquire_task_lock, release_task_lock, + TaskLockRenewer, natural_sort_key, log_system_event, ) @@ -66,7 +67,8 @@ def fetch_m3u_lines(account, use_cache=False): account.save(update_fields=["status", "last_message"]) response = requests.get( - account.server_url, headers=headers, stream=True + account.server_url, headers=headers, stream=True, + timeout=(30, 60), # 30s connect, 60s read between chunks ) # Log the actual response details for debugging @@ -126,119 +128,60 @@ def fetch_m3u_lines(account, use_cache=False): start_time = time.time() last_update_time = start_time progress = 0 - temp_content = b"" # Store content temporarily to validate before saving has_content = False - # First, let's collect the content and validate it - send_m3u_update(account.id, "downloading", 0) - for chunk in response.iter_content(chunk_size=8192): - if chunk: - temp_content += chunk - has_content = True + # Stream directly to a temp file to avoid holding the entire + # M3U in memory (large files can be 100MB+, which would use + # ~3x that in RAM in certain approaches). + temp_path = file_path + ".tmp" + try: + send_m3u_update(account.id, "downloading", 0) + with open(temp_path, "wb") as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + has_content = True - downloaded += len(chunk) - elapsed_time = time.time() - start_time + downloaded += len(chunk) + elapsed_time = time.time() - start_time - # Calculate download speed in KB/s - speed = downloaded / elapsed_time / 1024 # in KB/s + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 # in KB/s - # Calculate progress percentage - if total_size and total_size > 0: - progress = (downloaded / total_size) * 100 + # Calculate progress percentage + if total_size and total_size > 0: + progress = (downloaded / total_size) * 100 - # Time remaining (in seconds) - time_remaining = ( - (total_size - downloaded) / (speed * 1024) - if speed > 0 - else 0 - ) - - current_time = time.time() - if current_time - last_update_time >= 0.5: - last_update_time = current_time - if progress > 0: - # Update the account's last_message with detailed progress info - progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" - account.last_message = progress_msg - account.save(update_fields=["last_message"]) - - send_m3u_update( - account.id, - "downloading", - progress, - speed=speed, - elapsed_time=elapsed_time, - time_remaining=time_remaining, - message=progress_msg, + # Time remaining (in seconds) + time_remaining = ( + (total_size - downloaded) / (speed * 1024) + if speed > 0 + else 0 ) - # Check if we actually received any content - logger.info(f"Download completed. Has content: {has_content}, Content length: {len(temp_content)} bytes") - if not has_content or len(temp_content) == 0: - error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account.id, - "downloading", - 100, - status="error", - error=error_msg, - ) - return [], False + current_time = time.time() + if current_time - last_update_time >= 0.5: + last_update_time = current_time + if progress > 0: + # Update the account's last_message with detailed progress info + progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" + account.last_message = progress_msg + account.save(update_fields=["last_message"]) - # Basic validation: check if content looks like an M3U file - try: - content_str = temp_content.decode('utf-8', errors='ignore') - content_lines = content_str.strip().split('\n') + send_m3u_update( + account.id, + "downloading", + progress, + speed=speed, + elapsed_time=elapsed_time, + time_remaining=time_remaining, + message=progress_msg, + ) - # Log first few lines for debugging (be careful not to log too much) - preview_lines = content_lines[:5] - logger.info(f"Content preview (first 5 lines): {preview_lines}") - logger.info(f"Total lines in content: {len(content_lines)}") - - # Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content) - is_valid_m3u = False - - # First, check if this looks like an error response disguised as 200 OK - content_lower = content_str.lower() - if any(error_indicator in content_lower for error_indicator in [ - ' dict: """ Parse an EXTINF line from an M3U file. @@ -457,8 +489,10 @@ def parse_extinf_line(line: str) -> dict: attrs = {} last_attr_end = 0 - # Use a single regex that handles both quote types - for match in re.finditer(r'([^\s]+)=(["\'])([^\2]*?)\2', content): + # Use a single regex that handles both quote types. + # Keys must stop at '=' so values like base64-padded URLs ending with '==' + # don't get folded into the preceding attribute name. + for match in re.finditer(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2', content): key = match.group(1) value = match.group(3) attrs[key] = value @@ -724,6 +758,14 @@ def collect_xc_streams(account_id, enabled_groups): # Filter streams based on enabled categories filtered_count = 0 for stream in all_xc_streams: + # Fall back to a generated name if the provider returns null/empty + stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + if not stream.get("name"): + logger.warning( + f"XC stream has null/empty name; using generated name '{stream_name}' " + f"(stream_id={stream.get('stream_id', 'unknown')})" + ) + # Get the category_id for this stream category_id = str(stream.get("category_id", "")) @@ -733,7 +775,7 @@ def collect_xc_streams(account_id, enabled_groups): # Convert XC stream to our standard format with all properties preserved stream_data = { - "name": stream["name"], + "name": stream_name, "url": xc_client.get_stream_url(stream["stream_id"]), "attributes": { "tvg-id": stream.get("epg_channel_id", ""), @@ -817,7 +859,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): ) for stream in streams: - name = stream["name"] + name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + if not stream.get("name"): + logger.warning( + f"XC stream has null/empty name in category {group_name}; " + f"using generated name '{name}' (stream_id={stream.get('stream_id', 'unknown')})" + ) raw_stream_id = stream.get("stream_id", "") provider_stream_id = None if raw_stream_id: @@ -850,7 +897,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(group_id), "stream_hash": stream_hash, "custom_properties": stream, - "is_adult": int(stream.get("is_adult", 0)) == 1, + "is_adult": parse_is_adult(stream.get("is_adult", 0)), "is_stale": False, "stream_id": provider_stream_id, "stream_chno": stream_chno, @@ -1068,7 +1115,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, "custom_properties": stream_info["attributes"], - "is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1, + "is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)), "is_stale": False, "stream_id": provider_stream_id, "stream_chno": channel_num, @@ -1145,14 +1192,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." - # Aggressive garbage collection - # del streams_to_create, streams_to_update, stream_hashes, existing_streams - # from core.utils import cleanup_memory - # cleanup_memory(log_usage=True, force_collection=True) - # Clean up database connections for threading connections.close_all() + # Free batch data structures (reference-counted deallocation) + del streams_to_create, streams_to_update, stream_hashes, existing_streams + return retval @@ -1210,9 +1255,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 +1287,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 +1300,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 +1410,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 +1449,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 +1466,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 +1478,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 +1486,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 +1581,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 +1705,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 +1729,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 +1748,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 +1765,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 +1905,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 +1945,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 +2144,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 +2295,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 +2411,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 +2650,28 @@ 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() + + try: + return _refresh_single_m3u_account_impl(account_id) + finally: + # Guaranteed cleanup on all exit paths (success, exception, early return) + lock_renewer.stop() + release_task_lock("refresh_single_m3u_account", account_id) + + +def _refresh_single_m3u_account_impl(account_id): + """Implementation of M3U account refresh with guaranteed memory cleanup.""" # Record start time refresh_start_timestamp = timezone.now() # For the cleanup function start_time = time.time() # For tracking elapsed time as float @@ -2559,7 +2683,6 @@ 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.") - release_task_lock("refresh_single_m3u_account", account_id) return # Set status to fetching @@ -2588,7 +2711,6 @@ def refresh_single_m3u_account(account_id): else: logger.debug(f"No orphaned task found for M3U account {account_id}") - release_task_lock("refresh_single_m3u_account", account_id) return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up" # Fetch M3U lines and handle potential issues @@ -2603,6 +2725,7 @@ def refresh_single_m3u_account(account_id): extinf_data = data["extinf_data"] groups = data["groups"] + del data # Free top-level dict; extinf_data/groups retain their references except json.JSONDecodeError as e: # Handle corrupted JSON file logger.error( @@ -2638,7 +2761,6 @@ def refresh_single_m3u_account(account_id): logger.error( f"Failed to refresh M3U groups for account {account_id}: {result}" ) - release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account - download failed or other error" extinf_data, groups = result @@ -2671,7 +2793,6 @@ def refresh_single_m3u_account(account_id): status="error", error=f"Error refreshing M3U groups: {str(e)}", ) - release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account" # Only proceed with parsing if we actually have data and no errors were encountered @@ -2694,7 +2815,6 @@ def refresh_single_m3u_account(account_id): status="error", error="No data available for processing", ) - release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account, no data available" hash_keys = CoreSettings.get_m3u_hash_key().split(",") @@ -2840,6 +2960,9 @@ def refresh_single_m3u_account(account_id): logger.info(f"Processing {len(all_xc_streams)} XC streams in {len(batches)} batches") + # Free the original list; batches hold independent sliced copies + del all_xc_streams + # Use threading for XC stream processing - now with consistent batch sizes max_workers = min(4, len(batches)) logger.debug(f"Using {max_workers} threads for XC stream processing") @@ -3000,31 +3123,38 @@ def refresh_single_m3u_account(account_id): except Exception as e: logger.error(f"Error processing M3U for account {account_id}: {str(e)}") - account.status = M3UAccount.Status.ERROR - account.last_message = f"Error processing M3U: {str(e)}" - account.save(update_fields=["status", "last_message"]) + try: + account.status = M3UAccount.Status.ERROR + account.last_message = f"Error processing M3U: {str(e)}" + account.save(update_fields=["status", "last_message"]) + except Exception: + logger.debug(f"Failed to update account {account_id} status during error handling") raise # Re-raise the exception for Celery to handle + finally: + # Free large data structures regardless of success or failure + if 'existing_groups' in locals(): + del existing_groups + if 'extinf_data' in locals(): + del extinf_data + if 'groups' in locals(): + del groups + if 'batches' in locals(): + del batches + if 'all_xc_streams' in locals(): + del all_xc_streams + if 'data' in locals(): + del data + if 'filtered_groups' in locals(): + del filtered_groups + if 'channel_group_relationships' in locals(): + del channel_group_relationships - release_task_lock("refresh_single_m3u_account", account_id) - - # Aggressive garbage collection - # Only delete variables if they exist - if 'existing_groups' in locals(): - del existing_groups - if 'extinf_data' in locals(): - del extinf_data - if 'groups' in locals(): - del groups - if 'batches' in locals(): - del batches - - from core.utils import cleanup_memory - - cleanup_memory(log_usage=True, force_collection=True) - - # Clean up cache file since we've fully processed it - if os.path.exists(cache_path): - os.remove(cache_path) + # Remove cache file after processing (success or failure) + cache_path = os.path.join(m3u_dir, f"{account_id}.json") + try: + os.remove(cache_path) + except OSError: + pass return f"Dispatched jobs complete." diff --git a/apps/m3u/tests/__init__.py b/apps/m3u/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/m3u/tests/test_extinf_parsing.py b/apps/m3u/tests/test_extinf_parsing.py new file mode 100644 index 00000000..367f569b --- /dev/null +++ b/apps/m3u/tests/test_extinf_parsing.py @@ -0,0 +1,41 @@ +from django.test import SimpleTestCase + +from apps.m3u.tasks import parse_extinf_line + + +class ParseExtinfLineTests(SimpleTestCase): + def test_preserves_equals_padding_in_tvg_logo(self): + line = ( + '#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" ' + 'tvg-chno="1001" ' + 'tvg-name="UK Sky Sports Premier League" ' + 'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?' + 'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" ' + 'group-title="Team Games",UK Sky Sports Premier League' + ) + + parsed = parse_extinf_line(line) + + self.assertIsNotNone(parsed) + self.assertEqual( + parsed["attributes"]["tvg-logo"], + "https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==", + ) + self.assertEqual(parsed["attributes"]["group-title"], "Team Games") + self.assertEqual(parsed["name"], "UK Sky Sports Premier League") + + def test_supports_single_quoted_attributes(self): + line = ( + "#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' " + "group-title='Sports',Channel One" + ) + + parsed = parse_extinf_line(line) + + self.assertIsNotNone(parsed) + self.assertEqual( + parsed["attributes"]["tvg-logo"], + "https://example.com/logo==.png", + ) + self.assertEqual(parsed["attributes"]["group-title"], "Sports") + self.assertEqual(parsed["display_name"], "Channel One") diff --git a/apps/m3u/tests/test_memory_cleanup.py b/apps/m3u/tests/test_memory_cleanup.py new file mode 100644 index 00000000..bd556e4c --- /dev/null +++ b/apps/m3u/tests/test_memory_cleanup.py @@ -0,0 +1,106 @@ +""" +Tests for memory cleanup behavior in M3U refresh tasks. + +Verifies that database connections are properly closed, task locks are +released on all exit paths, and garbage collection runs where expected. + +""" +from unittest.mock import patch, MagicMock + +from django.test import SimpleTestCase + +from apps.m3u.models import M3UAccount + + +class ProcessM3UBatchCleanupTests(SimpleTestCase): + """Verify process_m3u_batch_direct cleans up after processing.""" + + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls): + """Database connections must be closed after batch processing (thread safety).""" + from apps.m3u.tasks import process_m3u_batch_direct + + mock_account = MagicMock() + mock_account.filters.order_by.return_value = [] + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + + with patch("django.db.connections") as mock_connections: + process_m3u_batch_direct(1, [], {}, ["name", "url"]) + mock_connections.close_all.assert_called() + + +class LockReleaseTests(SimpleTestCase): + """Verify task lock is released on all exit paths.""" + + @patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False) + def test_lock_released_on_account_not_found(self, mock_delete): + """release_task_lock must be called when account does not exist.""" + with patch( + "apps.m3u.tasks.acquire_task_lock", return_value=True + ), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch( + "apps.m3u.tasks.TaskLockRenewer" + ): + with patch( + "apps.m3u.tasks.M3UAccount.objects.get", + side_effect=M3UAccount.DoesNotExist, + ): + from apps.m3u.tasks import refresh_single_m3u_account + + refresh_single_m3u_account(99999) + + mock_release.assert_called_once_with( + "refresh_single_m3u_account", 99999 + ) + + def test_lock_released_on_exception(self): + """release_task_lock must be called when an exception is raised.""" + mock_account = MagicMock() + mock_account.is_active = True + mock_account.account_type = "STD" + mock_account.custom_properties = {} + mock_account.filters.all.return_value = [] + mock_account.status = MagicMock() + + with patch( + "apps.m3u.tasks.acquire_task_lock", return_value=True + ), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch( + "apps.m3u.tasks.TaskLockRenewer" + ): + with patch( + "apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account + ): + with patch("os.path.exists", return_value=False): + with patch( + "apps.m3u.tasks.refresh_m3u_groups", + side_effect=RuntimeError("test"), + ): + from apps.m3u.tasks import refresh_single_m3u_account + + try: + refresh_single_m3u_account(1) + except RuntimeError: + pass + + mock_release.assert_called_once_with("refresh_single_m3u_account", 1) + + +class XCCategoryCleanupTests(SimpleTestCase): + """Regression guard: process_xc_category_direct must continue to clean up.""" + + @patch("apps.m3u.tasks.XCClient") + @patch("apps.m3u.tasks.M3UAccount") + def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client): + """gc.collect() must be called after XC category processing.""" + from apps.m3u.tasks import process_xc_category_direct + + mock_account = MagicMock() + mock_account_cls.objects.get.return_value = mock_account + + with patch("gc.collect") as mock_gc, patch("django.db.connections"): + process_xc_category_direct(1, {}, {}, ["name", "url"]) + mock_gc.assert_called() diff --git a/apps/output/views.py b/apps/output/views.py index 1d53237a..84c5f29e 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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' ' ) xml_lines.append(f" {html.escape(program['title'])}") + + # Add subtitle if available + if program.get('sub_title'): + xml_lines.append(f" {html.escape(program['sub_title'])}") + xml_lines.append(f" {html.escape(program['description'])}") # 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' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1574,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None): yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1607,12 +1643,14 @@ def generate_epg(request, profile_name=None, user=None): # For real EPG data - filter only if days parameter was specified if num_days > 0: programs_qs = channel.epg_data.programs.filter( - start_time__gte=now, + end_time__gte=now, start_time__lt=cutoff_date ).order_by('id') # Explicit ordering for consistent chunking else: - # Return all programs if days=0 or not specified - programs_qs = channel.epg_data.programs.all().order_by('id') + # Return all non-expired programs if days=0 or not specified + programs_qs = channel.epg_data.programs.filter( + end_time__gte=now + ).order_by('id') # Process programs in chunks to avoid cursor timeout issues program_batch = [] @@ -1909,6 +1947,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 +2247,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 +2562,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 +2911,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", diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index 172af265..9f568054 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -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): diff --git a/apps/proxy/apps.py b/apps/proxy/apps.py index d1c8b966..b33d46cc 100644 --- a/apps/proxy/apps.py +++ b/apps/proxy/apps.py @@ -12,6 +12,6 @@ class ProxyConfig(AppConfig): from .hls_proxy.server import ProxyServer as HLSProxyServer from .ts_proxy.server import ProxyServer as TSProxyServer - # Initialize proxy servers + # Initialize proxy servers (TS uses singleton to prevent duplicate instances) self.hls_proxy = HLSProxyServer() - self.ts_proxy = TSProxyServer() + self.ts_proxy = TSProxyServer.get_instance() diff --git a/apps/proxy/config.py b/apps/proxy/config.py index 3b1ce967..68e27a6f 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -43,6 +43,7 @@ class BaseConfig: "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, } finally: @@ -81,6 +82,7 @@ class TSConfig(BaseConfig): # Buffer settings INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB) CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch + NEW_CLIENT_BEHIND_SECONDS = 5 # Start new clients this many seconds behind live (0 = start at live) KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head # Chunk read timeout CHUNK_TIMEOUT = 5 # Seconds to wait for each chunk read diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py index a361bfa1..836f719e 100644 --- a/apps/proxy/ts_proxy/client_manager.py +++ b/apps/proxy/ts_proxy/client_manager.py @@ -23,7 +23,7 @@ class ClientManager: self.channel_id = channel_id self.redis_client = redis_client self.clients = set() - self.lock = threading.Lock() + self.lock = threading.RLock() self.last_active_time = time.time() self.worker_id = worker_id # Store worker ID as instance variable self._heartbeat_running = True # Flag to control heartbeat thread @@ -43,14 +43,20 @@ class ClientManager: self._registered_clients = set() # Track already registered client IDs def _trigger_stats_update(self): - """Trigger a channel stats update via WebSocket""" + """Trigger a channel stats update via WebSocket in a background thread. + + Offloaded so the caller is not blocked. send_websocket_update is + gevent-safe (offloads async_to_sync to a native OS thread). + """ + threading.Thread(target=self._do_stats_update, daemon=True).start() + + def _do_stats_update(self): + """Perform the stats update in the background.""" try: - # Import here to avoid potential import issues from apps.proxy.ts_proxy.channel_status import ChannelStatus import redis from django.conf import settings - # Get all channels from Redis using settings redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0') redis_client = redis.Redis.from_url(redis_url, decode_responses=True) all_channels = [] @@ -59,7 +65,6 @@ class ClientManager: while True: cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100) for key in keys: - # Extract channel ID from key parts = key.split(':') if len(parts) >= 4: ch_id = parts[2] @@ -70,7 +75,6 @@ class ClientManager: if cursor == 0: break - # Send WebSocket update using existing infrastructure send_websocket_update( "updates", "update", diff --git a/apps/proxy/ts_proxy/config_helper.py b/apps/proxy/ts_proxy/config_helper.py index d7d33558..f9a0418e 100644 --- a/apps/proxy/ts_proxy/config_helper.py +++ b/apps/proxy/ts_proxy/config_helper.py @@ -41,6 +41,15 @@ class ConfigHelper: """Get number of chunks to start behind""" return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 4) + @staticmethod + def new_client_behind_seconds(): + """Get number of seconds behind live to start new clients. + 0 means start at live (buffer head). + Loaded from DB proxy_settings so users can change it at runtime.""" + from apps.proxy.config import TSConfig + settings = TSConfig.get_proxy_settings() + return settings.get('new_client_behind_seconds', 5) + @staticmethod def keepalive_interval(): """Get keepalive interval in seconds""" diff --git a/apps/proxy/ts_proxy/redis_keys.py b/apps/proxy/ts_proxy/redis_keys.py index 22b02648..29846f98 100644 --- a/apps/proxy/ts_proxy/redis_keys.py +++ b/apps/proxy/ts_proxy/redis_keys.py @@ -79,6 +79,12 @@ class RedisKeys: """Key for worker heartbeat""" return f"ts_proxy:worker:{worker_id}:heartbeat" + @staticmethod + def chunk_timestamps(channel_id): + """Sorted set mapping chunk receive-timestamps (score) to chunk indices (member). + Used for time-based client positioning.""" + return f"ts_proxy:channel:{channel_id}:buffer:chunk_timestamps" + @staticmethod def transcode_active(channel_id): """Key indicating active transcode process""" diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index df51744d..e1e2c545 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -34,18 +34,32 @@ logger = get_logger() class ProxyServer: """Manages TS proxy server instance with worker coordination""" _instance = None + _INITIALIZING = object() # sentinel for gevent-safe singleton @classmethod def get_instance(cls): - if cls._instance is None: - from .server import ProxyServer - from .stream_manager import StreamManager - from .stream_buffer import StreamBuffer - from .client_manager import ClientManager - - cls._instance = ProxyServer() - - return cls._instance + inst = cls._instance + if inst is not None and inst is not cls._INITIALIZING: + return inst + if inst is None: + cls._instance = cls._INITIALIZING + try: + from .server import ProxyServer + from .stream_manager import StreamManager + from .stream_buffer import StreamBuffer + from .client_manager import ClientManager + real_instance = ProxyServer() + cls._instance = real_instance + return real_instance + except Exception: + cls._instance = None # Reset so next call can retry + raise + # Another greenlet is initializing — wait for completion + while True: + inst = cls._instance + if inst is not None and inst is not cls._INITIALIZING: + return inst + gevent.sleep(0.05) def __init__(self): """Initialize proxy server with worker identification""" @@ -353,9 +367,16 @@ class ProxyServer: try: lock_key = RedisKeys.channel_owner(channel_id) - return self._execute_redis_command( - lambda: self.redis_client.get(lock_key).decode('utf-8') if self.redis_client.get(lock_key) else None + result = self._execute_redis_command( + lambda: self.redis_client.get(lock_key) ) + if result is None: + return None + try: + return result.decode('utf-8') + except (AttributeError, UnicodeDecodeError) as e: + logger.error(f"Error decoding channel owner for {channel_id}: {e}, raw={result!r}") + return None except Exception as e: logger.error(f"Error getting channel owner: {e}") return None @@ -374,20 +395,16 @@ class ProxyServer: # Create a lock key with proper namespace lock_key = RedisKeys.channel_owner(channel_id) - # Use Redis SETNX for atomic locking with error handling + # Use atomic SET NX EX for locking with error handling acquired = self._execute_redis_command( - lambda: self.redis_client.setnx(lock_key, self.worker_id) + lambda: self.redis_client.set(lock_key, self.worker_id, nx=True, ex=ttl) ) if acquired is None: # Redis command failed logger.warning(f"Redis command failed during ownership acquisition - assuming ownership") return True - # If acquired, set expiry to prevent orphaned locks if acquired: - self._execute_redis_command( - lambda: self.redis_client.expire(lock_key, ttl) - ) logger.info(f"Worker {self.worker_id} acquired ownership of channel {channel_id}") return True @@ -433,7 +450,7 @@ class ProxyServer: logger.error(f"Error releasing channel ownership: {e}") def extend_ownership(self, channel_id, ttl=30): - """Extend ownership lease with grace period""" + """Extend ownership lease, re-acquiring if key expired""" if not self.redis_client: return False @@ -441,10 +458,23 @@ class ProxyServer: lock_key = RedisKeys.channel_owner(channel_id) current = self.redis_client.get(lock_key) - # Only extend if we're still the owner - if current and current.decode('utf-8') == self.worker_id: + if current is None: + # Key expired — re-acquire if we have the stream_manager + if channel_id in self.stream_managers: + acquired = self.redis_client.set(lock_key, self.worker_id, nx=True, ex=ttl) + if acquired: + logger.warning(f"Re-acquired expired ownership for channel {channel_id}") + return True + else: + new_owner = self.redis_client.get(lock_key) + logger.warning(f"Could not re-acquire ownership for {channel_id}, new owner: {new_owner}") + return False + return False + + if current.decode('utf-8') == self.worker_id: self.redis_client.expire(lock_key, ttl) return True + return False except Exception as e: logger.error(f"Error extending ownership: {e}") @@ -766,7 +796,10 @@ class ProxyServer: if self.redis_client.exists(key): # Found orphaned keys without metadata - clean them up logger.warning(f"Found orphaned keys for channel {channel_id} without metadata - cleaning up") - self._clean_redis_keys(channel_id) + try: + self._clean_redis_keys(channel_id) + except Exception as e: + logger.error(f"Error cleaning redis keys for channel {channel_id}: {e}") return False return False @@ -788,13 +821,17 @@ class ProxyServer: # Force release resources in the Channel model try: channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() - logger.info(f"Released stream allocation for zombie channel {channel_id}") + if not channel.release_stream(): + logger.warning(f"Failed to release stream for zombie channel {channel_id}") + else: + logger.info(f"Released stream allocation for zombie channel {channel_id}") except Exception as e: try: stream = Stream.objects.get(stream_hash=channel_id) - stream.release_stream() - logger.info(f"Released stream allocation for zombie channel {channel_id}") + if not stream.release_stream(): + logger.warning(f"Failed to release stream for zombie channel {channel_id}") + else: + logger.info(f"Released stream allocation for zombie channel {channel_id}") except Exception as e: logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") @@ -1173,6 +1210,33 @@ class ProxyServer: else: # === NON-OWNER CHANNEL HANDLING === + # Safety: if we have a stream_manager, we ARE the real owner + # but the Redis key may have expired. Try to re-acquire. + if channel_id in self.stream_managers: + logger.warning( + f"Ownership gap for {channel_id}: this worker has stream_manager " + f"but am_i_owner returned False. Attempting re-acquisition." + ) + reacquired = self.extend_ownership(channel_id) + if reacquired: + logger.info(f"Successfully re-acquired ownership for {channel_id}") + continue + else: + # Defer cleanup if we still have active clients — give the + # new owner time to spin up its own stream before we tear + # ours down, so viewers don't get disconnected. + has_clients = ( + channel_id in self.client_managers + and self.client_managers[channel_id].get_client_count() > 0 + ) + if has_clients: + logger.warning( + f"Ownership lost for {channel_id} but {self.client_managers[channel_id].get_client_count()} " + f"client(s) still connected — deferring cleanup to next cycle" + ) + continue + logger.error(f"Failed to re-acquire ownership for {channel_id}, will clean up") + # For channels we don't own, check if they've been stopped/cleaned up in Redis if self.redis_client: # Method 1: Check for stopping key @@ -1339,10 +1403,15 @@ class ProxyServer: # Release the channel, stream, and profile keys from the channel try: channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() - except: - stream = Stream.objects.get(stream_hash=channel_id) - stream.release_stream() + if not channel.release_stream(): + logger.debug(f"Channel {channel_id}: release_stream found no keys to clean") + except (Channel.DoesNotExist, Exception): + try: + stream = Stream.objects.get(stream_hash=channel_id) + if not stream.release_stream(): + logger.debug(f"Stream {channel_id}: release_stream found no keys to clean") + except (Stream.DoesNotExist, Exception): + logger.debug(f"No Channel or Stream found for {channel_id}") if not self.redis_client: return 0 diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 4c4a73ac..e765ece3 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -265,18 +265,23 @@ class ChannelService: # Release the channel in the channel model if applicable try: channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() - logger.info(f"Released channel {channel_id} stream allocation") - model_released = True - except Channel.DoesNotExist: + model_released = channel.release_stream() + if model_released: + logger.info(f"Released channel {channel_id} stream allocation") + else: + logger.warning(f"Channel {channel_id}: release_stream found no keys to clean") + except (Channel.DoesNotExist, Exception): logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash") - stream = Stream.objects.get(stream_hash=channel_id) - stream.release_stream() - logger.info(f"Released stream {channel_id} stream allocation") - model_released = True - except Exception as e: - logger.error(f"Error releasing channel stream: {e}") - model_released = False + try: + stream = Stream.objects.get(stream_hash=channel_id) + model_released = stream.release_stream() + if model_released: + logger.info(f"Released stream {channel_id} stream allocation") + else: + logger.warning(f"Stream {channel_id}: release_stream found no keys to clean") + except (Stream.DoesNotExist, Exception) as e: + logger.error(f"No Channel or Stream found for {channel_id}: {e}") + model_released = False return { 'status': 'success', diff --git a/apps/proxy/ts_proxy/stream_buffer.py b/apps/proxy/ts_proxy/stream_buffer.py index 85feb5dd..111468bf 100644 --- a/apps/proxy/ts_proxy/stream_buffer.py +++ b/apps/proxy/ts_proxy/stream_buffer.py @@ -45,6 +45,22 @@ class StreamBuffer: self._write_buffer = bytearray() self.target_chunk_size = ConfigHelper.get('BUFFER_CHUNK_SIZE', TS_PACKET_SIZE * 5644) # ~1MB default + # Sorted-set key for chunk receive-timestamps (time-based positioning) + self.chunk_timestamps_key = RedisKeys.chunk_timestamps(channel_id) if channel_id else "" + + # Register Lua scripts once — subsequent calls use EVALSHA (just the + # SHA hash) instead of sending the full script text on every invocation. + if self.redis_client: + self._find_oldest_chunk_sha = self.redis_client.register_script( + self._FIND_OLDEST_CHUNK_LUA + ) + self._find_chunk_by_time_sha = self.redis_client.register_script( + self._FIND_CHUNK_BY_TIME_LUA + ) + else: + self._find_oldest_chunk_sha = None + self._find_chunk_by_time_sha = None + # Track timers for proper cleanup self.stopping = False self.fill_timers = [] @@ -92,6 +108,14 @@ class StreamBuffer: chunk_key = RedisKeys.buffer_chunk(self.channel_id, chunk_index) self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(chunk_data)) + # Record receive timestamp for time-based client positioning + if self.chunk_timestamps_key: + now = time.time() + self.redis_client.zadd(self.chunk_timestamps_key, {str(chunk_index): now}) + # Prune entries whose chunks have expired from Redis + self.redis_client.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl) + self.redis_client.expire(self.chunk_timestamps_key, self.chunk_ttl) + # Update local tracking self.index = chunk_index writes_done += 1 @@ -275,6 +299,13 @@ class StreamBuffer: if hasattr(self, '_partial_packet'): self._partial_packet = bytearray() + # Clean up the chunk timestamps sorted set + if self.redis_client and self.chunk_timestamps_key: + try: + self.redis_client.delete(self.chunk_timestamps_key) + except Exception as e: + logger.error(f"Error deleting chunk timestamps key: {e}") + except Exception as e: logger.error(f"Error during buffer stop: {e}") @@ -328,6 +359,146 @@ class StreamBuffer: return chunks, client_index + chunk_count + # Lua script that runs an atomic binary search on the Redis server. + # Chunks expire in FIFO order (same TTL, sequential writes), so the + # alive range is contiguous: [oldest_surviving .. buffer_head]. + # Binary search finds the boundary in O(log N) EXISTS calls with zero + # round-trips between steps and no TOCTOU races (Lua scripts are atomic). + # + # ARGV[1] = key prefix (e.g. "ts_proxy:channel::buffer:chunk:") + # ARGV[2] = low index (client_index + 1, first chunk the client needs) + # ARGV[3] = high index (buffer head, most recent chunk) + # + # Returns: the index of the oldest existing chunk, or -1 if none exist. + _FIND_OLDEST_CHUNK_LUA = """ + local prefix = ARGV[1] + local low = tonumber(ARGV[2]) + local high = tonumber(ARGV[3]) + + if redis.call('EXISTS', prefix .. high) == 0 then + return -1 + end + + local result = high + while low <= high do + local mid = math.floor((low + high) / 2) + if redis.call('EXISTS', prefix .. mid) == 1 then + result = mid + high = mid - 1 + else + low = mid + 1 + end + end + return result + """ + + def find_oldest_available_chunk(self, client_index): + """Find the oldest (lowest-index) chunk that still exists in Redis. + + Executes an atomic Lua binary search on the Redis server — one + round-trip, ~log2(N) EXISTS calls, no TOCTOU between steps. + + The actual read attempt (get_optimized_client_data) is what + authoritatively detects expiration; this method is best-effort + positioning that self-corrects on the next iteration if the found + chunk also expires before the client can read it. + + Args: + client_index: The client's current local_index (last consumed chunk). + + Returns: + int or None: The local_index value the client should jump to + (one before the first available chunk), or None if no + chunks are available at all. + """ + if not self.redis_client: + return None + + low = client_index + 1 # First chunk the client needs + high = self.index # Latest chunk written + + if low > high: + return None + + try: + # Uses EVALSHA under the hood — sends only the SHA hash, + # not the full script text, on every call after the first. + result = self._find_oldest_chunk_sha( + args=[ + RedisKeys.buffer_chunk_prefix(self.channel_id), + low, + high, + ], + ) + + if result == -1: + return None + + # Return result - 1 so local_index points to one before the + # first available chunk (matching the "last consumed" convention). + return int(result) - 1 + + except Exception as e: + logger.error(f"Error running find_oldest_chunk Lua script for channel {self.channel_id}: {e}") + return None + + # ------------------------------------------------------------------ + # Lua script: atomic reverse-scan of the chunk_timestamps sorted set. + # Finds the chunk whose receive-timestamp is closest to (but <=) a + # target wall-clock time. Returns the chunk index or -1. + # + # KEYS[1] = chunk_timestamps sorted-set key + # ARGV[1] = target timestamp (time.time() - desired_seconds_behind) + # ------------------------------------------------------------------ + _FIND_CHUNK_BY_TIME_LUA = """ + local ts_key = KEYS[1] + local target = tonumber(ARGV[1]) + + -- ZREVRANGEBYSCORE returns members with score <= target, highest first. + local result = redis.call('ZREVRANGEBYSCORE', ts_key, target, '-inf', 'LIMIT', 0, 1) + if #result == 0 then + return -1 + end + return tonumber(result[1]) + """ + + def find_chunk_index_by_time(self, seconds_behind): + """Find the chunk index that was received approximately *seconds_behind* + seconds ago. + + Uses an atomic Lua script against the chunk_timestamps sorted set so + no data can expire between the lookup and the read. + + Returns: + int or None: The chunk index to position the client at (this is + the *last consumed* convention, so the next read + starts at index+1). None if no suitable chunk + exists. + """ + if not self.redis_client or not self.chunk_timestamps_key: + return None + + target_time = time.time() - seconds_behind + + try: + result = self._find_chunk_by_time_sha( + keys=[self.chunk_timestamps_key], + args=[target_time], + ) + if result is None or int(result) == -1: + # No chunk old enough — fall back to the oldest available chunk + oldest = self.redis_client.zrange(self.chunk_timestamps_key, 0, 0) + if oldest: + return max(0, int(oldest[0]) - 1) # "last consumed" convention + return None + + # Return index - 1 so next read starts at that chunk + return max(0, int(result) - 1) + + except Exception as e: + logger.error(f"Error in find_chunk_index_by_time for channel {self.channel_id}: {e}") + return None + # Add a new method to safely create timers def schedule_timer(self, delay, callback, *args, **kwargs): """Schedule a timer and track it for proper cleanup""" diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index 50404f1d..f0174616 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -8,7 +8,7 @@ import logging import threading import gevent # Add this import at the top of your file from apps.proxy.config import TSConfig as Config -from apps.channels.models import Channel +from apps.channels.models import Channel, Stream from core.utils import log_system_event from .server import ProxyServer from .utils import create_ts_packet, get_logger @@ -58,6 +58,19 @@ class StreamGenerator: self.last_ttl_refresh = time.time() self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming + # Cached proxy server reference + self.proxy_server = None + + # Non-owner health check throttle: avoid Redis GET on every loop iteration + self._last_health_check_time = 0.0 + self._last_health_check_result = False + self._health_check_interval = 2.0 # seconds + + # Resource check throttle: Redis stop/state checks are expensive; throttle + # them while allowing cheap in-memory checks to run every iteration. + self._last_resource_check_time = 0.0 + self._resource_check_interval = 1.0 # seconds + def generate(self): """ Generator function that produces the stream content for the client. @@ -186,12 +199,50 @@ class StreamGenerator: logger.error(f"[{self.client_id}] No buffer found for channel {self.channel_id}") return False - # Client state tracking - use config for initial position - initial_behind = ConfigHelper.initial_behind_chunks() + # Client state tracking — determine start position + # When behind_seconds > 0, use time-based positioning to start + # the client that many seconds behind live. + # When behind_seconds == 0, start at live (buffer head). + behind_seconds = ConfigHelper.new_client_behind_seconds() current_buffer_index = buffer.index - self.local_index = max(0, current_buffer_index - initial_behind) + + if behind_seconds > 0: + time_index = buffer.find_chunk_index_by_time(behind_seconds) + if time_index is not None: + self.local_index = max(0, time_index) + logger.info( + f"[{self.client_id}] Time-based positioning: " + f"{behind_seconds}s behind -> index {self.local_index} " + f"(buffer head at {current_buffer_index})" + ) + else: + # Not enough buffer for the requested time — start as far + # back as possible (oldest available chunk). + oldest = buffer.find_oldest_available_chunk(0) + if oldest is not None: + self.local_index = max(0, oldest) + logger.info( + f"[{self.client_id}] Buffer shorter than {behind_seconds}s, " + f"starting at oldest available chunk {self.local_index} " + f"(buffer head at {current_buffer_index})" + ) + else: + # No timestamp data at all — start at live + self.local_index = current_buffer_index + logger.info( + f"[{self.client_id}] No timestamp data, starting at live: " + f"index {self.local_index} (buffer head at {current_buffer_index})" + ) + else: + # 0 = start at live (buffer head) + self.local_index = current_buffer_index + logger.info( + f"[{self.client_id}] Starting at live (behind_seconds=0): " + f"index {self.local_index} (buffer head at {current_buffer_index})" + ) # Store important objects as instance variables + self.proxy_server = proxy_server self.buffer = buffer self.stream_manager = stream_manager self.last_yield_time = time.time() @@ -224,17 +275,35 @@ class StreamGenerator: self.empty_reads += 1 self.consecutive_empty += 1 - # Check if we're too far behind (chunks expired from Redis) + # We got no data despite being behind the buffer head. + # The read itself is the authoritative signal — no separate + # existence check needed, avoiding TOCTOU races with Redis TTL. chunks_behind = self.buffer.index - self.local_index - if chunks_behind > 50: # If more than 50 chunks behind, jump forward - # Calculate new position: stay a few chunks behind current buffer - initial_behind = ConfigHelper.initial_behind_chunks() - new_index = max(self.local_index, self.buffer.index - initial_behind) + if chunks_behind > 0: + # Next chunk has expired — find the oldest chunk still in Redis + new_index = self.buffer.find_oldest_available_chunk(self.local_index) - logger.warning(f"[{self.client_id}] Client too far behind ({chunks_behind} chunks), jumping from {self.local_index} to {new_index}") - self.local_index = new_index - self.consecutive_empty = 0 # Reset since we're repositioning - continue # Try again immediately with new position + if new_index is not None: + skipped = new_index - self.local_index + logger.warning( + f"[{self.client_id}] Next chunk expired (index {self.local_index + 1}), " + f"jumping to oldest available: {new_index + 1} " + f"(skipped {skipped} chunks, buffer head at {self.buffer.index})" + ) + self.local_index = new_index + else: + # No chunks available at all — jump to near the buffer head + initial_behind = ConfigHelper.initial_behind_chunks() + new_index = max(self.local_index, self.buffer.index - initial_behind) + logger.warning( + f"[{self.client_id}] No chunks available in buffer, " + f"jumping to near buffer head: {new_index} " + f"(buffer head at {self.buffer.index})" + ) + self.local_index = new_index + + self.consecutive_empty = 0 + continue # Retry immediately with the new position if self._should_send_keepalive(self.local_index): keepalive_packet = create_ts_packet('keepalive') @@ -265,9 +334,7 @@ class StreamGenerator: def _check_resources(self): """Check if required resources still exist.""" - proxy_server = ProxyServer.get_instance() - - # Enhanced resource checks + proxy_server = self.proxy_server or ProxyServer.get_instance() if self.channel_id not in proxy_server.stream_buffers: logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream") return False @@ -276,35 +343,43 @@ class StreamGenerator: logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream") return False - # Check if this specific client has been stopped (Redis keys, etc.) - if proxy_server.redis_client: - # Channel stop check - with extended key set - stop_key = RedisKeys.channel_stopping(self.channel_id) - if proxy_server.redis_client.exists(stop_key): - logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream") + client_manager = proxy_server.client_managers[self.channel_id] + if self.client_id not in client_manager.clients: + logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream") + return False + + # --- Redis checks: throttled to _resource_check_interval (default 1s) --- + # 3 Redis round-trips on every iteration is expensive at stream rates; + # stop/state signals change infrequently so a 1-second poll is sufficient. + if not proxy_server.redis_client: + return True + + now = time.time() + if now - self._last_resource_check_time < self._resource_check_interval: + return True + + self._last_resource_check_time = now + + # Channel stop check + stop_key = RedisKeys.channel_stopping(self.channel_id) + if proxy_server.redis_client.exists(stop_key): + logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream") + return False + + # Channel state in metadata + metadata_key = RedisKeys.channel_metadata(self.channel_id) + metadata = proxy_server.redis_client.hgetall(metadata_key) + if metadata and b'state' in metadata: + state = metadata[b'state'].decode('utf-8') + if state in ['error', 'stopped', 'stopping']: + logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream") return False - # Also check channel state in metadata - metadata_key = RedisKeys.channel_metadata(self.channel_id) - metadata = proxy_server.redis_client.hgetall(metadata_key) - if metadata and b'state' in metadata: - state = metadata[b'state'].decode('utf-8') - if state in ['error', 'stopped', 'stopping']: - logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream") - return False - - # Client stop check - client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id) - if proxy_server.redis_client.exists(client_stop_key): - logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream") - return False - - # Also check if client has been removed from client_manager - if self.channel_id in proxy_server.client_managers: - client_manager = proxy_server.client_managers[self.channel_id] - if self.client_id not in client_manager.clients: - logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream") - return False + # Client stop check + client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id) + if proxy_server.redis_client.exists(client_stop_key): + logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream") + return False return True @@ -313,7 +388,7 @@ class StreamGenerator: # Process and send chunks total_size = sum(len(c) for c in chunks) logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}") - proxy_server = ProxyServer.get_instance() + proxy_server = self.proxy_server or ProxyServer.get_instance() # Send the chunks to the client for chunk in chunks: @@ -381,10 +456,38 @@ class StreamGenerator: """Determine if a keepalive packet should be sent.""" # Check if we're caught up to buffer head at_buffer_head = local_index >= self.buffer.index + if not at_buffer_head or self.consecutive_empty < 5: + return False - # If we're at buffer head and no data is coming, send keepalive - stream_healthy = self.stream_manager.healthy if self.stream_manager else True - return at_buffer_head and not stream_healthy and self.consecutive_empty >= 5 + if self.stream_manager is not None: + # Owner worker: use the in-memory health flag directly. + return not self.stream_manager.healthy + else: + # Non-owner worker: stream_manager only exists in the owner process. + # Approximate health from the Redis last_data timestamp; if stale + # beyond CONNECTION_TIMEOUT, send keepalives to prevent DVR timeout. + # Throttled: only re-query Redis every _health_check_interval seconds + # to avoid a Redis GET on every loop iteration during sustained waits. + now = time.time() + if now - self._last_health_check_time < self._health_check_interval: + return self._last_health_check_result + try: + proxy_server = self.proxy_server or ProxyServer.get_instance() + if proxy_server.redis_client: + raw = proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id)) + if raw: + age = now - float(raw) + timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10) + result = age >= timeout_threshold + else: + # No timestamp in Redis → key missing or expired → unhealthy + result = True + self._last_health_check_time = now + self._last_health_check_result = result + return result + except Exception: + pass + return False def _is_ghost_client(self, local_index): """Check if this appears to be a ghost client (stuck but buffer advancing).""" @@ -437,13 +540,17 @@ class StreamGenerator: client_count = proxy_server.client_managers[self.channel_id].get_total_client_count() # Only the last client or owner should release the stream if client_count <= 1 and proxy_server.am_i_owner(self.channel_id): - from apps.channels.models import Channel try: - # Get the channel by UUID - channel = Channel.objects.get(uuid=self.channel_id) - channel.release_stream() - stream_released = True - logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}") + # Try Channel first (normal flow), fall back to Stream (preview flow) + try: + obj = Channel.objects.get(uuid=self.channel_id) + except (Channel.DoesNotExist, Exception): + obj = Stream.objects.get(stream_hash=self.channel_id) + stream_released = obj.release_stream() + if stream_released: + logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}") + else: + logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}") except Exception as e: logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}") except Exception as e: @@ -472,8 +579,7 @@ class StreamGenerator: logger.error(f"Could not log client disconnect event: {e}") # Schedule channel shutdown if no clients left - if not stream_released: # Only if we haven't already released the stream - self._schedule_channel_shutdown_if_needed(local_clients) + self._schedule_channel_shutdown_if_needed(local_clients) def _schedule_channel_shutdown_if_needed(self, local_clients): """ diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 8b467b7f..14a714ea 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -39,84 +39,44 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] # Handle direct stream preview (custom streams) if isinstance(channel_or_stream, Stream): - from core.utils import RedisClient - stream = channel_or_stream logger.info(f"Previewing stream directly: {stream.id} ({stream.name})") - # For custom streams, we need to get the M3U account and profile - m3u_account = stream.m3u_account - if not m3u_account: + if not stream.m3u_account: logger.error(f"Stream {stream.id} has no M3U account") return None, None, False, None - # Get active profiles for this M3U account - m3u_profiles = m3u_account.profiles.filter(is_active=True) - default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) - - if not default_profile: - logger.error(f"No default active profile found for M3U account {m3u_account.id}") + # Use get_stream() to atomically reserve a slot and write the + # channel_stream / stream_profile Redis keys, matching the channel + # path so stream_name and stream_stats work correctly. + stream_id, profile_id, error_reason = stream.get_stream() + if not stream_id or not profile_id: + logger.error(f"No profile available for stream {stream.id}: {error_reason}") return None, None, False, None - # Check profiles in order: default first, then others - profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] + try: + profile = M3UAccountProfile.objects.get(id=profile_id) + m3u_account = stream.m3u_account - # Try to find an available profile with connection capacity - redis_client = RedisClient.get_client() - selected_profile = None + stream_user_agent = m3u_account.get_user_agent().user_agent + if stream_user_agent is None: + stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) + logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - for profile in profiles: - logger.info(profile) + stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern) - # Check connection availability - if redis_client: - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) + stream_profile = stream.get_stream_profile() + logger.debug(f"Using stream profile: {stream_profile.name}") - # Check if profile has available slots (or unlimited connections) - if profile.max_streams == 0 or current_connections < profile.max_streams: - selected_profile = profile - logger.debug(f"Selected profile {profile.id} with {current_connections}/{profile.max_streams} connections for stream preview") - break - else: - logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}") - else: - # No Redis available, use first active profile - selected_profile = profile - break + transcode = not stream_profile.is_proxy() + stream_profile_id = stream_profile.id - if not selected_profile: - logger.error(f"No profiles available with connection capacity for M3U account {m3u_account.id}") + return stream_url, stream_user_agent, transcode, stream_profile_id + except Exception as e: + logger.error(f"Error generating stream URL for stream {stream.id}: {e}") + stream.release_stream() return None, None, False, None - # Get the appropriate user agent - stream_user_agent = m3u_account.get_user_agent().user_agent - if stream_user_agent is None: - stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) - logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - - # Get stream URL with the selected profile's URL transformation - stream_url = transform_url(stream.url, selected_profile.search_pattern, selected_profile.replace_pattern) - - # Check if the stream has its own stream_profile set, otherwise use default - if stream.stream_profile: - stream_profile = stream.stream_profile - logger.debug(f"Using stream's own stream profile: {stream_profile.name}") - else: - stream_profile = StreamProfile.objects.get( - id=CoreSettings.get_default_stream_profile_id() - ) - logger.debug(f"Using default stream profile: {stream_profile.name}") - - # Check if transcoding is needed - if stream_profile.is_proxy() or stream_profile is None: - transcode = False - else: - transcode = True - - stream_profile_id = stream_profile.id - - return stream_url, stream_user_agent, transcode, stream_profile_id # Handle channel preview (existing logic) channel = channel_or_stream @@ -129,39 +89,42 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] logger.error(f"No stream available for channel {channel_id}: {error_reason}") return None, None, False, None - # Look up the Stream and Profile objects + # get_stream() allocated a connection slot - ensure it's released on any error try: + # Look up the Stream and Profile objects stream = Stream.objects.get(id=stream_id) profile = M3UAccountProfile.objects.get(id=profile_id) - except (Stream.DoesNotExist, M3UAccountProfile.DoesNotExist) as e: - logger.error(f"Error getting stream or profile: {e}") + + # Get the M3U account profile for URL pattern + m3u_profile = profile + + # Get the appropriate user agent + m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id) + stream_user_agent = m3u_account.get_user_agent().user_agent + + if stream_user_agent is None: + stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) + logger.debug(f"No user agent found for account, using default: {stream_user_agent}") + + # Generate stream URL based on the selected profile + input_url = stream.url + stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) + + # Check if transcoding is needed + stream_profile = channel.get_stream_profile() + if stream_profile.is_proxy() or stream_profile is None: + transcode = False + else: + transcode = True + + stream_profile_id = stream_profile.id + + return stream_url, stream_user_agent, transcode, stream_profile_id + except Exception as e: + logger.error(f"Error generating stream URL for channel {channel_id}: {e}") + if not channel.release_stream(): + logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error") return None, None, False, None - - # Get the M3U account profile for URL pattern - m3u_profile = profile - - # Get the appropriate user agent - m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id) - stream_user_agent = m3u_account.get_user_agent().user_agent - - if stream_user_agent is None: - stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) - logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - - # Generate stream URL based on the selected profile - input_url = stream.url - stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) - - # Check if transcoding is needed - stream_profile = channel.get_stream_profile() - if stream_profile.is_proxy() or stream_profile is None: - transcode = False - else: - transcode = True - - stream_profile_id = stream_profile.id - - return stream_url, stream_user_agent, transcode, stream_profile_id except Exception as e: logger.error(f"Error generating stream URL: {e}") return None, None, False, None diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 91f254a7..ccbb947d 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -54,6 +54,7 @@ def stream_ts(request, channel_id): client_user_agent = None proxy_server = ProxyServer.get_instance() + connection_allocated = False # Track if connection slot was allocated via get_stream() try: # Generate a unique client ID @@ -219,9 +220,10 @@ def stream_ts(request, channel_id): ) if stream_url is None: - # Release the channel's stream lock if one was acquired - # Note: Only call this if get_stream() actually assigned a stream - # In our case, if stream_url is None, no stream was ever assigned, so don't release + # Release any connection slot that may have been allocated + # by the error-checking get_stream() call during retries + if not channel.release_stream(): + logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup") # Get the specific error message if available wait_duration = f"{int(time.time() - wait_start_time)}s" @@ -237,8 +239,23 @@ def stream_ts(request, channel_id): {"error": error_msg, "waited": wait_duration}, status=503 ) # 503 Service Unavailable is appropriate here - # Get the stream ID from the channel - stream_id, m3u_profile_id, _ = channel.get_stream() + # generate_stream_url() called get_stream() which allocated a connection + # slot (INCR'd profile_connections) - track this for cleanup on error + if needs_initialization: + connection_allocated = True + + # Read stream assignment from Redis (already set by generate_stream_url → get_stream). + # Avoid calling get_stream() again — (INCR profile counter) + # It could double-allocate if the keys were cleared by a concurrent release. + stream_id = None + m3u_profile_id = None + if proxy_server.redis_client: + stream_id_bytes = proxy_server.redis_client.get(f"channel_stream:{channel.id}") + if stream_id_bytes: + stream_id = int(stream_id_bytes) + profile_id_bytes = proxy_server.redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + m3u_profile_id = int(profile_id_bytes) logger.info( f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}" ) @@ -308,7 +325,9 @@ def stream_ts(request, channel_id): f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}" ) # Release stream lock before redirecting - channel.release_stream() + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream before redirect") + connection_allocated = False # Final decision based on validation results if is_valid: logger.info( @@ -344,10 +363,17 @@ def stream_ts(request, channel_id): ) if not success: + if connection_allocated: + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream after init failure") + connection_allocated = False return JsonResponse( {"error": "Failed to initialize channel"}, status=500 ) + # Channel initialized - cleanup lifecycle now owns the connection release + connection_allocated = False + # If we're the owner, wait for connection to establish if proxy_server.am_i_owner(channel_id): manager = proxy_server.stream_managers.get(channel_id) @@ -507,6 +533,12 @@ def stream_ts(request, channel_id): except Exception as e: logger.error(f"Error in stream_ts: {e}", exc_info=True) + if connection_allocated: + try: + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream in exception handler") + except Exception: + pass return JsonResponse({"error": str(e)}, status=500) diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index aafc75cd..7bfccfca 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -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: @@ -1363,17 +1402,25 @@ class VODConnectionManager: client_id = session_id # Remove individual connection tracking keys created during streaming + # Check if connection key exists first - remove_connection() + # handles the profile DECR when the key exists, but skips it + # if the key already expired by TTL + connection_key_exists = False if content_type and content_uuid: + connection_key = self._get_connection_key(content_type, content_uuid, client_id) + connection_key_exists = bool(self.redis_client.exists(connection_key)) logger.info(f"[{session_id}] Cleaning up connection tracking keys") self.remove_connection(content_type, content_uuid, client_id) - # Remove from profile connections if counted (additional safety check) - if session_data.get(b'connection_counted') == b'True' and profile_id: - profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8'))) - current_count = int(self.redis_client.get(profile_key) or 0) - if current_count > 0: - self.redis_client.decr(profile_key) - logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections") + # Fallback DECR: only if the connection tracking key had already + # expired (remove_connection skips DECR in that case) + if not connection_key_exists: + if session_data.get(b'connection_counted') == b'True' and profile_id: + profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8'))) + current_count = int(self.redis_client.get(profile_key) or 0) + if current_count > 0: + self.redis_client.decr(profile_key) + logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections (fallback)") # Remove session tracking key self.redis_client.delete(session_key) diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 1534f761..6b048529 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -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: @@ -1036,9 +1154,10 @@ class MultiWorkerVODConnectionManager: self._decrement_profile_connections(m3u_profile.id) # Also clean up the Redis connection state since we won't be using it + # Pass connection_manager=None since we already decremented above if redis_connection: try: - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + redis_connection.cleanup(current_worker_id=self.worker_id) except Exception as cleanup_error: logger.error(f"[{client_id}] Error during cleanup after connection failure: {cleanup_error}") @@ -1262,12 +1381,14 @@ class MultiWorkerVODConnectionManager: profile_id = connection_data.get('m3u_profile_id') if profile_id: profile_connections_key = f"profile_connections:{profile_id}" + current_count = int(self.redis_client.get(profile_connections_key) or 0) # Use pipeline for atomic operations pipe = self.redis_client.pipeline() pipe.delete(connection_key) pipe.srem(content_connections_key, client_id) - pipe.decr(profile_connections_key) + if current_count > 0: + pipe.decr(profile_connections_key) pipe.execute() logger.info(f"Removed Redis-backed connection: {client_id}") diff --git a/apps/vod/admin.py b/apps/vod/admin.py index c660f310..6dadab3e 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -60,7 +60,7 @@ class M3USeriesRelationAdmin(admin.ModelAdmin): @admin.register(M3UEpisodeRelation) class M3UEpisodeRelationAdmin(admin.ModelAdmin): - list_display = ['episode', 'm3u_account', 'stream_id', 'created_at'] + list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at'] list_filter = ['m3u_account', 'created_at'] search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py new file mode 100644 index 00000000..e4973627 --- /dev/null +++ b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-24 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='series_relation', + field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index 7067856e..dd7d1753 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -261,6 +261,14 @@ class M3UEpisodeRelation(models.Model): """Links M3U accounts to Episodes with provider-specific information""" m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) # Streaming information (provider-specific) stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index c24023d6..ff195fc9 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -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 @@ -1628,20 +1645,33 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= orphaned_movie_count = orphaned_movies.count() if orphaned_movie_count > 0: logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations") - orphaned_movies.delete() + try: + orphaned_movies.delete() + except IntegrityError: + # A concurrent refresh task created a new relation for one of these movies + # between our query and the DELETE. Skip and let the next cleanup run handle it. + logger.warning( + "Skipped some orphaned movie deletions due to concurrent modifications; " + "they will be retried on the next cleanup run." + ) + orphaned_movie_count = 0 # Clean up series with no relations (orphaned) orphaned_series = Series.objects.filter(m3u_relations__isnull=True) orphaned_series_count = orphaned_series.count() if orphaned_series_count > 0: 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 + try: + orphaned_series.delete() + except IntegrityError: + logger.warning( + "Skipped some orphaned series deletions due to concurrent modifications; " + "they will be retried on the next cleanup run." + ) + orphaned_series_count = 0 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") diff --git a/core/api_views.py b/core/api_views.py index 20ef6249..dd15886c 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -183,6 +183,7 @@ class ProxySettingsViewSet(viewsets.ViewSet): "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, } settings_obj, created = CoreSettings.objects.get_or_create( key=PROXY_SETTINGS_KEY, @@ -506,7 +507,7 @@ class SystemNotificationViewSet(viewsets.ModelViewSet): ) # Filter admin-only notifications for non-admins - if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10: + if getattr(user, 'user_level', 0) < 10: queryset = queryset.filter(admin_only=False) # For developer notifications, evaluate conditions diff --git a/core/apps.py b/core/apps.py index f2780bd1..ee182e89 100644 --- a/core/apps.py +++ b/core/apps.py @@ -31,7 +31,6 @@ class CoreConfig(AppConfig): return self._sync_developer_notifications() - self._check_version_update() def _sync_developer_notifications(self): """Sync developer notifications from JSON file to database.""" @@ -47,14 +46,3 @@ class CoreConfig(AppConfig): except Exception as e: logger.warning(f"Failed to sync developer notifications on startup: {e}") - def _check_version_update(self): - """Check for version updates on startup.""" - import logging - - logger = logging.getLogger(__name__) - - try: - from core.tasks import check_for_version_update - check_for_version_update.delay() - except Exception as e: - logger.warning(f"Failed to check for version updates on startup: {e}") diff --git a/core/developer_notifications.py b/core/developer_notifications.py index cf16e7a2..f4214b43 100644 --- a/core/developer_notifications.py +++ b/core/developer_notifications.py @@ -200,7 +200,7 @@ def should_show_notification(notification_data: dict, user) -> bool: # Check user level user_level = notification_data.get('user_level', 'all') - if user_level == 'admin' and not getattr(user, 'is_superuser', False): + if user_level == 'admin' and getattr(user, 'user_level', 0) < 10: return False # Check conditions @@ -396,7 +396,7 @@ def get_user_developer_notifications(user) -> list: ) # Filter by admin_only based on user - if not getattr(user, 'is_superuser', False): + if getattr(user, 'user_level', 0) < 10: notifications = notifications.filter(admin_only=False) # Filter by conditions diff --git a/core/models.py b/core/models.py index 10f9073f..4e6322e7 100644 --- a/core/models.py +++ b/core/models.py @@ -329,6 +329,7 @@ class CoreSettings(models.Model): "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, }) # System Settings diff --git a/core/scheduling.py b/core/scheduling.py new file mode 100644 index 00000000..0b4b78c6 --- /dev/null +++ b/core/scheduling.py @@ -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() diff --git a/core/serializers.py b/core/serializers.py index 03ba33e8..bff5f701 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -78,6 +78,7 @@ class ProxySettingsSerializer(serializers.Serializer): redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600) channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300) channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60) + new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5) def validate_buffering_timeout(self, value): if value < 0 or value > 300: @@ -104,6 +105,11 @@ class ProxySettingsSerializer(serializers.Serializer): raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds") return value + def validate_new_client_behind_seconds(self, value): + if value < 0 or value > 120: + raise serializers.ValidationError("New client buffer must be between 0 and 120 seconds") + return value + class SystemNotificationSerializer(serializers.ModelSerializer): """Serializer for system notifications.""" diff --git a/core/tasks.py b/core/tasks.py index 44ddc68a..a18c2f74 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -789,6 +789,7 @@ def check_for_version_update(): try: is_dev_build = __timestamp__ is not None DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'} + if is_dev_build: # Check Docker Hub for newer dev builds docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev" @@ -878,7 +879,21 @@ def check_for_version_update(): } ) else: - # Production build - check GitHub for stable releases + # Production build - check GitHub for stable releases. + # Delete any stale notification for the currently running version upfront; + # a "vX is available" notification is meaningless once the user is already on vX. + # Notify the frontend immediately so the badge clears without waiting for the API call. + deleted_count = SystemNotification.objects.filter( + notification_key=f"version-{__version__}", + notification_type='version_update', + ).delete()[0] + if deleted_count > 0: + send_websocket_update( + 'updates', + 'update', + {'success': True, 'type': 'notifications_cleared', 'count': deleted_count} + ) + github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest" headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS} response = requests.get( diff --git a/core/utils.py b/core/utils.py index c0e87a42..ef5aa702 100644 --- a/core/utils.py +++ b/core/utils.py @@ -81,7 +81,6 @@ class RedisClient: # Validate connection with ping client.ping() - client.flushdb() # Disable persistence on first connection - improves performance # Only try to disable if not in a read-only environment @@ -222,34 +221,107 @@ 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. - Args: - group_name: The WebSocket group to send to (e.g. 'updates') - event_type: The type of message (e.g. 'update') - data: The data to send - collect_garbage: Whether to force garbage collection after sending + In uWSGI + gevent deployments, async_to_sync creates an asyncio event loop + whose native EpollSelector blocks the entire OS thread, freezing all gevent + greenlets in the worker. To avoid this, the actual send is offloaded to a + real OS thread from gevent's native threadpool when monkey-patching is active. """ channel_layer = get_channel_layer() - try: - async_to_sync(channel_layer.group_send)( - group_name, - { - 'type': event_type, - 'data': data - } - ) - except Exception as e: - logger.warning(f"Failed to send WebSocket update: {e}") - finally: - # Explicitly release references to help garbage collection - channel_layer = None + message = {'type': event_type, 'data': data} - # Force garbage collection if requested - if collect_garbage: - gc.collect() + def _do_send(): + try: + async_to_sync(channel_layer.group_send)(group_name, message) + except Exception as e: + logger.warning(f"Failed to send WebSocket update: {e}") + + try: + import gevent.monkey + if gevent.monkey.is_module_patched('threading'): + from gevent import get_hub + get_hub().threadpool.spawn(_do_send) + return + except Exception: + pass + + # Not in a gevent-patched environment — call directly + _do_send() + + if collect_garbage: + gc.collect() def send_websocket_event(event, success, data): """Acquire a lock to prevent concurrent task execution.""" @@ -397,6 +469,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 +572,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 +669,3 @@ def send_notification_dismissed(notification_key): ) except Exception as e: logger.error(f"Failed to send notification dismissed event: {e}") - diff --git a/debian_install.sh b/debian_install.sh index 3452e5c1..305424f8 100755 --- a/debian_install.sh +++ b/debian_install.sh @@ -12,9 +12,20 @@ fi trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR ############################################################################## -# 0) Warning / Disclaimer +# 0) Locales & Warning / Disclaimer ############################################################################## +setup_locales() { + echo ">>> Setting up locales..." + apt-get update + apt-get install -y locales + sed -i '/en_US.UTF-8 UTF-8/s/^# //g' /etc/locale.gen + locale-gen + update-locale LANG=en_US.UTF-8 + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 +} + show_disclaimer() { echo "**************************************************************" echo "WARNING: While we do not anticipate any problems, we disclaim all" @@ -106,8 +117,8 @@ setup_postgresql() { db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'") if [[ "$db_exists" != "1" ]]; then - echo ">>> Creating database '${POSTGRES_DB}'..." - sudo -u postgres createdb "$POSTGRES_DB" + echo ">>> Creating database '${POSTGRES_DB}' with UTF8 encoding..." + sudo -u postgres createdb -E UTF8 "$POSTGRES_DB" else echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation." fi @@ -168,24 +179,51 @@ install_uv() { setup_python_env() { echo ">>> Setting up Python virtual environment with UV..." - # Install UV globally first - install_uv su - "$DISPATCH_USER" < /dev/null; then - curl -LsSf https://astral.sh/uv/install.sh | sh + set -euo pipefail + cd "$APP_DIR" export PATH="\$HOME/.local/bin:\$PATH" -fi -# Create venv and install dependencies using UV -uv venv env --python $PYTHON_BIN -uv sync --python env/bin/python --no-install-project --no-dev + + command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh + + rm -rf env + $PYTHON_BIN -m venv env + env/bin/python -m ensurepip --upgrade + + export UV_PROJECT_ENVIRONMENT="$APP_DIR/env" + uv sync --no-dev + + env/bin/python -m pip install -q gunicorn EOSU + ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg" } + +############################################################################## +# 6.1) Ensure Environment File +############################################################################## + +ensure_env_file() { + echo ">>> Ensuring DJANGO_SECRET_KEY exists in ${APP_DIR}/.env..." + su - "$DISPATCH_USER" <> .env +fi +EOSU +} + + ############################################################################## # 7) Build Frontend ############################################################################## @@ -231,17 +269,21 @@ create_directories() { django_migrate_collectstatic() { echo ">>> Running Django migrations & collectstatic..." su - "$DISPATCH_USER" <&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 +# Uses 'migrate --check' which exits 0 only when all migrations are applied, +# and exits 1 on unapplied migrations OR connection errors (safe either way) +MIG_TIMEOUT=300 +MIG_WAITED=0 echo 'Waiting for migrations to complete...' -until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do - echo 'Migrations not ready yet, waiting...' +until python manage.py migrate --check >/dev/null 2>&1; do + if [ $MIG_WAITED -ge $MIG_TIMEOUT ]; then + echo "❌ ERROR: Timed out waiting for migrations after ${MIG_TIMEOUT}s." + echo " Check web container logs for migration errors." + exit 1 + fi + echo_with_timestamp 'Migrations not ready yet, waiting...' sleep 2 + MIG_WAITED=$((MIG_WAITED + 2)) done # Start Celery diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8ea47318..7d2a1bf6 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -99,31 +99,35 @@ echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'" # READ-ONLY - don't let users change these export POSTGRES_DIR=/data/db -# Global variables, stored so other users inherit them -if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then - # Define all variables to process - variables=( - PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE - POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT - DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT - DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH - CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY - ) +# Global variables, stored so other users inherit them. +# Rewritten every startup so that container restarts with changed env vars +# pick up the new values (not stale ones from a previous run). +# Define all variables to process +variables=( + PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE + POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT + DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT + DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH + CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY +) - # Process each variable for both profile.d and environment - for var in "${variables[@]}"; do - # Check if the variable is set in the environment - if [ -n "${!var+x}" ]; then - # Add to profile.d - echo "export ${var}=${!var}" >> /etc/profile.d/dispatcharr.sh - # Add to /etc/environment if not already there - grep -q "^${var}=" /etc/environment || echo "${var}=${!var}" >> /etc/environment - else - echo "Warning: Environment variable $var is not set" - fi - done -fi +# Truncate files before rewriting +> /etc/profile.d/dispatcharr.sh + +# Process each variable for both profile.d and environment +for var in "${variables[@]}"; do + # Check if the variable is set in the environment + if [ -n "${!var+x}" ]; then + # Add to profile.d (quoted to handle special characters in values) + echo "export ${var}='${!var}'" >> /etc/profile.d/dispatcharr.sh + # Add/update in /etc/environment + sed -i "/^${var}=/d" /etc/environment + echo "${var}='${!var}'" >> /etc/environment + else + echo "Warning: Environment variable $var is not set" + fi +done chmod +x /etc/profile.d/dispatcharr.sh @@ -163,20 +167,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 @@ -186,26 +179,17 @@ except Exception: check_external_postgres_version || exit 1 fi -# Wait for Redis to be ready (modular mode uses external Redis) +# Wait for Redis to be ready and flush stale state. +# In modular mode Redis is external — call wait_for_redis.py here +# because uWSGI's exec-pre runs under 'su -' which strips env vars +# (DISPATCHARR_ENV, REDIS_HOST, etc.). +# In AIO mode Redis is started by uWSGI (attach-daemon), so the +# exec-pre in uwsgi.ini handles the wait + flush there instead. 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 -try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(2) - s.connect(('${REDIS_HOST}', ${REDIS_PORT})) - s.close() - sys.exit(0) -except Exception: - sys.exit(1) -" 2>/dev/null; do - echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." - sleep 1 - done - echo "✅ External Redis is ready" + echo_with_timestamp "Waiting for Redis to be ready..." + python3 /app/scripts/wait_for_redis.py + echo "✅ Redis is ready" fi # Ensure database encoding is UTF8 (handles both internal and external databases) diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 87aa94ef..2ece526f 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -204,14 +204,19 @@ check_external_postgres_version() { MIN_REQUIRED_VERSION=$PG_VERSION # Query external PostgreSQL version - EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "postgres" -tAc "SHOW server_version;" 2>/dev/null | grep -oE '^[0-9]+') + # Use $POSTGRES_DB — restricted users may not have access to the default 'postgres' database + PG_VERSION_ERR=$(mktemp) + EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SHOW server_version;" 2>"$PG_VERSION_ERR" | grep -oE '^[0-9]+') if [ -z "$EXTERNAL_VERSION" ]; then echo "❌ ERROR: Unable to determine external PostgreSQL version" - echo " Could not connect to database at ${POSTGRES_HOST}:${POSTGRES_PORT}" + echo " Could not connect to database '$POSTGRES_DB' at ${POSTGRES_HOST}:${POSTGRES_PORT} as user '$POSTGRES_USER'" + echo " Error: $(cat "$PG_VERSION_ERR")" echo " Please verify your database connection settings." + rm -f "$PG_VERSION_ERR" return 1 fi + rm -f "$PG_VERSION_ERR" # Compare versions if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 0c317017..b4524bbc 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -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 \ No newline at end of file +fi diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini index 3220a8d8..57ecaea8 100644 --- a/docker/uwsgi.modular.ini +++ b/docker/uwsgi.modular.ini @@ -5,8 +5,8 @@ ; exec-pre = touch /data/logs/uwsgi.log ; exec-pre = chmod 666 /data/logs/uwsgi.log -; First run Redis availability check script once -exec-pre = python /app/scripts/wait_for_redis.py +; Redis wait + flush is handled by the entrypoint in modular mode +; (uWSGI exec-pre runs under 'su -' which strips Docker env vars) ; Start Daphne for WebSocket support (required for real-time features) ; Redis and Celery run in separate containers in modular mode diff --git a/docs/images/channels.png b/docs/images/channels.png new file mode 100644 index 00000000..87236f1f Binary files /dev/null and b/docs/images/channels.png differ diff --git a/docs/images/guide.png b/docs/images/guide.png new file mode 100644 index 00000000..37cfa132 Binary files /dev/null and b/docs/images/guide.png differ diff --git a/docs/images/m3u-epg-manager.png b/docs/images/m3u-epg-manager.png new file mode 100644 index 00000000..34bea87d Binary files /dev/null and b/docs/images/m3u-epg-manager.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png new file mode 100644 index 00000000..9106fbe5 Binary files /dev/null and b/docs/images/settings.png differ diff --git a/docs/images/stats.png b/docs/images/stats.png new file mode 100644 index 00000000..a664bb7d Binary files /dev/null and b/docs/images/stats.png differ diff --git a/docs/images/vod-library.png b/docs/images/vod-library.png new file mode 100644 index 00000000..6effdb70 Binary files /dev/null and b/docs/images/vod-library.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a349a3d..5ba9fec3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,9 +62,9 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.29", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz", - "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, "license": "MIT" }, @@ -76,23 +76,23 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", - "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -100,7 +100,7 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -111,12 +111,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -125,13 +125,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -150,13 +150,13 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -181,12 +181,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -196,40 +196,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -237,9 +237,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -250,9 +250,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -266,13 +266,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -286,17 +286,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -310,21 +310,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -338,16 +338,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.21.tgz", - "integrity": "sha512-plP8N8zKfEZ26figX4Nvajx8DuzfuRpLTqglQ5d0chfnt35Qt3X+m6ASZ+rG0D0kxe/upDVNwSIVJP5n4FuNfw==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", "dev": true, "funding": [ { @@ -359,15 +359,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -381,7 +378,7 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@dnd-kit/accessibility": { @@ -598,9 +595,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -615,9 +612,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -632,9 +629,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -649,9 +646,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -666,9 +663,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -683,9 +680,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -700,9 +697,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -717,9 +714,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -734,9 +731,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -751,9 +748,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -768,9 +765,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -785,9 +782,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -802,9 +799,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -819,9 +816,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -836,9 +833,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -853,9 +850,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -870,9 +867,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -887,9 +884,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -904,9 +901,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -921,9 +918,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -938,9 +935,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -955,9 +952,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -972,9 +969,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -989,9 +986,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1006,9 +1003,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1023,9 +1020,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1040,9 +1037,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1160,9 +1157,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1196,22 +1193,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, @@ -1231,12 +1246,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -1348,12 +1363,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "license": "Apache-2.0" - }, "node_modules/@mantine/charts": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.0.2.tgz", @@ -1475,16 +1484,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", - "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1496,9 +1505,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", - "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1510,9 +1519,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", - "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1524,9 +1533,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", - "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1538,9 +1547,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", - "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1552,9 +1561,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", - "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1566,9 +1575,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", - "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1580,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", - "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1594,9 +1603,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", - "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1608,9 +1617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", - "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1622,9 +1631,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", - "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1636,9 +1659,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", - "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1650,9 +1687,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", - "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1664,9 +1701,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", - "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1678,9 +1715,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", - "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1692,9 +1729,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", - "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1706,9 +1743,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", - "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1719,10 +1756,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", - "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1734,9 +1785,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", - "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1748,9 +1799,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", - "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1762,9 +1813,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", - "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1776,9 +1827,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", - "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1803,10 +1854,197 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", + "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", + "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", + "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", + "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", + "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", + "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", + "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", + "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", + "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", + "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@swc/wasm": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.7.tgz", - "integrity": "sha512-m1Cslgkp7gFIUB2ZiIUHMoUskwxOAi9uaf27inoKb7Oc8MkMjt+eNTeSyeGckkwRtMQiybKYTGGnA5imxSsedQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.11.tgz", + "integrity": "sha512-230rdYZf8ux3nIwISOQNCFrxzxpL/UFY4Khv/3UsvpEdo709j/+Tg80yXWW3DXETeZNPBV72QpvEBhXsl7Lc9g==", "dev": true, "license": "Apache-2.0" }, @@ -1891,9 +2129,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", - "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1993,9 +2231,9 @@ } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -2041,9 +2279,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2061,9 +2299,9 @@ } }, "node_modules/@videojs/http-streaming": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz", - "integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==", + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.4.tgz", + "integrity": "sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", @@ -2109,14 +2347,14 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", - "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", + "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.47", - "@swc/core": "^1.13.5" + "@rolldown/pluginutils": "1.0.0-rc.2", + "@swc/core": "^1.15.11" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2125,6 +2363,45 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/@vitejs/plugin-react-swc/node_modules/@swc/core": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", + "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.11", + "@swc/core-darwin-x64": "1.15.11", + "@swc/core-linux-arm-gnueabihf": "1.15.11", + "@swc/core-linux-arm64-gnu": "1.15.11", + "@swc/core-linux-arm64-musl": "1.15.11", + "@swc/core-linux-x64-gnu": "1.15.11", + "@swc/core-linux-x64-musl": "1.15.11", + "@swc/core-win32-arm64-msvc": "1.15.11", + "@swc/core-win32-ia32-msvc": "1.15.11", + "@swc/core-win32-x64-msvc": "1.15.11" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -2250,9 +2527,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2295,9 +2572,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2312,36 +2589,23 @@ } }, "node_modules/allotment": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.4.tgz", - "integrity": "sha512-LMM5Xe5nLePFOLAlW/5k3ARqznYGUyNekV4xJrfDKn1jimW3nlZE6hT/Tu0T8s0VgAkr9s2P7+uM0WvJKn5DAw==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.5.tgz", + "integrity": "sha512-7i4NT7ieXEyAd5lBrXmE7WHz/e7hRuo97+j+TwrPE85ha6kyFURoc76nom0dWSZ1pTKVEAMJy/+f3/Isfu/41A==", "license": "MIT", "dependencies": { "classnames": "^2.3.0", "eventemitter3": "^5.0.0", + "fast-deep-equal": "^3.1.3", "lodash.clamp": "^4.0.0", "lodash.debounce": "^4.0.0", - "lodash.isequal": "^4.5.0", - "use-resize-observer": "^9.0.0" + "usehooks-ts": "^3.1.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/allotment/node_modules/use-resize-observer": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", - "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==", - "license": "MIT", - "dependencies": { - "@juggle/resize-observer": "^3.3.1" - }, - "peerDependencies": { - "react": "16.8.0 - 18", - "react-dom": "16.8.0 - 18" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2420,11 +2684,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/bidi-js": { "version": "1.0.3", @@ -2437,14 +2704,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/cac": { @@ -2501,9 +2770,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -2545,13 +2814,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2633,15 +2895,16 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", - "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0" + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { "node": ">=20" @@ -2684,9 +2947,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -2775,19 +3038,29 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -2915,9 +3188,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2928,32 +3201,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escape-string-regexp": { @@ -2969,9 +3242,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { @@ -2981,7 +3254,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3100,9 +3373,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3156,9 +3429,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { @@ -3393,17 +3666,23 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/http-proxy-agent": { @@ -3434,19 +3713,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3586,18 +3852,19 @@ } }, "node_modules/jsdom": { - "version": "27.3.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", - "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", @@ -3607,7 +3874,6 @@ "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", @@ -3737,13 +4003,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3771,9 +4030,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3853,16 +4112,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mpd-parser": { @@ -4164,9 +4426,9 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -4207,13 +4469,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4234,6 +4489,12 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/property-expr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", @@ -4251,24 +4512,24 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-draggable": { @@ -4303,9 +4564,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.70.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", - "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4319,9 +4580,10 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/react-lifecycles-compat": { @@ -4404,9 +4666,9 @@ } }, "node_modules/react-router": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", - "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4426,12 +4688,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", - "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.12.0" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -4663,9 +4925,9 @@ } }, "node_modules/rollup": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", - "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4679,38 +4941,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.5", - "@rollup/rollup-android-arm64": "4.53.5", - "@rollup/rollup-darwin-arm64": "4.53.5", - "@rollup/rollup-darwin-x64": "4.53.5", - "@rollup/rollup-freebsd-arm64": "4.53.5", - "@rollup/rollup-freebsd-x64": "4.53.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", - "@rollup/rollup-linux-arm-musleabihf": "4.53.5", - "@rollup/rollup-linux-arm64-gnu": "4.53.5", - "@rollup/rollup-linux-arm64-musl": "4.53.5", - "@rollup/rollup-linux-loong64-gnu": "4.53.5", - "@rollup/rollup-linux-ppc64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-musl": "4.53.5", - "@rollup/rollup-linux-s390x-gnu": "4.53.5", - "@rollup/rollup-linux-x64-gnu": "4.53.5", - "@rollup/rollup-linux-x64-musl": "4.53.5", - "@rollup/rollup-openharmony-arm64": "4.53.5", - "@rollup/rollup-win32-arm64-msvc": "4.53.5", - "@rollup/rollup-win32-ia32-msvc": "4.53.5", - "@rollup/rollup-win32-x64-gnu": "4.53.5", - "@rollup/rollup-win32-x64-msvc": "4.53.5", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4884,9 +5142,9 @@ "license": "MIT" }, "node_modules/tabbable": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", - "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tiny-case": { @@ -4963,22 +5221,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, @@ -5143,6 +5401,21 @@ } } }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -5166,13 +5439,13 @@ } }, "node_modules/video.js": { - "version": "8.23.4", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", - "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", + "version": "8.23.7", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.7.tgz", + "integrity": "sha512-cG4HOygYt+Z8j6Sf5DuK6OgEOoM+g9oGP6vpqoZRaD13aHE4PMITbyjJUXZcIQbgB0wJEadBRaVm5lJIzo2jAA==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "^3.17.2", + "@babel/runtime": "^7.28.4", + "@videojs/http-streaming": "^3.17.3", "@videojs/vhs-utils": "^4.1.1", "@videojs/xhr": "2.7.0", "aes-decrypter": "^4.0.2", @@ -5217,9 +5490,9 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { @@ -5401,9 +5674,9 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5415,19 +5688,6 @@ "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef", "license": "MIT" }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -5496,9 +5756,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -5534,24 +5794,6 @@ "dev": true, "license": "MIT" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -5590,9 +5832,9 @@ } }, "node_modules/zustand": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", - "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 7b2d5927..16197c31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,6 +70,7 @@ "react-dom": "19.1.0" }, "overrides": { - "js-yaml": "^4.1.1" + "js-yaml": "^4.1.1", + "minimatch": "^10.2.1" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3869740e..7950e8ae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 = () => { } /> } /> } /> + } /> + } + /> } /> } /> } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 2d6cc0d9..1df2e295 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -19,6 +19,21 @@ import useAuthStore from './store/auth'; export const WebsocketContext = createContext([false, () => {}, null]); +// Debounce: coalesces rapid recording WS events into a single fetchRecordings() +// call (400 ms window) to prevent redundant re-renders in the TV Guide. +let _recordingFetchTimer = null; +function scheduleRecordingFetch() { + if (_recordingFetchTimer) clearTimeout(_recordingFetchTimer); + _recordingFetchTimer = setTimeout(async () => { + _recordingFetchTimer = null; + try { + await useChannelsStore.getState().fetchRecordings(); + } catch (e) { + console.warn('Failed to refresh recordings:', e); + } + }, 400); +} + export const WebsocketProvider = ({ children }) => { const [isReady, setIsReady] = useState(false); const [val, setVal] = useState(null); @@ -193,21 +208,21 @@ export const WebsocketProvider = ({ children }) => { loading: false, autoClose: 4000, }); - try { - await useChannelsStore.getState().fetchRecordings(); - } catch {} + scheduleRecordingFetch(); } else if (status === 'skipped') { + const reasonMap = { + no_commercials_detected: 'No commercials were detected in this recording', + no_commercials: 'No commercials were detected in this recording', + }; notifications.update({ id, title: 'No commercials to remove', - message: parsedEvent.data.reason || '', + message: reasonMap[parsedEvent.data.reason] || parsedEvent.data.reason || '', color: 'teal', loading: false, autoClose: 3000, }); - try { - await useChannelsStore.getState().fetchRecordings(); - } catch {} + scheduleRecordingFetch(); } else if (status === 'error') { notifications.update({ id, @@ -217,9 +232,7 @@ export const WebsocketProvider = ({ children }) => { loading: false, autoClose: 6000, }); - try { - await useChannelsStore.getState().fetchRecordings(); - } catch {} + scheduleRecordingFetch(); } break; } @@ -424,7 +437,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 +502,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:', @@ -508,19 +521,11 @@ export const WebsocketProvider = ({ children }) => { break; case 'recording_updated': - try { - await useChannelsStore.getState().fetchRecordings(); - } catch (e) { - console.warn('Failed to refresh recordings on update:', e); - } + scheduleRecordingFetch(); break; case 'recordings_refreshed': - try { - await useChannelsStore.getState().fetchRecordings(); - } catch (e) { - console.warn('Failed to refresh recordings on refreshed:', e); - } + scheduleRecordingFetch(); break; case 'recording_started': @@ -528,11 +533,7 @@ export const WebsocketProvider = ({ children }) => { title: 'Recording started!', message: `Started recording channel ${parsedEvent.data.channel}`, }); - try { - await useChannelsStore.getState().fetchRecordings(); - } catch (e) { - console.warn('Failed to refresh recordings on start:', e); - } + scheduleRecordingFetch(); break; case 'recording_ended': @@ -540,10 +541,36 @@ export const WebsocketProvider = ({ children }) => { title: 'Recording finished!', message: `Stopped recording channel ${parsedEvent.data.channel}`, }); - try { - await useChannelsStore.getState().fetchRecordings(); - } catch (e) { - console.warn('Failed to refresh recordings on end:', e); + scheduleRecordingFetch(); + break; + + case 'recording_stopped': + notifications.show({ + title: 'Recording stopped', + message: `Recording stopped early for ${parsedEvent.data.channel || 'channel'}. Partial content has been saved.`, + color: 'yellow', + }); + scheduleRecordingFetch(); + break; + + case 'recording_extended': + scheduleRecordingFetch(); + break; + + case 'recording_cancelled': + notifications.show({ + title: parsedEvent.data.was_in_progress ? 'Recording cancelled' : 'Recording deleted', + message: parsedEvent.data.was_in_progress + ? 'Recording cancelled and content removed.' + : 'Recording deleted.', + color: 'red', + }); + // Surgical removal by ID avoids a full fetchRecordings() re-render. + // Fall back to a full refresh if the ID is missing (e.g. older server). + if (parsedEvent.data.recording_id != null) { + useChannelsStore.getState().removeRecording(parsedEvent.data.recording_id); + } else { + scheduleRecordingFetch(); } break; @@ -704,7 +731,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 +794,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) { diff --git a/frontend/src/api.js b/frontend/src/api.js index 2b4c192d..9e4ac982 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); } @@ -1157,7 +1314,6 @@ export default class API { static async getEPGs() { try { const response = await request(`${host}/api/epg/sources/`); - return response; } catch (e) { errorNotification('Failed to retrieve EPGs', e); @@ -1174,11 +1330,11 @@ export default class API { } } - static async getCurrentPrograms(channelIds = null) { + static async getCurrentPrograms(channelUUIDs = null) { try { const response = await request(`${host}/api/epg/current-programs/`, { method: 'POST', - body: { channel_ids: channelIds }, + body: { channel_uuids: channelUUIDs }, }); return response; @@ -2506,6 +2662,58 @@ export default class API { } } + static async stopRecording(id) { + try { + await request(`${host}/api/channels/recordings/${id}/stop/`, { + method: 'POST', + }); + } catch (e) { + errorNotification(`Failed to stop recording ${id}`, e); + throw e; + } + } + + static async extendRecording(id, extraMinutes) { + try { + const resp = await request( + `${host}/api/channels/recordings/${id}/extend/`, + { + method: 'POST', + body: JSON.stringify({ extra_minutes: extraMinutes }), + headers: { 'Content-Type': 'application/json' }, + } + ); + return resp; + } catch (e) { + errorNotification(`Failed to extend recording ${id}`, e); + throw e; + } + } + + static async refreshArtwork(id) { + try { + await request(`${host}/api/channels/recordings/${id}/refresh-artwork/`, { + method: 'POST', + }); + } catch (e) { + errorNotification(`Failed to refresh artwork for recording ${id}`, e); + throw e; + } + } + + static async updateRecordingMetadata(id, { title, description }) { + try { + await request(`${host}/api/channels/recordings/${id}/update-metadata/`, { + method: 'POST', + body: JSON.stringify({ title, description }), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (e) { + errorNotification(`Failed to update recording metadata`, e); + throw e; + } + } + static async runComskip(recordingId) { try { const resp = await request( @@ -2668,8 +2876,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(); } @@ -2696,6 +2902,13 @@ export default class API { return await request(`${host}/api/accounts/users/me/`); } + static async updateMe(data) { + return await request(`${host}/api/accounts/users/me/`, { + method: 'PATCH', + body: data, + }); + } + static async getUsers() { try { const response = await request(`${host}/api/accounts/users/`); @@ -2720,6 +2933,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 +3052,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 +3332,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); + } + } } diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 94fb169c..86e7d7af 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -1,4 +1,4 @@ -import { Modal, Group, Text, Button, Checkbox, Box } from '@mantine/core'; +import { Modal, Group, Button, Checkbox, Box } from '@mantine/core'; import React, { useState } from 'react'; import useWarningsStore from '../store/warnings'; diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index 83fa4de7..b2de253d 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -4,12 +4,16 @@ class ErrorBoundary extends React.Component { state = { hasError: false }; static getDerivedStateFromError(error) { - return { hasError: true }; + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo); } render() { if (this.state.hasError) { - return
Something went wrong
; + return
Something went wrong: {this.state.error?.message}
; } return this.props.children; } diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 6ee94243..e8f71c68 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -12,6 +12,7 @@ export const Field = ({ field, value, onChange }) => { const description = field.help_text ?? field.description ?? field.value; const common = { label: field.label, description }; const effective = value ?? field.default; + switch (field.type) { case 'info': return ( diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 557767ed..d06273b6 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -4,36 +4,18 @@ import Draggable from 'react-draggable'; import useVideoStore from '../store/useVideoStore'; import mpegts from 'mpegts.js'; import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core'; +import { + applyConstraints, + calculateNewDimensions, + getClientCoordinates, + getLivePlayerErrorMessage, + getVODPlayerErrorMessage, +} from '../utils/components/FloatingVideoUtils.js'; -export default function FloatingVideo() { - const isVisible = useVideoStore((s) => s.isVisible); - const streamUrl = useVideoStore((s) => s.streamUrl); - const contentType = useVideoStore((s) => s.contentType); - const metadata = useVideoStore((s) => s.metadata); - const hideVideo = useVideoStore((s) => s.hideVideo); - const videoRef = useRef(null); - const playerRef = useRef(null); - const videoContainerRef = useRef(null); - const [isLoading, setIsLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const [showOverlay, setShowOverlay] = useState(true); - const [videoSize, setVideoSize] = useState({ width: 320, height: 180 }); - const [isResizing, setIsResizing] = useState(false); - const resizeStateRef = useRef(null); - const overlayTimeoutRef = useRef(null); - const aspectRatioRef = useRef(320 / 180); - const [dragPosition, setDragPosition] = useState(null); - const dragPositionRef = useRef(null); - const dragOffsetRef = useRef({ x: 0, y: 0 }); - const initialPositionRef = useRef(null); - - const MIN_WIDTH = 220; - const MIN_HEIGHT = 124; - const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging - const HEADER_HEIGHT = 38; // height of the close button header area - const ERROR_HEIGHT = 45; // approximate height of error message area when displayed +const ResizeHandles = ({ startResize }) => { const HANDLE_SIZE = 18; const HANDLE_OFFSET = 0; + const resizeHandleBaseStyle = { position: 'absolute', width: HANDLE_SIZE, @@ -43,6 +25,7 @@ export default function FloatingVideo() { zIndex: 8, touchAction: 'none', }; + const resizeHandles = [ { id: 'bottom-right', @@ -106,6 +89,56 @@ export default function FloatingVideo() { }, ]; + return ( + <> + {/* Resize handles */} + {resizeHandles.map((handle) => ( + startResize(event, handle)} + onTouchStart={(event) => startResize(event, handle)} + style={{ + ...resizeHandleBaseStyle, + ...handle.style, + cursor: handle.cursor, + }} + /> + ))} + + ); +} + +export default function FloatingVideo() { + const isVisible = useVideoStore((s) => s.isVisible); + const streamUrl = useVideoStore((s) => s.streamUrl); + const contentType = useVideoStore((s) => s.contentType); + const metadata = useVideoStore((s) => s.metadata); + const hideVideo = useVideoStore((s) => s.hideVideo); + + const videoRef = useRef(null); + const playerRef = useRef(null); + const videoContainerRef = useRef(null); + const resizeStateRef = useRef(null); + const overlayTimeoutRef = useRef(null); + const aspectRatioRef = useRef(320 / 180); + const dragPositionRef = useRef(null); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const initialPositionRef = useRef(null); + + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [showOverlay, setShowOverlay] = useState(true); + const [videoSize, setVideoSize] = useState({ width: 320, height: 180 }); + const [isResizing, setIsResizing] = useState(false); + const [dragPosition, setDragPosition] = useState(null); + + const MIN_WIDTH = 220; + const MIN_HEIGHT = 124; + const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging + const HEADER_HEIGHT = 38; // height of the close button header area + const ERROR_HEIGHT = 45; // approximate height of error message area when displayed + // Safely destroy the mpegts player to prevent errors const safeDestroyPlayer = () => { try { @@ -190,41 +223,20 @@ export default function FloatingVideo() { }; const handleError = (e) => { setIsLoading(false); - const error = e.target.error; - let errorMessage = 'Video playback error'; - if (error) { - switch (error.code) { - case error.MEDIA_ERR_ABORTED: - errorMessage = 'Video playback was aborted'; - break; - case error.MEDIA_ERR_NETWORK: - errorMessage = 'Network error while loading video'; - break; - case error.MEDIA_ERR_DECODE: - errorMessage = 'Video codec not supported by your browser'; - break; - case error.MEDIA_ERR_SRC_NOT_SUPPORTED: - errorMessage = 'Video format not supported by your browser'; - break; - default: - errorMessage = error.message || 'Unknown video error'; - } - } - - setLoadError(errorMessage); + setLoadError(getVODPlayerErrorMessage(e.target.error)); }; // Enhanced progress tracking for VOD const handleProgress = () => { - if (video.buffered.length > 0) { - const bufferedEnd = video.buffered.end(video.buffered.length - 1); - const duration = video.duration; - if (duration > 0) { - const bufferedPercent = (bufferedEnd / duration) * 100; - // You could emit this to a store for UI feedback - } - } + // if (video.buffered.length > 0) { + // const bufferedEnd = video.buffered.end(video.buffered.length - 1); + // const duration = video.duration; + // if (duration > 0) { + // const bufferedPercent = (bufferedEnd / duration) * 100; + // // You could emit this to a store for UI feedback + // } + // } }; // Add event listeners @@ -301,37 +313,7 @@ export default function FloatingVideo() { if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) { console.error('Player error:', errorType, errorDetail); - let errorMessage = `Error: ${errorType}`; - - if (errorType === 'MediaError') { - const errorString = errorDetail?.toLowerCase() || ''; - - if ( - errorString.includes('audio') || - errorString.includes('ac3') || - errorString.includes('ac-3') - ) { - errorMessage = - 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'; - } else if ( - errorString.includes('video') || - errorString.includes('h264') || - errorString.includes('h.264') - ) { - errorMessage = - 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'; - } else if (errorString.includes('mse')) { - errorMessage = - "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."; - } else { - errorMessage = - 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'; - } - } else if (errorDetail) { - errorMessage += ` - ${errorDetail}`; - } - - setLoadError(errorMessage); + setLoadError(getLivePlayerErrorMessage(errorType, errorDetail)); } }); @@ -448,15 +430,7 @@ export default function FloatingVideo() { (event) => { if (!resizeStateRef.current) return; - const clientX = - event.touches && event.touches.length - ? event.touches[0].clientX - : event.clientX; - const clientY = - event.touches && event.touches.length - ? event.touches[0].clientY - : event.clientY; - + const { clientX, clientY } = getClientCoordinates(event); const { startX, startY, @@ -466,104 +440,62 @@ export default function FloatingVideo() { handle, aspectRatio, } = resizeStateRef.current; - const deltaX = clientX - startX; - const deltaY = clientY - startY; - const widthDelta = deltaX * handle.xDir; - const heightDelta = deltaY * handle.yDir; + const ratio = aspectRatio || aspectRatioRef.current; + const { width: nextWidth, height: nextHeight } = calculateNewDimensions( + clientX - startX, + clientY - startY, + startWidth, + startHeight, + handle, + ratio + ); - // Derive width/height while keeping the original aspect ratio - let nextWidth = startWidth + widthDelta; - let nextHeight = nextWidth / ratio; - - // Allow vertical-driven resize if the user drags mostly vertically - if (Math.abs(deltaY) > Math.abs(deltaX)) { - nextHeight = startHeight + heightDelta; - nextWidth = nextHeight * ratio; - } - - // Respect minimums while keeping the ratio - if (nextWidth < MIN_WIDTH) { - nextWidth = MIN_WIDTH; - nextHeight = nextWidth / ratio; - } - - if (nextHeight < MIN_HEIGHT) { - nextHeight = MIN_HEIGHT; - nextWidth = nextHeight * ratio; - } - - // Keep within viewport with a margin based on current position - const posX = startPos?.x ?? 0; - const posY = startPos?.y ?? 0; - const margin = VISIBLE_MARGIN; - let maxWidth = null; - let maxHeight = null; - - if (!handle.isLeft) { - maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin); - } - - if (!handle.isTop) { - maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin); - } - - if (maxWidth != null && nextWidth > maxWidth) { - nextWidth = maxWidth; - nextHeight = nextWidth / ratio; - } - - if (maxHeight != null && nextHeight > maxHeight) { - nextHeight = maxHeight; - nextWidth = nextHeight * ratio; - } - - // Final pass to honor both bounds while keeping the ratio - if (maxWidth != null && nextWidth > maxWidth) { - nextWidth = maxWidth; - nextHeight = nextWidth / ratio; - } + const constrainedSize = applyConstraints( + nextWidth, + nextHeight, + ratio, + startPos, + handle, + MIN_WIDTH, + MIN_HEIGHT, + VISIBLE_MARGIN + ); setVideoSize({ - width: Math.round(nextWidth), - height: Math.round(nextHeight), + width: Math.round(constrainedSize.width), + height: Math.round(constrainedSize.height), }); - if (handle.isLeft || handle.isTop) { - let nextX = posX; - let nextY = posY; - - if (handle.isLeft) { - nextX = posX + (startWidth - nextWidth); - } - - if (handle.isTop) { - nextY = posY + (startHeight - nextHeight); - } - - const clamped = clampToVisibleWithSize( - nextX, - nextY, - nextWidth, - nextHeight - ); - - if (handle.isLeft) { - nextX = clamped.x; - } - - if (handle.isTop) { - nextY = clamped.y; - } - - const nextPos = { x: nextX, y: nextY }; - setDragPosition(nextPos); - dragPositionRef.current = nextPos; - } + updatePositionIfNeeded( + handle, + startPos, + startWidth, + startHeight, + constrainedSize + ); }, [MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize] ); + const updatePositionIfNeeded = (handle, startPos, startWidth, startHeight, newSize) => { + if (!handle.isLeft && !handle.isTop) return; + + const posX = startPos?.x ?? 0; + const posY = startPos?.y ?? 0; + let nextX = handle.isLeft ? posX + (startWidth - newSize.width) : posX; + let nextY = handle.isTop ? posY + (startHeight - newSize.height) : posY; + + const clamped = clampToVisibleWithSize(nextX, nextY, newSize.width, newSize.height); + const nextPos = { + x: handle.isLeft ? clamped.x : nextX, + y: handle.isTop ? clamped.y : nextY, + }; + + setDragPosition(nextPos); + dragPositionRef.current = nextPos; + }; + const endResize = useCallback(() => { setIsResizing(false); resizeStateRef.current = null; @@ -577,14 +509,7 @@ export default function FloatingVideo() { event.stopPropagation(); event.preventDefault(); - const clientX = - event.touches && event.touches.length - ? event.touches[0].clientX - : event.clientX; - const clientY = - event.touches && event.touches.length - ? event.touches[0].clientY - : event.clientY; + const { clientX, clientY } = getClientCoordinates(event); const aspectRatio = videoSize.height > 0 @@ -718,13 +643,35 @@ export default function FloatingVideo() { boxShadow: '0 2px 10px rgba(0,0,0,0.7)', }} > - {/* Simple header row with a close button */} + {/* Header row with optional title and close button */} + {metadata?.name ? ( + + {metadata.name} + + ) : ( + + )} @@ -853,20 +801,7 @@ export default function FloatingVideo() { )} - {/* Resize handles */} - {resizeHandles.map((handle) => ( - startResize(event, handle)} - onTouchStart={(event) => startResize(event, handle)} - style={{ - ...resizeHandleBaseStyle, - ...handle.style, - cursor: handle.cursor, - }} - /> - ))} + ); diff --git a/frontend/src/components/GuideRow.jsx b/frontend/src/components/GuideRow.jsx index 28f41563..b1d47c56 100644 --- a/frontend/src/components/GuideRow.jsx +++ b/frontend/src/components/GuideRow.jsx @@ -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) => ( - - No program data - - ) + {Array.from({ length: totalPlaceholders }).map( + (_, placeholderIndex) => { + const left = placeholderIndex * blockWidth; + if (left + blockWidth < vpLeft || left > vpRight) return null; + return ( + + No program data + + ); + } )} ); @@ -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 && ( { h={'100%'} pl={0} > - {channelPrograms.length > 0 ? ( - channelPrograms.map((program) => + {visiblePrograms.length > 0 ? ( + visiblePrograms.map((program) => renderProgram(program, undefined, channel) ) - ) : ( + ) : channelPrograms.length > 0 ? null : ( )} diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index b9819b4e..bd6319bf 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -1,7 +1,6 @@ // frontend/src/components/FloatingVideo.js import React, { useEffect, useState } from 'react'; import usePlaylistsStore from '../store/playlists'; -import { notifications } from '@mantine/notifications'; import useStreamsStore from '../store/streams'; import useChannelsStore from '../store/channels'; import useEPGsStore from '../store/epgs'; @@ -10,21 +9,54 @@ import { Stack, Button, Group } from '@mantine/core'; import API from '../api'; import { useNavigate } from 'react-router-dom'; import { CircleCheck } from 'lucide-react'; +import { showNotification } from '../utils/notificationUtils.js'; + +const M3uSetupSuccess = ({ data }) => { + const navigate = useNavigate(); + + const onClickRefresh = () => { + API.refreshPlaylist(data.account); + }; + + const onClickConfigure = () => { + // Store the ID we want to edit in the store first + usePlaylistsStore.getState().setEditPlaylistId(data.account); + + // Then navigate to the content sources page + // Using the exact path that matches your app's routing structure + navigate('/sources'); + }; + + return ( + + {data.message || + 'M3U groups loaded. Configure group filters and auto channel sync settings.'} + + + + + + ); +}; export default function M3URefreshNotification() { const playlists = usePlaylistsStore((s) => s.playlists); 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); const [notificationStatus, setNotificationStatus] = useState({}); - const navigate = useNavigate(); const handleM3UUpdate = (data) => { + // Skip if status hasn't changed if ( JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data) ) { @@ -36,132 +68,101 @@ export default function M3URefreshNotification() { return; } - // Store the updated status first - setNotificationStatus({ - ...notificationStatus, + // Update notification status + setNotificationStatus((prev) => ({ + ...prev, [data.account]: data, - }); + })); - // Special handling for pending setup status + // Handle different status types if (data.status === 'pending_setup') { - fetchChannelGroups(); - fetchPlaylists(); - - notifications.show({ - title: `M3U Setup: ${playlist.name}`, - message: ( - - {data.message || - 'M3U groups loaded. Configure group filters and auto channel sync settings.'} - - - - - - ), - color: 'orange.5', - autoClose: 5000, // Keep visible a bit longer - }); + handlePendingSetup(playlist, data); return; } - // Check for error status FIRST before doing anything else if (data.status === 'error') { - // Only show the error notification if we have a complete task (progress=100) - // or if it's explicitly flagged as an error - if (data.progress === 100) { - notifications.show({ - title: `M3U Processing: ${playlist.name}`, - message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`, - color: 'red', - autoClose: 5000, // Keep error visible a bit longer - }); - } - return; // Exit early for any error status + handleError(playlist, data); + return; } - // Check if we already have an error stored for this account, and if so, don't show further notifications + // Skip if already errored const currentStatus = notificationStatus[data.account]; if (currentStatus && currentStatus.status === 'error') { - // Don't show any other notifications once we've hit an error return; } - const taskProgress = data.progress; + // Handle normal progress updates (0% start, 100% completion) + if (data.progress === 0 || data.progress === 100) { + handleProgressNotification(playlist, data); + } + }; - // Only show start and completion notifications for normal operation - if (data.progress != 0 && data.progress != 100) { - return; + const handlePendingSetup = (playlist, data) => { + fetchChannelGroups(); + fetchPlaylists(); + + showNotification({ + title: `M3U Setup: ${playlist.name}`, + message: , + color: 'orange.5', + autoClose: 5000, + }); + }; + + const handleError = (playlist, data) => { + if (data.progress === 100) { + showNotification({ + title: `M3U Processing: ${playlist.name}`, + message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`, + color: 'red', + autoClose: 5000, + }); + } + }; + + const getActionMessage = (action) => { + const messages = { + downloading: 'Downloading', + parsing: 'Stream parsing', + processing_groups: 'Group parsing', + vod_refresh: 'VOD content refresh', + }; + return messages[action] || 'Processing'; + }; + + const triggerPostCompletionFetches = (action) => { + if (action == 'parsing') { + fetchStreams(); + API.requeryChannels(); + fetchChannelIds(); + } else if (action == 'processing_groups') { + fetchStreams(); + fetchChannelGroups(); + fetchEPGData(); + fetchPlaylists(); + } else if (action == 'vod_refresh') { + fetchPlaylists(); + fetchCategories(); + } + }; + + const handleProgressNotification = (playlist, data) => { + const baseMessage = getActionMessage(data.action); + const message = + data.progress == 0 + ? `${baseMessage} starting...` + : `${baseMessage} complete!`; + + if (data.progress == 100) { + triggerPostCompletionFetches(data.action); } - let message = ''; - switch (data.action) { - case 'downloading': - message = 'Downloading'; - break; - - case 'parsing': - message = 'Stream parsing'; - break; - - case 'processing_groups': - message = 'Group parsing'; - break; - - case 'vod_refresh': - message = 'VOD content refresh'; - break; - } - - if (taskProgress == 0) { - message = `${message} starting...`; - } else if (taskProgress == 100) { - message = `${message} complete!`; - - // Only trigger additional fetches on successful completion - if (data.action == 'parsing') { - fetchStreams(); - API.requeryChannels(); - fetchChannels(); - } else if (data.action == 'processing_groups') { - fetchStreams(); - fetchChannelGroups(); - fetchEPGData(); - fetchPlaylists(); - } else if (data.action == 'vod_refresh') { - // VOD refresh completed, trigger VOD categories refresh - fetchPlaylists(); // Refresh playlist data to show updated VOD info - fetchCategories(); // Refresh VOD categories to make them visible - } - } - - notifications.show({ + showNotification({ title: `M3U Processing: ${playlist.name}`, message, - loading: taskProgress == 0, + loading: data.progress == 0, autoClose: 2000, - icon: taskProgress == 100 ? : null, + icon: data.progress == 100 ? : null, }); }; diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx index f7da56ad..252e7da0 100644 --- a/frontend/src/components/NotificationCenter.jsx +++ b/frontend/src/components/NotificationCenter.jsx @@ -10,7 +10,9 @@ import { Group, Indicator, Popover, - ScrollArea, + PopoverDropdown, + PopoverTarget, + ScrollAreaAutosize, Stack, Text, ThemeIcon, @@ -33,7 +35,11 @@ import { ArrowRight, } from 'lucide-react'; import useNotificationsStore from '../store/notifications'; -import API from '../api'; +import { + dismissAllNotifications, + dismissNotification, + getNotifications, +} from '../utils/components/NotificationCenterUtils.js'; // Get icon for notification type const getNotificationIcon = (type) => { @@ -249,7 +255,7 @@ const NotificationCenter = ({ onSettingAction }) => { // Fetch notifications on mount and periodically const fetchNotifications = useCallback(async () => { try { - await API.getNotifications(showDismissed); + await getNotifications(showDismissed); } catch (error) { console.error('Failed to fetch notifications:', error); } @@ -265,7 +271,7 @@ const NotificationCenter = ({ onSettingAction }) => { const handleDismiss = async (notificationId, actionTaken = null) => { try { - await API.dismissNotification(notificationId, actionTaken); + await dismissNotification(notificationId, actionTaken); } catch (error) { console.error('Failed to dismiss notification:', error); } @@ -273,7 +279,7 @@ const NotificationCenter = ({ onSettingAction }) => { const handleDismissAll = async () => { try { - await API.dismissAllNotifications(); + await dismissAllNotifications(); } catch (error) { console.error('Failed to dismiss all notifications:', error); } @@ -302,7 +308,7 @@ const NotificationCenter = ({ onSettingAction }) => { shadow="lg" withArrow > - + { - + - + {/* Header */} @@ -367,7 +373,7 @@ const NotificationCenter = ({ onSettingAction }) => { {/* Notification list */} - + {displayedNotifications.length === 0 ? ( { ))} )} - + {/* Footer with info text */} {!showDismissed && @@ -421,7 +427,7 @@ const NotificationCenter = ({ onSettingAction }) => { )} - + ); }; diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx index c8e551af..d22e6630 100644 --- a/frontend/src/components/SeriesModal.jsx +++ b/frontend/src/components/SeriesModal.jsx @@ -16,113 +16,330 @@ import { Tabs, Table, Divider, + TableTbody, + TableTd, + TableTh, + TableThead, + TableTr, + TabsList, + TabsPanel, + TabsTab, } from '@mantine/core'; import { Play, Copy } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; import { copyToClipboard } from '../utils'; import useVODStore from '../store/useVODStore'; import useVideoStore from '../store/useVideoStore'; import useSettingsStore from '../store/settings'; +import { + formatDuration, + formatStreamLabel, + getEpisodeAirdate, + getEpisodeStreamUrl, + getTmdbUrlLink, + getYouTubeEmbedUrl, + groupEpisodesBySeason, + imdbUrl, + sortBySeasonNumber, + sortEpisodesList, + tmdbUrl, +} from '../utils/components/SeriesModalUtils.js'; +import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx'; -const imdbUrl = (imdb_id) => - imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; -const tmdbUrl = (tmdb_id, type = 'movie') => - tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; -const formatDuration = (seconds) => { - if (!seconds) return ''; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; +const Series = ({ displaySeries, onClickYouTubeTrailer }) => { + return ( + + {displaySeries.series_image || displaySeries.logo?.url ? ( + + {displaySeries.name} + + ) : ( + + + + )} + + + {displaySeries.name} + + {/* Original name if different */} + {displaySeries.o_name && + displaySeries.o_name !== displaySeries.name && ( + + Original: {displaySeries.o_name} + + )} + + + {displaySeries.year && ( + {displaySeries.year} + )} + {displaySeries.rating && ( + {displaySeries.rating} + )} + {displaySeries.age && ( + {displaySeries.age} + )} + Series + {displaySeries.episode_count && ( + {displaySeries.episode_count} episodes + )} + {/* imdb_id and tmdb_id badges */} + {displaySeries.imdb_id && ( + + IMDb + + )} + {displaySeries.tmdb_id && ( + + TMDb + + )} + + + {/* Release date */} + {displaySeries.release_date && ( + + Release Date: {displaySeries.release_date} + + )} + + {displaySeries.genre && ( + + Genre: {displaySeries.genre} + + )} + + {displaySeries.director && ( + + Director: {displaySeries.director} + + )} + + {displaySeries.cast && ( + + Cast: {displaySeries.cast} + + )} + + {displaySeries.country && ( + + Country: {displaySeries.country} + + )} + + {/* Description */} + {displaySeries.description && ( + + + Description + + {displaySeries.description} + + )} + + {/* Watch Trailer button if available */} + {displaySeries.youtube_trailer && ( + + )} + + + ); }; -const formatStreamLabel = (relation) => { - // Create a label for the stream that includes provider name and stream-specific info - const provider = relation.m3u_account.name; - const streamId = relation.stream_id; +const Episode = ({ episode, displaySeries }) => { + return ( + + {/* Episode Image and Description Row */} + + {/* Episode Image */} + {episode.movie_image && ( + + {episode.name} + + )} - // Try to extract quality info - prioritizing the new quality_info field from backend - let qualityInfo = ''; + {/* Episode Description */} + + {episode.description && ( + + + Description + + + {episode.description} + + + )} + + - // 1. Check the new quality_info field from backend (PRIMARY) - if (relation.quality_info) { - if (relation.quality_info.quality) { - qualityInfo = ` - ${relation.quality_info.quality}`; - } else if (relation.quality_info.resolution) { - qualityInfo = ` - ${relation.quality_info.resolution}`; - } else if (relation.quality_info.bitrate) { - qualityInfo = ` - ${relation.quality_info.bitrate}`; - } - } + {/* Additional Episode Details */} + + {episode.rating && ( + + + Rating + + + {episode.rating} + + + )} + {/* IMDb and TMDb badges for episode */} + {(episode.imdb_id || displaySeries.tmdb_id) && ( + + + Links + + {episode.imdb_id && ( + + IMDb + + )} + {displaySeries.tmdb_id && ( + + TMDb + + )} + + )} - // 2. Fallback: Check custom_properties detailed info structure - if (qualityInfo === '' && relation.custom_properties) { - const props = relation.custom_properties; + {episode.director && ( + + + Director + + {episode.director} + + )} - // Check detailed_info structure (where the real data is!) - if (qualityInfo === '' && props.detailed_info) { - const detailedInfo = props.detailed_info; + {episode.actors && ( + + + Cast + + + {episode.actors} + + + )} + - // Extract from video resolution - if ( - detailedInfo.video && - detailedInfo.video.width && - detailedInfo.video.height - ) { - const width = detailedInfo.video.width; - const height = detailedInfo.video.height; + {/* Technical Details */} + {(episode.bitrate || episode.video || episode.audio) && ( + + + Technical Details + + + {episode.bitrate && episode.bitrate > 0 && ( + + Bitrate: {episode.bitrate} kbps + + )} + {episode.video && Object.keys(episode.video).length > 0 && ( + + Video:{' '} + {episode.video.codec_long_name || episode.video.codec_name} + {episode.video.width && episode.video.height + ? `, ${episode.video.width}x${episode.video.height}` + : ''} + + )} + {episode.audio && Object.keys(episode.audio).length > 0 && ( + + Audio:{' '} + {episode.audio.codec_long_name || episode.audio.codec_name} + {episode.audio.channels + ? `, ${episode.audio.channels} channels` + : ''} + + )} + + + )} - // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) - if (width >= 3840) { - qualityInfo = ' - 4K'; - } else if (width >= 1920) { - qualityInfo = ' - 1080p'; - } else if (width >= 1280) { - qualityInfo = ' - 720p'; - } else if (width >= 854) { - qualityInfo = ' - 480p'; - } else { - qualityInfo = ` - ${width}x${height}`; - } - } - - // Extract from movie name in detailed_info - if (qualityInfo === '' && detailedInfo.name) { - const name = detailedInfo.name; - if (name.includes('4K') || name.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { - qualityInfo = ' - 480p'; - } - } - } - } - - // 3. Final fallback: Check stream name for quality markers - if (qualityInfo === '' && relation.stream_name) { - const streamName = relation.stream_name; - if (streamName.includes('4K') || streamName.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (streamName.includes('1080p') || streamName.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (streamName.includes('720p') || streamName.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (streamName.includes('480p')) { - qualityInfo = ' - 480p'; - } - } - - return `${provider}${qualityInfo}${streamId ? ` (Stream ${streamId})` : ''}`; + {/* Provider Information */} + {episode.m3u_account && ( + + + Provider: + + + {episode.m3u_account.name || episode.m3u_account} + + + )} + + ); }; const SeriesModal = ({ series, opened, onClose }) => { const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore(); const showVideo = useVideoStore((s) => s.showVideo); const env_mode = useSettingsStore((s) => s.environment.env_mode); + const [detailedSeries, setDetailedSeries] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); const [activeTab, setActiveTab] = useState(null); @@ -192,12 +409,7 @@ const SeriesModal = ({ series, opened, onClose }) => { // Try to get episodes from the fetched data if (detailedSeries.episodesList) { - return detailedSeries.episodesList.sort((a, b) => { - if (a.season_number !== b.season_number) { - return (a.season_number || 0) - (b.season_number || 0); - } - return (a.episode_number || 0) - (b.episode_number || 0); - }); + return sortEpisodesList(detailedSeries.episodesList); } // If no episodes in detailed series, return empty array @@ -206,22 +418,12 @@ const SeriesModal = ({ series, opened, onClose }) => { // Group episodes by season const episodesBySeason = React.useMemo(() => { - const grouped = {}; - seriesEpisodes.forEach((episode) => { - const season = episode.season_number || 1; - if (!grouped[season]) { - grouped[season] = []; - } - grouped[season].push(episode); - }); - return grouped; + return groupEpisodesBySeason(seriesEpisodes); }, [seriesEpisodes]); // Get available seasons sorted const seasons = React.useMemo(() => { - return Object.keys(episodesBySeason) - .map(Number) - .sort((a, b) => a - b); + return sortBySeasonNumber(episodesBySeason); }, [episodesBySeason]); // Update active tab when seasons change or modal opens @@ -244,49 +446,12 @@ const SeriesModal = ({ series, opened, onClose }) => { }, [opened]); const handlePlayEpisode = (episode) => { - let streamUrl = `/proxy/vod/episode/${episode.uuid}`; - - // Add selected provider as query parameter if available - if (selectedProvider) { - // Use stream_id for most specific selection, fallback to account_id - if (selectedProvider.stream_id) { - streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; - } else { - streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; - } - } - - if (env_mode === 'dev') { - streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; - } else { - streamUrl = `${window.location.origin}${streamUrl}`; - } + const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode); showVideo(streamUrl, 'vod', episode); }; - const getEpisodeStreamUrl = (episode) => { - let streamUrl = `/proxy/vod/episode/${episode.uuid}`; - - // Add selected provider as query parameter if available - if (selectedProvider) { - // Use stream_id for most specific selection, fallback to account_id - if (selectedProvider.stream_id) { - streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; - } else { - streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; - } - } - - if (env_mode === 'dev') { - streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; - } else { - streamUrl = `${window.location.origin}${streamUrl}`; - } - return streamUrl; - }; - const handleCopyEpisodeLink = async (episode) => { - const streamUrl = getEpisodeStreamUrl(episode); + const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode); await copyToClipboard(streamUrl, { successTitle: 'Link Copied!', successMessage: 'Episode link copied to clipboard', @@ -297,13 +462,14 @@ const SeriesModal = ({ series, opened, onClose }) => { setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id); }; - // Helper to get embeddable YouTube URL - const getEmbedUrl = (url) => { - if (!url) return ''; - // Accepts full YouTube URLs or just IDs - const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/); - const videoId = match ? match[1] : url; - return `https://www.youtube.com/embed/${videoId}`; + const onClickYouTubeTrailer = () => { + setTrailerUrl(getYouTubeEmbedUrl(displaySeries.youtube_trailer)); + setTrailerModalOpened(true); + }; + + const onChangeSelectedProvider = (value) => { + const provider = providers.find((p) => p.id.toString() === value); + setSelectedProvider(provider); }; if (!series) return null; @@ -371,153 +537,10 @@ const SeriesModal = ({ series, opened, onClose }) => { )} {/* Series poster and basic info */} - - {displaySeries.series_image || displaySeries.logo?.url ? ( - - {displaySeries.name} - - ) : ( - - - - )} - - - {displaySeries.name} - - {/* Original name if different */} - {displaySeries.o_name && - displaySeries.o_name !== displaySeries.name && ( - - Original: {displaySeries.o_name} - - )} - - - {displaySeries.year && ( - {displaySeries.year} - )} - {displaySeries.rating && ( - {displaySeries.rating} - )} - {displaySeries.age && ( - {displaySeries.age} - )} - Series - {displaySeries.episode_count && ( - - {displaySeries.episode_count} episodes - - )} - {/* imdb_id and tmdb_id badges */} - {displaySeries.imdb_id && ( - - IMDb - - )} - {displaySeries.tmdb_id && ( - - TMDb - - )} - - - {/* Release date */} - {displaySeries.release_date && ( - - Release Date:{' '} - {displaySeries.release_date} - - )} - - {displaySeries.genre && ( - - Genre: {displaySeries.genre} - - )} - - {displaySeries.director && ( - - Director: {displaySeries.director} - - )} - - {displaySeries.cast && ( - - Cast: {displaySeries.cast} - - )} - - {displaySeries.country && ( - - Country: {displaySeries.country} - - )} - - {/* Description */} - {displaySeries.description && ( - - - Description - - {displaySeries.description} - - )} - - {/* Watch Trailer button if available */} - {displaySeries.youtube_trailer && ( - - )} - - + {/* Provider Information */} @@ -553,12 +576,7 @@ const SeriesModal = ({ series, opened, onClose }) => { label: formatStreamLabel(provider), }))} value={selectedProvider?.id?.toString() || ''} - onChange={(value) => { - const provider = providers.find( - (p) => p.id.toString() === value - ); - setSelectedProvider(provider); - }} + onChange={(value) => onChangeSelectedProvider(value)} placeholder="Select stream..." style={{ maxWidth: 350 }} disabled={loadingProviders} @@ -579,43 +597,39 @@ const SeriesModal = ({ series, opened, onClose }) => { ) : seasons.length > 0 ? ( - + {seasons.map((season) => ( - + Season {season} - + ))} - + {seasons.map((season) => ( - + - - - Ep - Title - - Duration - - Date - - Action - - - - + + + Ep + Title + Duration + Date + Action + + + {episodesBySeason[season]?.map((episode) => ( - handleEpisodeRowClick(episode)} > - + {episode.episode_number || '?'} - - + + {episode.name} @@ -626,22 +640,18 @@ const SeriesModal = ({ series, opened, onClose }) => { )} - - + + {formatDuration(episode.duration_secs)} - - + + - {episode.air_date - ? new Date( - episode.air_date - ).toLocaleDateString() - : 'N/A'} + {getEpisodeAirdate(episode)} - - + + { - - + + {expandedEpisode === episode.id && ( - - + - - {/* Episode Image and Description Row */} - - {/* Episode Image */} - {episode.movie_image && ( - - - - )} - - {/* Episode Description */} - - {episode.description && ( - - - Description - - - {episode.description} - - - )} - - - - {/* Additional Episode Details */} - - {episode.rating && ( - - - Rating - - - {episode.rating} - - - )} - {/* IMDb and TMDb badges for episode */} - {(episode.imdb_id || - displaySeries.tmdb_id) && ( - - - Links - - {episode.imdb_id && ( - - IMDb - - )} - {displaySeries.tmdb_id && ( - - TMDb - - )} - - )} - - {episode.director && ( - - - Director - - - {episode.director} - - - )} - - {episode.actors && ( - - - Cast - - - {episode.actors} - - - )} - - - {/* Technical Details */} - {(episode.bitrate || - episode.video || - episode.audio) && ( - - - Technical Details - - - {episode.bitrate && - episode.bitrate > 0 && ( - - Bitrate:{' '} - {episode.bitrate} kbps - - )} - {episode.video && - Object.keys(episode.video) - .length > 0 && ( - - Video:{' '} - {episode.video - .codec_long_name || - episode.video.codec_name} - {episode.video.width && - episode.video.height - ? `, ${episode.video.width}x${episode.video.height}` - : ''} - - )} - {episode.audio && - Object.keys(episode.audio) - .length > 0 && ( - - Audio:{' '} - {episode.audio - .codec_long_name || - episode.audio.codec_name} - {episode.audio.channels - ? `, ${episode.audio.channels} channels` - : ''} - - )} - - - )} - - {/* Provider Information */} - {episode.m3u_account && ( - - - Provider: - - - {episode.m3u_account.name || - episode.m3u_account} - - - )} - - - + + + )} ))} - +
-
+ ))}
) : ( @@ -909,36 +716,11 @@ const SeriesModal = ({ series, opened, onClose }) => { {/* YouTube Trailer Modal */} - setTrailerModalOpened(false)} - title="Trailer" - size="xl" - centered - > - - {trailerUrl && ( -