Merge pull request #1221 from Dispatcharr/dev

Dispatcharr - v0.24.0
This commit is contained in:
SergeantPanda 2026-05-03 09:29:57 -05:00 committed by GitHub
commit 90fa73982b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 4587 additions and 2276 deletions

View file

@ -7,6 +7,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- **HDHomeRun discovery endpoints now respect the `M3U_EPG` network access policy**. `DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, and `HDHRDeviceXMLAPIView` were marked `AllowAny` so HDHR clients (Plex, Emby, Jellyfin, Channels DVR, etc.) can discover the tuner without authenticating, but they were not gated by any network allowlist. The lineup enumerates every channel name and per-channel UUID stream URL, so any client that could reach the server could full-enumerate the lineup. All four views now call `network_access_allowed(request, "M3U_EPG")` and return `403 Forbidden` for clients outside the allowlist, matching the gating already applied to the M3U and EPG endpoints (and matching what the Network Access settings UI already advertised: "Limit access to M3U, EPG, and HDHR URLs"). Operators with a restrictive `M3U_EPG` policy will see HDHR discovery start being blocked for off-LAN clients on upgrade; loosen the policy if remote HDHR access is required.
- **Removed `dangerouslySetInnerHTML` from the M3U Profile regex preview**. The "Matched Text" preview in the M3U Profile editor built an HTML string by interpolating the user's sample input into a `<mark>` wrapper and rendering it via `dangerouslySetInnerHTML`. A crafted sample input (admin-only, so self-XSS only) could inject arbitrary HTML into the preview pane. The preview now returns an array of plain strings and `<mark>` React elements, so user input is always treated as text by React.
- **Authorization on DVR recording playback endpoints**. `RecordingViewSet.file` and `RecordingViewSet.hls` now require an authenticated session and enforce a per-user channel-access check before serving any bytes. Admins (`user_level >= 10`) are always allowed; standard users are allowed only when the recording's source channel is visible under their channel-profile assignments and within their `user_level`, mirroring the same logic used by `stream_xc` for live channels. Unauthenticated requests now receive `403 Forbidden` instead of being served. The pre-existing `network_access_allowed(request, "STREAMS")` perimeter check is retained as a separate, prior gate so external IPs can be blocked from streaming entirely even with a valid token.
- Updated `lxml` 6.0.3 → 6.1.0, resolving the following CVE:
- **CVE-2026-41066**: External entity injection (XXE) in `iterparse()` and `ETCompatXMLParser`.
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high):
- Updated `@xmldom/xmldom` 0.8.12 → 0.8.13, resolving **high** uncontrolled recursion in XML serialization causing DoS ([GHSA-2v35-w6hq-6mfw](https://github.com/advisories/GHSA-2v35-w6hq-6mfw)), **high** XML injection via unvalidated `DocumentType` serialization ([GHSA-f6ww-3ggp-fr8h](https://github.com/advisories/GHSA-f6ww-3ggp-fr8h)), **high** XML node injection via unvalidated processing instruction serialization ([GHSA-x6wf-f3px-wcqx](https://github.com/advisories/GHSA-x6wf-f3px-wcqx)), and **high** XML node injection via unvalidated comment serialization ([GHSA-j759-j44w-7fr8](https://github.com/advisories/GHSA-j759-j44w-7fr8))
- Updated `postcss` 8.5.6 → 8.5.13, resolving **moderate** XSS via unescaped `</style>` in CSS stringify output ([GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93))
### Fixed
- **EPG program times shifted by the host system's UTC offset when `/etc/localtime` is bind-mounted into the container**. Mounting `/etc/localtime` from a non-UTC host causes PostgreSQL to silently resolve the `'UTC'` timezone name to the host's local timezone (e.g. CDT, CET) rather than actual UTC - even though `SHOW timezone` returns `UTC` and the zoneinfo file exists. This made PostgreSQL format all stored `timestamptz` values with the host's UTC offset, and psycopg2 returned datetimes shifted by that offset, causing every EPG program time to be read back N hours wrong and written to the XML output incorrectly. The fix registers a Django `connection_created` signal that issues `SET TIME ZONE 'UTC0'` on every new database connection. `UTC0` is a POSIX timezone string that bypasses the broken zoneinfo name-lookup path entirely; it resolves unconditionally to UTC+00 regardless of the host timezone or what files are mounted. (Fixes #651)
- **Xtream Codes `player_api.php` missing `active_cons` and reporting wrong `max_connections`**. The `user_info` block returned by the XC API did not include the `active_cons` field, which Enigma2 clients (XStreamity, XKlass) read unconditionally and crash with `KeyError: 'active_cons'` when it is absent. `max_connections` was also hardcoded to the system-wide tuner count for every user, ignoring per-user `stream_limit` configuration. `xc_get_info` now reports `max_connections` as the user's `stream_limit` when set, falling back to the system tuner count for unlimited users; `active_cons` is the user's own active connection count when they have a per-user limit, or the system-wide active connection count when they do not (so unlimited clients can still see how much of the global tuner pool is in use). The existing `get_user_active_connections` helper was generalized to accept `user_id=None` for the system-wide query rather than duplicating its Redis scan logic. (Fixes #990)
- **Plugin discovery re-running on every connect event**. The `PluginManager` cache hit check used `if self._registry and ...`, which always evaluated false when zero plugins were installed because an empty dict is falsy. Every Connect event (`recording_start`, `client_connect`, `channel_start`, etc.) was therefore triggering a full filesystem walk of `/data/plugins` and emitting `Discovering plugins (no DB sync) in /data/plugins` / `Discovered 0 plugin(s)` log lines. Tracked separately via a new `_discovery_completed` flag so the cache short-circuits subsequent calls regardless of registry contents, dropping discovery to once per worker process lifetime.
- **Empty show/season folders left behind after deleting a recording**. Deleting a recording removes the MKV (or HLS working directory if still in progress) but previously left the parent show / season directories on disk even when they no longer contained any other recordings. After file cleanup, `RecordingViewSet.destroy` now walks up from the deleted file's parent directory and removes any now-empty directories, stopping at the `/data/recordings` library root so the root itself is never touched.
- **Premature DVR concat on graceful Celery worker shutdown**. A `worker_shutting_down` signal handler in `apps/channels/tasks.py` now sets a module-level `_DVR_SHUTTING_DOWN` flag. The `run_recording` loop checks this flag after FFmpeg exits and, if the recording's `end_time` has not yet passed, skips concatenation and persists `status="interrupted"` with `interrupted_reason="server_shutdown"`. The HLS working directory is preserved across the restart so the recovery path on the next worker startup can resume segment numbering from where it left off rather than truncating the show.
- **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name.
- **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545)
- **EPG channel name truncation**: EPG sources that include long `<display-name>` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134)
- **Channel start/client connect notifications suppressed on first stats poll after page load**: after logging in or navigating to the Stats page, the notification logic was not firing for any connections present in the first stats response, even for streams that had started after the page loaded. Replaced the flag-based approach with a `pageLoadTime` module-level constant compared against each client's `connected_at` Unix timestamp from Redis; connections that pre-date the page load are filtered out, while genuinely new ones fire immediately. `get_basic_channel_info` now also includes `connected_at` in each client entry so this check works for the Stats page's API poll path as well as the WebSocket path.
### Added
- **In-progress recording playback from the DVR page**. The Watch button on a recording card is now enabled while the recording is still in progress, and the in-app floating player can play the live HLS playlist with full timeshift / scrub-back to the start of the recording.
- The frontend now bundles `hls.js` and routes any `.m3u8` URL through it (Chrome, Edge, Firefox, and other Chromium-based browsers). Native HLS via `<video src=>` is reserved for Safari, where it Just Works for the same playlist.
- hls.js requests are authenticated via `xhrSetup`, attaching the same `Authorization: Bearer <accessToken>` header the live mpegts.js player already uses, so the new per-user authorization check on the recording endpoints (see Security) is satisfied for every playlist refresh and segment fetch.
- **Watch DVR recordings live while they are still recording**: `run_recording` has been refactored from a single TS file capture to an FFmpeg HLS segmentation pipeline. While recording is in progress, the `file_url` exposed on the recording points to a new `/api/channels/recordings/{id}/hls/index.m3u8` endpoint instead of the eventual MKV path, so any HLS-capable client (the built-in web player, VLC, infuse, channels DVR clients, etc.) can join the stream at any time and watch from the live edge. When the recording ends, the segments are concatenated into the final MKV, `file_url` is updated to point to the existing `/file/` endpoint, and the HLS working directory is cleaned up. Hitting `/file/` while a recording is still in progress now redirects to the HLS playlist rather than 404, and hitting `/hls/index.m3u8` after the recording has finalized redirects to `/file/`, so URLs cached by clients in either form continue to work across the transition.
- New HLS playback endpoint (`RecordingViewSet.hls`) serves `.m3u8` and `.ts` files out of the recording's working directory with path traversal protection (`os.path.realpath` containment check). Playlist files are rewritten on the fly so each segment line becomes an absolute URL routed back through this endpoint, preserving authentication and path isolation. Both this and the existing `/file/` endpoint are gated by the `STREAMS` network policy and a per-user authorization check (see Security).
- Explicit `path('recordings/<int:pk>/hls/<path:seg_path>', ...)` route registered in `apps/channels/api_urls.py` _before_ `router.urls`. DRF's `DefaultRouter` appends a mandatory trailing slash to every URL it generates, but HLS players request segments by their natural filenames (`seg_00001.ts`) without a trailing slash, so the explicit route is required for the player to ever reach the view.
- `DISPATCHARR_WEB_HOST` environment variable for modular deployments. The Celery container needs to reach the uWSGI / web container to fetch HLS segments while recording (FFmpeg pulls from the public stream URL, the same way any other client does). `get_dvr_stream_base_url()` resolves the base URL deterministically: AIO / dev / debug containers reach uWSGI on `127.0.0.1:$DISPATCHARR_PORT` since Celery and uWSGI share the container, while modular deployments use `http://$DISPATCHARR_WEB_HOST:$DISPATCHARR_PORT` and default `DISPATCHARR_WEB_HOST` to `web` (the compose service name). Documented as a commented-out override in `docker/docker-compose.yml`.
- HLS viewer heartbeat. Every `.ts` request refreshes a Redis key `dvr:hls_viewer:{id}` with a 20 second TTL. After concat succeeds, the recording task waits for that key to expire naturally before deleting the HLS working directory, so an actively-watching client is never cut off mid-segment when the recording ends. Cleanup happens within 20 seconds of the last client stopping, with a 4 hour safety cap as a guard against a stuck Redis state.
- **VOD start/stop notifications**: the frontend now shows a toast notification when a VOD stream starts or stops. `vod_started` and `vod_stopped` WebSocket events are fired from the backend when a new provider connection is opened or the last active stream on a session ends. The 1-second delayed-cleanup window is used as a settle period before firing `vod_stopped`, so seek reconnects that re-establish `active_streams` within that window suppress the notification. A `vod_stats` WebSocket push is sent alongside every event to keep the Stats page connection table in sync in real time. `vod_start` and `vod_stop` system events are also written to the system event log for each transition, and are now selectable as integration trigger events (webhook/script) in the Connect page.
- **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo)
- **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv)
- **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv)
- **Targeted stream-stats refresh in the channel table**: expanding a channel row now refreshes `stream_stats` for that channel's streams via a new lightweight delta endpoint (`GET /api/channels/channels/{channel_id}/streams/stats/`) instead of relying on a full channel-list re-fetch. The endpoint accepts a `since` (ISO 8601) cursor and an optional `ids` filter and returns only streams whose `stream_stats_updated_at` is strictly newer than the cursor, so the response is empty when nothing has changed. The frontend computes the cursor on demand from the streams already in the store, fires the request once on row expand, and fires it again scoped to a single stream ID when the in-app preview player closes. A new `patchChannelStreamStats` Zustand mutator merges the response into the store while preserving object identity for unchanged channels and streams, so memoized rows do not re-render. This restores the live-stats refresh behaviour that the channel-table performance work removed (`requeryChannels()`) without re-introducing the page-wide re-fetch.
- **Editable default M3U profile patterns**: the default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields, allowing users to apply a URL transformation to every stream in the playlist (useful for replacing a local IP address, for example). A warning alert explains the fallback behaviour. A "Reset to Defaults" button restores the pass-through patterns (`^(.*)$` / `$1`). The live regex demonstration panel is also shown for the default profile. The backend serializer and `transform_url` were updated accordingly: `search_pattern` and `replace_pattern` are now permitted fields when updating a default profile, and `transform_url` uses `regex.subn()` to detect a genuine non-matches, logging a `WARNING` when the pattern does not match any part of the URL.
### Performance
- **TS proxy buffer writes pipelined**: `StreamBuffer.add_chunk` previously issued five separate Redis commands per buffered chunk (`incr`, `setex`, `zadd`, `zremrangebyscore`, `expire`), each a full round trip. The `incr` is still issued first because its return value is needed to build the chunk key, but the remaining four commands are now queued on a non-transactional pipeline and flushed in a single `execute()` call. Drops per-chunk Redis round trips from 5 to 2 on busy channels.
- **TS proxy per-client stats writes throttled**: `ClientStreamer._process_chunks` was issuing a Redis `hset` of client stats (chunks sent, bytes sent, transfer rates, last_active) on every chunk delivered to every client. The `hset` is now gated to once per second per client via a new `stats_write_interval` throttle, matching the actual polling cadence of the frontend stats panel. The TTL refresh logic and in-memory rate calculations are unchanged. Dramatically reduces Redis traffic on channels with many concurrent viewers.
- **`send_m3u_update` skips redundant `M3UAccount` lookup**: the helper used to re-fetch the `M3UAccount` row on every progress tick just to populate `status` and `last_message`. It now skips the query entirely when the caller has already supplied both fields in `kwargs`, and uses `.only("status", "last_message")` when the lookup is needed. Removes thousands of pointless `SELECT` queries from M3U download and processing progress streams.
- **M3U auto-channel orphan cleanup**: replaced a redundant `orphaned_channels.count()` followed by `.delete()` with a single `qs.delete()` call that uses the count returned by Django's delete. Saves one COUNT query per auto-sync run.
- **Channel table performance**:
- Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports.
- Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization.
- Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator.
- Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator.
- Stream reorder in `ChannelTableStreams` now completes in a single PATCH request instead of three. Previously dragging a stream to a new position triggered a PATCH, then `requeryStreams()` (re-fetching all streams), then `requeryChannels()` (re-fetching the entire paginated channel list with all embedded stream objects). The reorder now uses a dedicated `API.reorderChannelStreams()` path that issues only the PATCH, then updates the store in-place by reordering the existing stream objects without any network round-trips. On failure, `requeryChannels()` is called to restore correct state.
- Fixed N+1 `UPDATE` queries in the stream-order write path. `ChannelSerializer.update()` and the bulk-edit view were calling `ChannelStream.save(update_fields=["order"])` once per stream whose position changed. Both now collect all modified `ChannelStream` objects and issue a single `ChannelStream.objects.bulk_update(…, ["order"])` call. Also removed an accidental `print(normalized_ids)` debug statement left in the serializer.
- Applied the same lightweight store-update approach from stream reorder to stream removal. `removeStream` previously called `API.updateChannel` (which internally triggered `requeryStreams`), then also explicitly called `requeryChannels()` and `requeryStreams()`. Removal now calls `API.reorderChannelStreams` with the remaining stream list (optimistic local `setData` first), matching the one-request pattern used by drag reorder.
- `removeStream` in `ChannelTableStreams` was capturing `data` and `channel` in its closure, causing the `columns` `useMemo` to recreate the entire column array (and new TanStack table instance) on every reorder or remove. Both values are now read through refs (`channelRef`, `dataRef`) so `removeStream` has no dependencies and is stable for the lifetime of the component. Removed `removeStream` and `playlists` from the `columns` dep array.
- `DraggableRow` (stream rows inside the expanded channel) is now wrapped in `React.memo` with a comparator on `row.original` identity and `index`, so rows whose stream data and position haven't changed are skipped entirely during re-renders caused by a sibling row moving.
- Removed dead code in the `name` column cell: `playlists[stream.m3u_account]?.name` was indexing an array by an integer ID, which always returns `undefined`, the value was immediately overridden by `m3uAccountsMap`. The dead access and its fallback variable are gone; `m3uAccountsMap` is now the sole lookup.
- Removed spurious `state: { data }` from the `useReactTable` call. `data` is a root-level TanStack Table option, not a controlled-state entry; passing it in `state` was a no-op but misleading.
- Adding streams to a channel (per-row "Add to Channel" button and bulk "Add selected to Channel") now completes in a single PATCH request instead of three. Previously each operation called `API.updateChannel` (which internally triggered `requeryStreams`), then `requeryChannels()`. Both paths now use a dedicated `API.addStreamsToChannel()` method that issues only the PATCH, merges the existing channel streams with the newly added stream objects locally, and updates the store in-place, no `requeryStreams` or `requeryChannels` round-trips.
- Removed an unnecessary `requeryStreams()` call from `API.createChannelFromStream()`. Stream data does not change when a channel is created from it; the caller already calls `requeryChannels()` to display the new channel.
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)
- Eliminated per-tick DB queries from the channel stats system. Previously `get_basic_channel_info()` in `channel_status.py` issued a `Stream.objects.filter()` and an `M3UAccountProfile.objects.filter()` on every stats tick for each active channel to resolve display names. Channel name and stream name are now written into the Redis metadata hash at channel-init time (via `initialize_channel`) and read back directly during stats collection, zero DB queries per tick. `m3u_profile_name` is now resolved on the frontend from the already-loaded playlists store rather than being pushed from the backend.
- Eliminated a redundant `Channel` DB lookup inside `ChannelService.initialize_channel()`. `views.py` already fetches the `Channel` object via `get_stream_object()` before calling `initialize_channel()`; `channel.name` is now passed as an optional `channel_name` parameter, so the service uses the caller-supplied value and only falls back to a DB query when the name is not provided (e.g. stream-preview paths). A matching `stream_name` parameter was added for the same reason; the `stream_name` DB query is skipped entirely on normal channel start since a channel name is always available.
- Eliminated a redundant `Stream` DB lookup on stream switches. `get_stream_info_for_switch()` already fetches the `Stream` object to build the URL; it now includes `stream_name` in its return dict. `change_stream_url()` captures that value and passes it through to `_update_channel_metadata()`, which skips its own `Stream.objects.filter()` when the name is already known.
- **Dedicated thread-pool Celery worker for DVR recordings**. `run_recording` is long-running and almost entirely I/O-bound: it loops in short ticks polling FFmpeg, the DB, and Redis for the full duration of the recording. Running it on the default prefork worker pool (`--autoscale=6,1`) meant at most 6 concurrent recordings, and only if no other background work was running. M3U refreshes, EPG parsing, channel matching, comskip, etc. all competed for the same 6 slots, so if every worker was busy when a recording's start time arrived, the task would queue in Redis and FFmpeg would not start until a worker freed up, causing the user to miss the beginning of the show.
- A second Celery worker is now started alongside the default one, configured with `--pool=threads --concurrency=20` and bound to a dedicated `dvr` queue. Threads fit this workload because every blocking call in `run_recording` releases the GIL (sleep, subprocess wait, file / DB / Redis I/O) and FFmpeg itself runs in a separate OS process. A `task_routes` entry in `dispatcharr/celery.py` routes `apps.channels.tasks.run_recording` to the `dvr` queue regardless of how it is dispatched (Celery Beat `PeriodicTask`, `.delay()`, etc.). The default prefork worker now subscribes only to the `celery` queue (`-Q celery -n default@%h --autoscale=6,1`), so recordings can no longer starve background tasks and background tasks can no longer delay recordings.
- Net result: up to 20 concurrent recordings, zero startup delay regardless of EPG / M3U refresh activity, and a memory cost of roughly 80 to 120 MB for the second always-on worker process. Each additional concurrent recording costs nearly nothing on top because all 20 threads share that single process. Applied to AIO (`docker/uwsgi.ini`), dev (`docker/uwsgi.dev.ini`), debug (`docker/uwsgi.debug.ini`), and the modular celery container (`docker/entrypoint.celery.sh`).
- **Added database index on `Stream.name`**. The stream list default sort is `ORDER BY name`, but the column had no index, causing full table scans that spilled to disk (observed at ~38 MB temp file / ~800 ms) on large stream libraries. `db_index=True` is now set on the field, letting PostgreSQL satisfy the sort via an index scan with no disk spill. (Fixes #1209)
- **Streams table now only refetches what changed**. Loading the Channels page fired `/streams/`, `/streams/ids/`, and `/streams/filter-options/` together via `Promise.all`, and the trio refired on every pagination, sort, or filter change. The IDs list and filter options don't depend on page or sort order, so they were re-pulled needlessly (~440 KB per pagination round trip on a 70k-stream library). `fetchData` was split into a `fetchPageData` for the visible rows (depends on page + sort + filters) plus dedicated effects for the IDs and filter-options endpoints (filters only). Initial-load timing is unchanged (still parallelized), but every subsequent paginate or sort toggle now hits only `/streams/`.
### Changed
- **Plugin discovery startup gated to the main process**. `apps/plugins/apps.py` now consults `should_skip_initialization()` before its eager in-memory `discover_plugins()` pass in `AppConfig.ready()`. The pass previously ran in every uwsgi worker fork, every Celery worker, and every `manage.py` invocation. Plugin discovery now happens lazily on first Connect event in worker processes (cached thereafter); the existing `post_migrate` handler still keeps the database side in sync.
- DVR recording pipeline rewritten around FFmpeg + HLS. The previous implementation wrote a single growing `.ts` file via the proxy and remuxed it to `.mkv` at the end; the new implementation runs FFmpeg with `-f hls`, regenerates monotonic PTS to handle erratic IPTV source timestamps, shifts output timestamps so they start at 0 (fixing negative-PTS streams that prevented HLS segment boundary detection), and concatenates the resulting segments into the final MKV at the end of the recording. Server-restart resume now continues segment numbering from the previous session rather than overwriting earlier segments.
- Stall detection added to the recording loop. Once the first segment confirms the stream is flowing, the loop watches both segment count and segment file mtime. If neither advances for the configured stall window (e.g. when an upstream proxy ghost-kills the client), FFmpeg is signaled and the recording ends cleanly rather than hanging until `end_time`. Recording duration is honored via SIGINT so FFmpeg writes `#EXT-X-ENDLIST` cleanly into the final playlist.
- `end_time` extension is now picked up by the running recording without restarting FFmpeg. The DB poll loop simply updates the in-memory deadline.
- Recording cancellation cleanup updated for the new layout. `RecordingViewSet.destroy()` now removes the HLS working directory via a `_safe_rmtree` helper (with the same `allowed_roots` containment check used for the existing `_safe_remove`) instead of trying to delete the obsolete `_temp_file_path`.
- HLS working directory lifecycle in `custom_properties` is now two-phase. After concat succeeds, only `file_url` and `output_file_url` are updated so new client requests are redirected to the final MKV via `/file/`; `_hls_dir` is intentionally preserved through the viewer-wait grace period so in-flight `.ts` requests keep resolving. `_hls_dir` is cleared from `custom_properties` only after `shutil.rmtree` actually removes the directory.
- **Column resizing in `CustomTable`**: column widths are now propagated to body cells via CSS custom properties (`--header-{id}-size`) injected on the table wrapper, rather than reading `column.getSize()` directly in each cell's React style. This decouples body-cell widths from React renders so that memoized rows (which skip re-renders for performance) still reflect resize changes instantly via CSS cascade.
- Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state.
- Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows.
- Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths.
- Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed.
- **Improved channel start/client connect notifications**: when a channel starts streaming, the redundant separate "new channel" and "new client" toasts are now combined into a single **"Channel started streaming"** notification. All connect/disconnect notifications display both the channel name and the user identity (username + IP, or IP alone) on separate lines. A module-level `pageLoadTime` timestamp compared against each client's `connected_at` field from Redis filters out pre-existing connections on page load, while connections that genuinely start after the page loads (even if they arrive in the very first stats poll) correctly fire notifications.
- **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response.
- **Duration tooltip on Stats page connection table**: the hover tooltip on the Duration column now shows a human-readable breakdown instead of a raw seconds count. Seconds only under a minute, minutes and seconds under an hour, hours and minutes under a day, and days and hours beyond that (e.g. `2 hours, 15 minutes`).
- Dependency updates:
- `psycopg2-binary` 2.9.11 → 2.9.12
- `lxml` 6.0.3 → 6.1.0 (security patch; see Security section)
- `sentence-transformers` 5.4.0 → 5.4.1
- `@xmldom/xmldom` 0.8.12 → 0.8.13 (security patch; see Security section)
### Removed
- **Dead M3U helper methods**: deleted `M3UAccount.deactivate_streams`, `M3UAccount.reactivate_streams`, and `M3UFilter.filter_streams`. None had any callers anywhere in the codebase. New code that needs to flip `is_active` on every stream of an account should use `self.streams.update(is_active=...)` rather than reintroducing a per-row `.save()` loop.
- **HDHomeRun SSDP advertiser**. The `apps/hdhr/ssdp.py` module and the `start_ssdp()` call in `apps.hdhr.apps.HdhrConfig.ready()` have been removed. The implementation advertised a `urn:schemas-upnp-org:device:MediaServer:1` UPnP MediaServer device on `udp/1900`, but Dispatcharr does not implement the UPnP MediaServer Content Directory Service that such an advertisement promises, and the `LOCATION`-targeted `/hdhr/device.xml` returns HDHomeRun-flavored XML rather than the UPnP descriptor a generic UPnP browser expects. None of the major HDHomeRun clients (Plex, Emby, Jellyfin, Channels DVR, Kodi) use SSDP for tuner discovery, they use SiliconDust's native UDP broadcast on `udp/65001`, which Dispatcharr does not currently implement. The advertiser ran a listener thread, a broadcaster thread, a bound multicast socket, and emitted a `NOTIFY` broadcast every 30 seconds for no consumer. Tuners can still be added in any HDHomeRun-aware client by entering the Dispatcharr URL manually (e.g. `http://<host>:9191/`).
## [0.23.0] - 2026-04-17
### Security

View file

@ -55,12 +55,17 @@ This is the simplest valid repo manifest - one plugin with enough info to show i
"name": "Weather Display",
"description": "Shows weather info on the dashboard",
"author": "Acme Labs",
"maintainers": ["alice", "bob"],
"license": "MIT",
"deprecated": false,
"repo_url": "https://github.com/acmelabs/dispatcharr-weather",
"discord_thread": "https://discord.com/channels/123456/789012",
"latest_version": "1.2.5",
"last_updated": "2025-01-20T15:30:00Z",
"manifest_url": "plugins/weather_display/manifest.json",
"latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip",
"latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"latest_size": 142,
"icon_url": "plugins/weather_display/logo.png",
"min_dispatcharr_version": "2.5.0",
"max_dispatcharr_version": null
@ -128,13 +133,18 @@ If the name contains any of these, the repo will be rejected on add and skipped
| `name` | **Yes** | Human-readable display name. |
| `description` | No | Short description shown on the plugin card. |
| `author` | No | Author or organization name. |
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
@ -216,11 +226,14 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"author": "Acme Labs",
"license": "MIT",
"latest_version": "1.2.5",
"registry_name": "Acme Labs Plugins",
"registry_url": "https://github.com/acmelabs/dispatcharr-plugins",
"versions": [
{
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 142,
"build_timestamp": "2025-01-20T15:30:00Z",
"commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648",
"commit_sha_short": "4e8f1b1",
@ -231,6 +244,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"version": "1.2.5-rc.1",
"url": "releases/weather_display-1.2.5-rc.1.zip",
"checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"size": 141,
"prerelease": true,
"build_timestamp": "2025-01-18T09:00:00Z",
"min_dispatcharr_version": "2.5.0"
@ -239,6 +253,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"version": "1.2.4",
"url": "releases/weather_display-1.2.4.zip",
"checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6",
"size": 138,
"build_timestamp": "2025-01-15T10:00:00Z",
"min_dispatcharr_version": "2.4.0"
}
@ -246,7 +261,9 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"latest": {
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"latest_url": "releases/weather_display-latest.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 142,
"build_timestamp": "2025-01-20T15:30:00Z",
"min_dispatcharr_version": "2.5.0"
}
@ -263,8 +280,10 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
| `author` | No | Author/org name shown in the detail modal. |
| `license` | No | SPDX license identifier. |
| `latest_version` | No | Latest version string. |
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
| `versions` | No | Array of version objects (newest first recommended). |
| `latest` | No | Object mirroring the latest version entry for quick access. |
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
### Version Object Fields
@ -277,6 +296,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
@ -536,12 +556,18 @@ You can host release zips as GitHub Release assets and reference them with absol
"name": "string (required)",
"description": "string",
"author": "string",
"maintainers": ["string"],
"license": "string (SPDX)",
"deprecated": "boolean",
"repo_url": "string (URL)",
"discord_thread": "string (URL)",
"latest_version": "string (semver)",
"last_updated": "string (ISO 8601)",
"manifest_url": "string (URL or relative path)",
"latest_url": "string (URL or relative path)",
"latest_sha256": "string (64-char hex)",
"latest_md5": "string",
"latest_size": "number (KB)",
"icon_url": "string (URL or relative path)",
"min_dispatcharr_version": "string (semver)",
"max_dispatcharr_version": "string (semver) or null"
@ -562,11 +588,15 @@ You can host release zips as GitHub Release assets and reference them with absol
"author": "string",
"license": "string (SPDX)",
"latest_version": "string (semver)",
"registry_name": "string",
"registry_url": "string (URL)",
"versions": [
{
"version": "string (required)",
"url": "string (required, URL or relative path)",
"checksum_sha256": "string (64-char hex)",
"size": "number (KB)",
"prerelease": "boolean",
"build_timestamp": "string (ISO 8601)",
"commit_sha": "string",
"commit_sha_short": "string",
@ -577,7 +607,9 @@ You can host release zips as GitHub Release assets and reference them with absol
"latest": {
"version": "string",
"url": "string",
"latest_url": "string (stable symlink URL)",
"checksum_sha256": "string",
"size": "number (KB)",
"build_timestamp": "string",
"min_dispatcharr_version": "string",
"max_dispatcharr_version": "string or null"

View file

@ -15,6 +15,7 @@ from .api_views import (
RecordingViewSet,
RecurringRecordingRuleViewSet,
GetChannelStreamsAPIView,
GetChannelStreamStatsAPIView,
SeriesRulesAPIView,
DeleteSeriesRuleAPIView,
EvaluateSeriesRulesAPIView,
@ -41,6 +42,7 @@ urlpatterns = [
path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'),
path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'),
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
path('channels/<int:channel_id>/streams/stats/', GetChannelStreamStatsAPIView.as_view(), name='get_channel_stream_stats'),
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),
# DVR series rules (order matters: specific routes before catch-all slug)
@ -49,6 +51,11 @@ urlpatterns = [
path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'),
path('series-rules/<path:tvg_id>/', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'),
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
path(
'recordings/<int:pk>/hls/<path:seg_path>',
RecordingViewSet.as_view({'get': 'hls'}),
name='recording-hls',
),
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
]

View file

@ -62,13 +62,14 @@ from rest_framework.filters import SearchFilter, OrderingFilter
from apps.epg.models import EPGData
from apps.vod.models import Movie, Series
from django.db.models import Q
from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404
from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404, JsonResponse, HttpResponseRedirect
from django.utils import timezone
import mimetypes
from django.conf import settings
from rest_framework.pagination import PageNumberPagination
from dispatcharr.utils import network_access_allowed
logger = logging.getLogger(__name__)
@ -797,17 +798,21 @@ class ChannelViewSet(viewsets.ModelViewSet):
if to_remove:
channel.channelstream_set.filter(stream_id__in=to_remove).delete()
to_update = []
for order, stream_id in enumerate(normalized_ids):
if stream_id in current_links:
cs = current_links[stream_id]
if cs.order != order:
cs.order = order
cs.save(update_fields=["order"])
to_update.append(cs)
else:
ChannelStream.objects.create(
channel=channel, stream_id=stream_id, order=order
)
if to_update:
ChannelStream.objects.bulk_update(to_update, ["order"])
# Return the updated objects (already in memory)
serialized_channels = ChannelSerializer(
[channel for channel, _ in validated_updates],
@ -2232,6 +2237,99 @@ class GetChannelStreamsAPIView(APIView):
return Response(serializer.data)
class GetChannelStreamStatsAPIView(APIView):
"""Returns a stats delta for a channel's streams (id, stream_stats,
stream_stats_updated_at). Supports `since` (ISO 8601) and `ids`
(comma-separated) query params."""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description=(
"Return a minimal stats delta for the streams attached to a "
"channel. Used by the channel table to refresh `stream_stats` "
"on row expand and after a preview closes without re-pulling "
"full stream rows."
),
parameters=[
OpenApiParameter(
name="since",
type=OpenApiTypes.DATETIME,
location=OpenApiParameter.QUERY,
required=False,
description=(
"ISO 8601 timestamp. Returns only streams whose "
"`stream_stats_updated_at` is strictly newer than this "
"value. Omit to return all streams for the channel."
),
),
OpenApiParameter(
name="ids",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description=(
"Comma-separated stream IDs to restrict the response "
"to. Combined with `since` via AND."
),
),
],
responses={
200: inline_serializer(
name="ChannelStreamStatsDelta",
fields={
"id": serializers.IntegerField(),
"stream_stats": serializers.JSONField(allow_null=True),
"stream_stats_updated_at": serializers.DateTimeField(allow_null=True),
},
many=True,
),
400: inline_serializer(
name="ChannelStreamStatsErrorResponse",
fields={"detail": serializers.CharField()},
),
},
)
def get(self, request, channel_id):
from django.utils.dateparse import parse_datetime
get_object_or_404(Channel, id=channel_id)
qs = Stream.objects.filter(channels=channel_id)
since_raw = request.query_params.get("since")
if since_raw:
since_dt = parse_datetime(since_raw)
if since_dt is None:
return Response(
{"detail": "Invalid 'since' value. Expected ISO 8601."},
status=status.HTTP_400_BAD_REQUEST,
)
qs = qs.filter(stream_stats_updated_at__gt=since_dt)
ids_raw = request.query_params.get("ids")
if ids_raw:
try:
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
except ValueError:
return Response(
{"detail": "Invalid 'ids' value. Expected comma-separated integers."},
status=status.HTTP_400_BAD_REQUEST,
)
qs = qs.filter(id__in=ids)
data = list(
qs.values("id", "stream_stats", "stream_stats_updated_at")
)
return Response(data)
class UpdateChannelMembershipAPIView(APIView):
permission_classes = [IsOwnerOfObject]
@ -2440,13 +2538,58 @@ class RecordingViewSet(viewsets.ModelViewSet):
def get_permissions(self):
# Allow unauthenticated playback of recording files (like other streaming endpoints)
if self.action == 'file':
if self.action in ('file', 'hls'):
return [AllowAny()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def _user_can_play_recording(self, request, recording):
"""Authorization gate for recording playback (file/hls actions).
Mirrors how live stream endpoints authorize non-admin users, but
unlike the XC-style endpoints these URLs carry no credentials of
their own, so we require an authenticated session/JWT:
* Unauthenticated requests denied.
* Admins (user_level >= 10) allowed.
* Authenticated non-admins allowed only if the recording's
source channel is visible under their channel-profile
assignments and within their user_level.
The network_access_allowed(request, "STREAMS") check applied
before this is a network-perimeter gate (e.g. block external IPs
from streaming at all); it is not a substitute for per-user
authorization.
"""
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "user_level", 0) >= 10:
return True
channel = getattr(recording, "channel", None)
if channel is None:
# Recording with no source channel, only admins can play.
return False
try:
user_profile_count = user.channel_profiles.count()
except Exception:
user_profile_count = 0
filters = {
"id": channel.id,
"user_level__lte": user.user_level,
}
if user_profile_count > 0:
filters["channelprofilemembership__enabled"] = True
filters["channelprofilemembership__channel_profile__in"] = (
user.channel_profiles.all()
)
return Channel.objects.filter(**filters).distinct().exists()
return Channel.objects.filter(**filters).exists()
@action(detail=True, methods=["post"], url_path="comskip")
def comskip(self, request, pk=None):
"""Trigger comskip processing for this recording."""
@ -2460,14 +2603,32 @@ class RecordingViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], url_path="file")
def file(self, request, pk=None):
"""Stream a recorded file with HTTP Range support for seeking."""
"""Stream a completed recording file with HTTP Range support for seeking.
For in-progress recordings, file_url in custom_properties points to
/hls/index.m3u8. If a client hits this endpoint while the recording
is still running (or the MKV is not yet produced), it is redirected to
the HLS playlist endpoint.
"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
recording = get_object_or_404(Recording, pk=pk)
if not self._user_can_play_recording(request, recording):
return JsonResponse({"error": "Forbidden"}, status=403)
cp = recording.custom_properties or {}
file_path = cp.get("file_path")
file_name = cp.get("file_name") or "recording"
if not file_path or not os.path.exists(file_path):
raise Http404("Recording file not found")
if not file_path or not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
# Redirect to HLS if recording is still in progress
hls_dir = cp.get("_hls_dir")
if hls_dir and os.path.isdir(hls_dir):
hls_url = request.build_absolute_uri(
f"/api/channels/recordings/{pk}/hls/index.m3u8"
)
return HttpResponseRedirect(hls_url)
if not file_path or not os.path.exists(file_path):
raise Http404("Recording file not found")
# Guess content type
ext = os.path.splitext(file_path)[1].lower()
@ -2528,6 +2689,74 @@ class RecordingViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return response
@action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)")
def hls(self, request, pk=None, seg_path=None):
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
Clients connecting during recording should use the m3u8 URL returned in
custom_properties.file_url. Segment URLs inside the playlist are rewritten
to route through this endpoint so authentication and path isolation are
preserved.
"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
recording = get_object_or_404(Recording, pk=pk)
if not self._user_can_play_recording(request, recording):
return JsonResponse({"error": "Forbidden"}, status=403)
cp = recording.custom_properties or {}
hls_dir = cp.get("_hls_dir")
if not hls_dir or not os.path.isdir(hls_dir):
# HLS dir is gone, recording is likely complete. Redirect to the
# permanent MKV endpoint for .m3u8 requests so clients that still
# have the HLS URL bookmarked get a useful response.
cp = recording.custom_properties or {}
file_path = cp.get("file_path")
if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0:
return HttpResponseRedirect(
request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/")
)
raise Http404("HLS content not available for this recording")
# Security: prevent path traversal outside the HLS directory
safe_dir = os.path.realpath(hls_dir)
requested = os.path.realpath(os.path.join(hls_dir, seg_path))
if not requested.startswith(safe_dir + os.sep) and requested != safe_dir:
return Response({"error": "Forbidden"}, status=403)
if not os.path.isfile(requested):
raise Http404(f"HLS file not found: {seg_path}")
if seg_path.endswith(".m3u8"):
# Rewrite relative segment lines to absolute URLs through this API
base_url = request.build_absolute_uri(
f"/api/channels/recordings/{pk}/hls/"
)
lines = []
with open(requested) as _f:
for line in _f:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
lines.append(f"{base_url}{stripped}\n")
else:
lines.append(line)
return HttpResponse("".join(lines), content_type="application/x-mpegURL")
if seg_path.endswith(".ts"):
# Refresh the viewer heartbeat in Redis so the Celery task knows an
# active client is still fetching segments. TTL is 20 s, enough for
# three 4-second segments plus network margin.
try:
from core.utils import RedisClient
_rv = RedisClient.get_client(max_retries=1, retry_interval=0)
if _rv:
_rv.set(f"dvr:hls_viewer:{pk}", "1", ex=20)
except Exception:
pass
return FileResponse(open(requested, "rb"), content_type="video/mp2t")
raise Http404("Unsupported HLS file type")
@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."""
@ -2834,7 +3063,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
file_path = cp.get("file_path")
temp_ts_path = cp.get("_temp_file_path")
hls_dir = cp.get("_hls_dir")
channel_uuid = str(instance.channel.uuid)
# 1. Delete the DB record (also fires post_delete → revoke_task_on_delete)
@ -2867,6 +3096,41 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception as ex:
logger.warning(f"Failed to delete recording artifact {path}: {ex}")
def _safe_rmtree(path: str):
if not path or not isinstance(path, str):
return
try:
import shutil as _shutil
if any(path.startswith(root) for root in allowed_roots) and os.path.isdir(path):
_shutil.rmtree(path)
logger.info(f"Deleted recording HLS directory: {path}")
except Exception as ex:
logger.warning(f"Failed to delete HLS directory {path}: {ex}")
# Clean up empty parent directories up to the recordings root to prevent orphaned folders from accumulating over time.
recordings_root = os.path.normpath('/data/recordings')
def _prune_empty_parents(path: str):
if not path or not isinstance(path, str):
return
try:
parent = os.path.dirname(os.path.normpath(path))
while (
parent
and parent != recordings_root
and parent.startswith(recordings_root + os.sep)
and os.path.isdir(parent)
and not os.listdir(parent)
):
try:
os.rmdir(parent)
logger.info(f"Removed empty recording directory: {parent}")
except OSError:
break
parent = os.path.dirname(parent)
except Exception as ex:
logger.debug(f"Unable to prune empty parents for {path}: {ex}")
def _background_cancel():
# Only stop the DVR client if the recording was actively streaming.
# Stopping for completed/upcoming recordings would kill an unrelated
@ -2884,7 +3148,13 @@ class RecordingViewSet(viewsets.ModelViewSet):
# Best-effort file cleanup in case run_recording already exited
# before the DB delete.
_safe_remove(file_path)
_safe_remove(temp_ts_path)
_safe_rmtree(hls_dir)
# If removing the file/HLS dir leaves the show/season folder
# empty, clean those up too. Both paths share the same parent
# in normal layouts, but run the prune for each just in case.
_prune_empty_parents(file_path)
_prune_empty_parents(hls_dir)
try:
from django.db import connection as _conn

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.4 on 2026-04-21 14:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0034_remove_stream_dispatcharr_stream_id_idx_and_more'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='name',
field=models.CharField(max_length=512),
),
migrations.AlterField(
model_name='stream',
name='name',
field=models.CharField(default='Default Stream', max_length=512),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-30 19:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0035_alter_channel_name_alter_stream_name'),
]
operations = [
migrations.AlterField(
model_name='stream',
name='name',
field=models.CharField(db_index=True, default='Default Stream', max_length=512),
),
]

View file

@ -56,7 +56,7 @@ class Stream(models.Model):
Represents a single stream (e.g. from an M3U source or custom URL).
"""
name = models.CharField(max_length=255, default="Default Stream")
name = models.CharField(max_length=512, default="Default Stream", db_index=True)
url = models.URLField(max_length=4096, blank=True, null=True)
m3u_account = models.ForeignKey(
M3UAccount,
@ -294,7 +294,7 @@ class ChannelManager(models.Manager):
class Channel(models.Model):
channel_number = models.FloatField(db_index=True)
name = models.CharField(max_length=255)
name = models.CharField(max_length=512)
logo = models.ForeignKey(
"Logo",
on_delete=models.SET_NULL,

View file

@ -365,7 +365,6 @@ class ChannelSerializer(serializers.ModelSerializer):
normalized_ids = [
stream.id if hasattr(stream, "id") else stream for stream in streams
]
print(normalized_ids)
# Get current mapping of stream_id -> ChannelStream
current_links = {
@ -382,17 +381,21 @@ class ChannelSerializer(serializers.ModelSerializer):
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
# Update or create with new order
to_update = []
for order, stream_id in enumerate(normalized_ids):
if stream_id in current_links:
cs = current_links[stream_id]
if cs.order != order:
cs.order = order
cs.save(update_fields=["order"])
to_update.append(cs)
else:
ChannelStream.objects.create(
channel=instance, stream_id=stream_id, order=order
)
if to_update:
ChannelStream.objects.bulk_update(to_update, ["order"])
return instance
def validate_channel_number(self, value):

File diff suppressed because it is too large Load diff

View file

@ -2,58 +2,68 @@ import os
from django.test import SimpleTestCase
from unittest.mock import patch
from apps.channels.tasks import build_dvr_candidates
from apps.channels.tasks import get_dvr_stream_base_url
class DVRPortResolutionTests(SimpleTestCase):
class DVRStreamBaseURLTests(SimpleTestCase):
"""
Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT
environment variable instead of hardcoding port 9191.
Tests that get_dvr_stream_base_url() returns the correct single URL
for each deployment mode.
"""
@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, {}, clear=True)
def test_aio_default_uses_localhost_5656(self):
"""AIO mode (default) reaches uwsgi directly on loopback port 5656."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://127.0.0.1:5656')
@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_ENV': 'aio'}, clear=True)
def test_aio_explicit_uses_localhost_5656(self):
"""Explicit DISPATCHARR_ENV=aio also uses loopback port 5656."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://127.0.0.1:5656')
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'dev'}, clear=True)
def test_dev_mode_uses_localhost_5656(self):
"""Dev mode shares the container with uwsgi — uses loopback port 5656."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://127.0.0.1:5656')
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular', 'DISPATCHARR_PORT': '9191'}, clear=True)
def test_modular_uses_web_service_name(self):
"""Modular mode uses the 'web' Docker service name by default."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://web:9191')
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular', 'DISPATCHARR_PORT': '8080'}, clear=True)
def test_modular_custom_port(self):
"""Modular mode respects DISPATCHARR_PORT."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://web:8080')
@patch.dict(os.environ, {
'DISPATCHARR_PORT': '7777',
'DISPATCHARR_ENV': 'dev',
'REDIS_HOST': 'redis',
'DISPATCHARR_ENV': 'modular',
'DISPATCHARR_PORT': '9191',
'DISPATCHARR_WEB_HOST': 'dispatcharr_web',
}, 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)
def test_modular_custom_web_host(self):
"""DISPATCHARR_WEB_HOST overrides the default 'web' service name."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://dispatcharr_web:9191')
@patch.dict(os.environ, {
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234',
'REDIS_HOST': 'redis',
'DISPATCHARR_ENV': 'modular',
}, 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')
def test_explicit_override_always_wins(self):
"""DISPATCHARR_INTERNAL_TS_BASE_URL takes priority over all other settings."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://custom:1234')
@patch.dict(os.environ, {
'DISPATCHARR_PORT': '3000',
'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000',
'REDIS_HOST': 'redis',
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234/',
}, 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)
def test_explicit_override_strips_trailing_slash(self):
"""Trailing slash is stripped from DISPATCHARR_INTERNAL_TS_BASE_URL."""
url = get_dvr_stream_base_url()
self.assertEqual(url, 'http://custom:1234')

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-23 23:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_connect', '0002_alter_eventsubscription_event'),
]
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'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], max_length=100),
),
]

View file

@ -16,6 +16,8 @@ SUPPORTED_EVENTS = {
"login_failed": "Login Failed",
"epg_blocked": "EPG Blocked",
"m3u_blocked": "M3U Blocked",
"vod_start": "VOD Started",
"vod_stop": "VOD Stopped",
}
class Integration(models.Model):

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-21 14:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0021_epgsource_priority'),
]
operations = [
migrations.AlterField(
model_name='epgdata',
name='name',
field=models.CharField(max_length=512),
),
]

View file

@ -137,7 +137,7 @@ class EPGData(models.Model):
# Removed the Channel foreign key. We now just store the original tvg_id
# and a name (which might simply be the tvg_id if no real channel exists).
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=255)
name = models.CharField(max_length=512)
icon_url = models.URLField(max_length=500, null=True, blank=True)
epg_source = models.ForeignKey(
EPGSource,

View file

@ -966,6 +966,7 @@ def parse_channels_only(source):
batch_size = 500 # Process in batches to limit memory usage
progress = 0 # Initialize progress variable here
icon_url_max_length = EPGData._meta.get_field('icon_url').max_length # Get max length for icon_url field
name_max_length = EPGData._meta.get_field('name').max_length # Get max length for name field
# Track memory at key points
if process:
@ -1022,6 +1023,10 @@ def parse_channels_only(source):
if not display_name:
display_name = tvg_id
if display_name and len(display_name) > name_max_length:
logger.warning(f"EPG display name too long ({len(display_name)} > {name_max_length}), truncating: {display_name[:80]}...")
display_name = display_name[:name_max_length]
# Use lazy loading approach to reduce memory usage
if tvg_id in existing_tvg_ids:
# Only fetch the object if we need to update it and it hasn't been loaded yet

View file

@ -18,11 +18,22 @@ from django.views import View
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from dispatcharr.utils import network_access_allowed
# Configure logger
logger = logging.getLogger(__name__)
def _hdhr_network_check(request):
"""Return a 403 JsonResponse if the client IP is not allowed by the
M3U_EPG network access policy. HDHR discovery endpoints expose channel
inventory and stream URLs, so they share the same allowlist as M3U/EPG.
"""
if not network_access_allowed(request, "M3U_EPG"):
return JsonResponse({"error": "Forbidden"}, status=403)
return None
@login_required
def hdhr_dashboard_view(request):
"""Render the HDHR management page."""
@ -53,6 +64,10 @@ class DiscoverAPIView(APIView):
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request, profile=None):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked
uri_parts = ["hdhr"]
if profile is not None:
uri_parts.append(profile)
@ -106,6 +121,10 @@ class LineupAPIView(APIView):
description="Retrieve the available channel lineup",
)
def get(self, request, profile=None):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked
if profile is not None:
channel_profile = ChannelProfile.objects.get(name=profile)
channels = Channel.objects.filter(
@ -147,6 +166,10 @@ class LineupStatusAPIView(APIView):
description="Retrieve the HDHomeRun lineup status",
)
def get(self, request, profile=None):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked
data = {
"ScanInProgress": 0,
"ScanPossible": 0,
@ -165,6 +188,10 @@ class HDHRDeviceXMLAPIView(APIView):
description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>

View file

@ -1,10 +1,7 @@
from django.apps import AppConfig
from . import ssdp
class HdhrConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.hdhr'
verbose_name = "HDHomeRun Emulation"
def ready(self):
# Start SSDP services when the app is ready
ssdp.start_ssdp()

View file

@ -1,69 +0,0 @@
import os
import socket
import threading
import time
import gevent # Add this import
from django.conf import settings
# SSDP Multicast Address and Port
SSDP_MULTICAST = "239.255.255.250"
SSDP_PORT = 1900
DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaServer:1"
SERVER_PORT = 8000
def get_host_ip():
try:
# This relies on "host.docker.internal" being mapped to the hosts gateway IP.
return socket.gethostbyname("host.docker.internal")
except Exception:
return "127.0.0.1"
def ssdp_response(addr, host_ip):
response = (
f"HTTP/1.1 200 OK\r\n"
f"CACHE-CONTROL: max-age=1800\r\n"
f"EXT:\r\n"
f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n"
f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
f"ST: {DEVICE_TYPE}\r\n"
f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n"
f"\r\n"
)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.sendto(response.encode("utf-8"), addr)
sock.close()
def ssdp_listener(host_ip):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((SSDP_MULTICAST, SSDP_PORT))
while True:
data, addr = sock.recvfrom(1024)
if b"M-SEARCH" in data and DEVICE_TYPE.encode("utf-8") in data:
print(f"Received M-SEARCH from {addr}")
ssdp_response(addr, host_ip)
def ssdp_broadcaster(host_ip):
notify = (
f"NOTIFY * HTTP/1.1\r\n"
f"HOST: {SSDP_MULTICAST}:{SSDP_PORT}\r\n"
f"CACHE-CONTROL: max-age=1800\r\n"
f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n"
f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
f"NT: {DEVICE_TYPE}\r\n"
f"NTS: ssdp:alive\r\n"
f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n"
f"\r\n"
)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
while True:
sock.sendto(notify.encode("utf-8"), (SSDP_MULTICAST, SSDP_PORT))
gevent.sleep(30) # Replace time.sleep with gevent.sleep
def start_ssdp():
host_ip = get_host_ip()
threading.Thread(target=ssdp_listener, args=(host_ip,), daemon=True).start()
threading.Thread(target=ssdp_broadcaster, args=(host_ip,), daemon=True).start()
print(f"SSDP services started on {host_ip}.")

View file

@ -111,18 +111,6 @@ class M3UAccount(models.Model):
def display_action(self):
return "Exclude" if self.exclude else "Include"
def deactivate_streams(self):
"""Deactivate all streams linked to this account."""
for stream in self.streams.all():
stream.is_active = False
stream.save()
def reactivate_streams(self):
"""Reactivate all streams linked to this account."""
for stream in self.streams.all():
stream.is_active = True
stream.save()
@classmethod
def get_custom_account(cls):
return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True)
@ -205,25 +193,6 @@ class M3UFilter(models.Model):
exclude_status = "Exclude" if self.exclude else "Include"
return f"[{self.m3u_account.name}] {filter_type_display}: {self.regex_pattern} ({exclude_status})"
@staticmethod
def filter_streams(streams, filters):
included_streams = set()
excluded_streams = set()
for f in filters:
for stream in streams:
if f.applies_to(stream.name, stream.group_name):
if f.exclude:
excluded_streams.add(stream)
else:
included_streams.add(stream)
# If no include filters exist, assume all non-excluded streams are valid
if not any(not f.exclude for f in filters):
return streams.exclude(id__in=[s.id for s in excluded_streams])
return streams.filter(id__in=[s.id for s in included_streams])
class ServerGroup(models.Model):
"""Represents a logical grouping of servers or channels."""

View file

@ -93,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
if instance.is_default:
# For default profiles, only allow updating name, custom_properties, and exp_date
allowed_fields = {'name', 'custom_properties', 'exp_date'}
# For default profiles, only allow updating name, custom_properties, exp_date, and patterns
allowed_fields = {'name', 'custom_properties', 'exp_date', 'search_pattern', 'replace_pattern'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
f"Default profiles can only modify name, notes, and expiration. "
f"Default profiles can only modify name, notes, expiration, and URL patterns. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)

View file

@ -1087,6 +1087,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
streams_to_update = []
stream_hashes = {}
name_max_length = Stream._meta.get_field('name').max_length
logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}")
if compiled_filters:
logger.debug(f"Using compiled filters: {[f[1].regex_pattern for f in compiled_filters]}")
@ -1099,6 +1101,11 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
logger.warning(f"Skipping stream '{name}': URL too long ({len(url)} characters, max 4096)")
continue
# Truncate name if it exceeds the model field limit
if name and len(name) > name_max_length:
logger.warning(f"Stream name too long ({len(name)} > {name_max_length}), truncating: {name[:80]}...")
name = name[:name_max_length]
tvg_id, tvg_logo = get_case_insensitive_attr(
stream_info["attributes"], "tvg-id", ""
), get_case_insensitive_attr(stream_info["attributes"], "tvg-logo", "")
@ -2347,12 +2354,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
).values_list('channel_id', flat=True)
)
orphaned_count = orphaned_channels.count()
if orphaned_count > 0:
orphaned_channels.delete()
channels_deleted += orphaned_count
deleted_total, _ = orphaned_channels.delete()
if deleted_total:
channels_deleted += deleted_total
logger.info(
f"Deleted {orphaned_count} orphaned auto channels with no valid streams"
f"Deleted {deleted_total} orphaned auto channels with no valid streams"
)
logger.info(
@ -3181,16 +3187,17 @@ def send_m3u_update(account_id, action, progress, **kwargs):
"action": action,
}
# Add the status and message if not already in kwargs
try:
account = M3UAccount.objects.get(id=account_id)
if account:
# Only fetch the account when we actually need to fill in missing fields.
# Many callers in tight loops already pass status/message; skip the DB hit then.
if "status" not in kwargs or "message" not in kwargs:
try:
account = M3UAccount.objects.only("status", "last_message").get(id=account_id)
if "status" not in kwargs:
data["status"] = account.status
if "message" not in kwargs and account.last_message:
data["message"] = account.last_message
except:
pass # If account can't be retrieved, continue without these fields
except Exception:
pass
# Add the additional key-value pairs from kwargs
data.update(kwargs)

View file

@ -20,6 +20,7 @@ import logging
from django.db.models.functions import Lower
import os
from apps.m3u.utils import calculate_tuner_count
from apps.proxy.utils import get_user_active_connections
import regex
from core.utils import log_system_event
import hashlib
@ -1945,6 +1946,13 @@ def xc_get_info(request, full=False):
hostname = raw_host
port = "443" if request.is_secure() else "80"
if user.stream_limit and user.stream_limit > 0:
active_cons = len(get_user_active_connections(user.id))
max_connections = user.stream_limit
else:
active_cons = len(get_user_active_connections(None))
max_connections = calculate_tuner_count(minimum=1, unlimited_default=50)
info = {
"user_info": {
"username": request.GET.get("username"),
@ -1953,7 +1961,8 @@ def xc_get_info(request, full=False):
"auth": 1,
"status": "Active",
"exp_date": str(int(time.time()) + (90 * 24 * 60 * 60)),
"max_connections": str(calculate_tuner_count(minimum=1, unlimited_default=50)),
"active_cons": str(active_cons),
"max_connections": str(max_connections),
"allowed_output_formats": [
"ts",
],

View file

@ -13,7 +13,11 @@ class PluginsConfig(AppConfig):
- Skip during common management commands that don't need discovery.
- Register post_migrate handler to sync plugin registry to DB after migrations.
- Do an in-memory discovery (no DB) so registry is available early.
- Do an in-memory discovery (no DB) so registry is available early
but only in the main uwsgi/daphne process. Celery workers and
management commands skip the eager pass: the post_migrate handler
covers the DB sync, and `connect/utils.py` lazy-discovers on first
event using the loader's cache.
"""
try:
# Allow explicit opt-out via env var
@ -43,8 +47,11 @@ class PluginsConfig(AppConfig):
_post_migrate_discover,
dispatch_uid="apps.plugins.post_migrate_discover",
)
#
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
# Perform non-DB discovery now to populate in-memory registry.
from .loader import PluginManager
PluginManager.get().discover_plugins(sync_db=False)
except Exception:

View file

@ -54,6 +54,7 @@ class PluginManager:
self._alias_names: Dict[str, str] = {}
self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token")
self._last_reload_token = 0.0
self._discovery_completed = False
self._lock = threading.RLock()
# Ensure plugins directory exists
@ -71,7 +72,7 @@ class PluginManager:
token = self._get_reload_token()
if use_cache and not force_reload:
with self._lock:
if self._registry and token <= self._last_reload_token:
if self._discovery_completed and token <= self._last_reload_token:
return self._registry
if token > self._last_reload_token:
force_reload = True
@ -231,6 +232,7 @@ class PluginManager:
self._alias_names = new_aliases
if token > self._last_reload_token:
self._last_reload_token = token
self._discovery_completed = True
logger.info(f"Discovered {len(new_registry)} plugin(s)")
except FileNotFoundError:

View file

@ -148,9 +148,7 @@ class ChannelStatus:
}
if 'connected_at' in client_data:
connected_at = float(client_data['connected_at'])
client_info['connected_at'] = connected_at
client_info['connection_duration'] = time.time() - connected_at
client_info['connected_at'] = float(client_data['connected_at'])
if 'last_active' in client_data:
last_active = float(client_data['last_active'])
@ -399,24 +397,21 @@ class ChannelStatus:
'uptime': uptime
}
# Add stream ID and name information
channel_name = metadata.get(ChannelMetadataField.CHANNEL_NAME)
if channel_name:
info['channel_name'] = channel_name
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
try:
from apps.channels.models import Stream
stream = Stream.objects.filter(id=stream_id).first()
if stream:
info['stream_name'] = stream.name
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get stream name for ID {stream_id}: {e}")
info['stream_id'] = int(stream_id_bytes)
except ValueError:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
stream_name = metadata.get(ChannelMetadataField.STREAM_NAME)
if stream_name:
info['stream_name'] = stream_name
# Add data throughput information to basic info
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES)
if total_bytes_bytes:
@ -472,8 +467,7 @@ class ChannelStatus:
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
if connected_at_bytes:
connected_at = float(connected_at_bytes)
client_info['connected_since'] = time.time() - connected_at
client_info['connected_at'] = float(connected_at_bytes)
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
if user_id_bytes:
@ -485,21 +479,11 @@ class ChannelStatus:
info['clients'] = clients
info['client_count'] = client_count
# Add M3U profile information
# Add M3U profile ID from Redis metadata (name resolved on frontend from playlists store)
m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id:
try:
m3u_profile_id = int(m3u_profile_id)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
try:
from apps.m3u.models import M3UAccountProfile
m3u_profile = M3UAccountProfile.objects.filter(id=m3u_profile_id).first()
if m3u_profile:
info['m3u_profile_name'] = m3u_profile.name
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}")
info['m3u_profile_id'] = int(m3u_profile_id)
except ValueError:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}")

View file

@ -50,6 +50,8 @@ class ChannelMetadataField:
STATE = "state"
OWNER = "owner"
STREAM_ID = "stream_id"
CHANNEL_NAME = "channel_name"
STREAM_NAME = "stream_name"
# Profile fields
STREAM_PROFILE = "stream_profile"

View file

@ -66,6 +66,7 @@ class ProxyServer:
self.stream_managers = {}
self.stream_buffers = {}
self.client_managers = {}
self._channel_names = {}
# Generate a unique worker ID
import socket
@ -672,7 +673,9 @@ class ProxyServer:
# Log channel start event
try:
channel_obj = Channel.objects.get(uuid=channel_id)
_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
channel_name = _name if _name else str(channel_id)
self._channel_names[channel_id] = channel_name
# Get stream name if stream_id is available
stream_name = None
@ -686,7 +689,7 @@ class ProxyServer:
log_system_event(
'channel_start',
channel_id=channel_id,
channel_name=channel_obj.name,
channel_name=channel_name,
stream_name=stream_name,
stream_id=channel_stream_id
)
@ -941,7 +944,7 @@ class ProxyServer:
# Log channel stop event (after cleanup, before releasing ownership section ends)
try:
channel_obj = Channel.objects.get(uuid=channel_id)
channel_name = self._channel_names.pop(channel_id, None) or str(channel_id)
# Calculate runtime and get total bytes from metadata
runtime = None
@ -967,7 +970,7 @@ class ProxyServer:
log_system_event(
'channel_stop',
channel_id=channel_id,
channel_name=channel_obj.name,
channel_name=channel_name,
runtime=runtime,
total_bytes=total_bytes
)

View file

@ -23,7 +23,7 @@ class ChannelService:
"""Service class for channel operations"""
@staticmethod
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None):
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None, channel_name=None, stream_name=None):
"""
Initialize a channel with the given parameters.
@ -35,6 +35,8 @@ class ChannelService:
stream_profile_value: Stream profile value to store in metadata
stream_id: ID of the stream being used
m3u_profile_id: ID of the M3U profile being used
channel_name: Channel name (avoids DB lookup if already known)
stream_name: Stream name (avoids DB lookup if already known)
Returns:
bool: Success status
@ -79,6 +81,23 @@ class ChannelService:
if m3u_profile_id:
update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
# Store channel name and stream name so stats workers don't need DB calls
try:
if not channel_name:
from apps.channels.models import Channel
channel_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
if channel_name:
update_data[ChannelMetadataField.CHANNEL_NAME] = channel_name
else:
# No channel name means stream preview mode, use stream name as display fallback
if stream_id and not stream_name:
from apps.channels.models import Stream
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
if stream_name:
update_data[ChannelMetadataField.STREAM_NAME] = stream_name
except Exception as e:
logger.warning(f"Failed to store channel/stream names in Redis for {channel_id}: {e}")
if update_data:
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
@ -103,6 +122,7 @@ class ChannelService:
# If no direct URL is provided but a target stream is, get URL from target stream
stream_id = None
stream_name = None
if not new_url and target_stream_id:
stream_info = get_stream_info_for_switch(channel_id, target_stream_id)
if 'error' in stream_info:
@ -113,6 +133,7 @@ class ChannelService:
new_url = stream_info['url']
user_agent = stream_info['user_agent']
stream_id = target_stream_id
stream_name = stream_info.get('stream_name')
# Extract M3U profile ID from stream info if available
if 'm3u_profile_id' in stream_info:
m3u_profile_id = stream_info['m3u_profile_id']
@ -186,7 +207,7 @@ class ChannelService:
if proxy_server.redis_client:
try:
if success:
ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id, stream_name)
else:
ChannelService._update_channel_metadata(channel_id, manager.url, user_agent)
result['metadata_updated'] = True
@ -579,7 +600,7 @@ class ChannelService:
# Helper methods for Redis operations
@staticmethod
def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None):
def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None, stream_name=None):
"""Update channel metadata in Redis"""
proxy_server = ProxyServer.get_instance()
@ -598,6 +619,14 @@ class ChannelService:
metadata[ChannelMetadataField.USER_AGENT] = user_agent
if stream_id:
metadata[ChannelMetadataField.STREAM_ID] = str(stream_id)
if not stream_name:
try:
from apps.channels.models import Stream
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
except Exception as e:
logger.warning(f"Failed to update stream name in Redis for stream {stream_id}: {e}")
if stream_name:
metadata[ChannelMetadataField.STREAM_NAME] = stream_name
if m3u_profile_id:
metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)

View file

@ -103,19 +103,23 @@ class StreamBuffer:
chunk_data = self._write_buffer[:self.target_chunk_size]
self._write_buffer = self._write_buffer[self.target_chunk_size:]
# Write optimized chunk to Redis
# Write optimized chunk to Redis. We need the new index from
# incr() to build the chunk key, so issue that first; the
# remaining writes are pipelined into one round trip.
if self.redis_client:
chunk_index = self.redis_client.incr(self.buffer_index_key)
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
pipe = self.redis_client.pipeline(transaction=False)
pipe.setex(chunk_key, self.chunk_ttl, bytes(chunk_data))
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)
pipe.zadd(self.chunk_timestamps_key, {str(chunk_index): now})
pipe.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl)
pipe.expire(self.chunk_timestamps_key, self.chunk_ttl)
pipe.execute()
# Update local tracking
self.index = chunk_index

View file

@ -43,6 +43,12 @@ class StreamGenerator:
self.client_user_agent = client_user_agent
self.channel_initializing = channel_initializing
self.user = user
# Cache channel name once to avoid repeated DB queries for logging
try:
_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
self.channel_name = _name if _name else str(channel_id)
except Exception:
self.channel_name = str(channel_id)
# Performance and state tracking
self.stream_start_time = time.time()
@ -60,6 +66,11 @@ class StreamGenerator:
self.last_ttl_refresh = time.time()
self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming
# Throttle per-client stats writes to Redis.
# channels with many viewers.
self.last_stats_write = 0.0
self.stats_write_interval = 1.0
# Cached proxy server reference
self.proxy_server = None
@ -107,11 +118,10 @@ class StreamGenerator:
# Log client connect event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'client_connect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
@ -443,9 +453,12 @@ class StreamGenerator:
logger.debug(f"[{self.client_id}] Stats: {self.chunks_sent} chunks, {self.bytes_sent/1024:.1f} KB, "
f"avg: {avg_rate:.1f} KB/s, current: {self.current_rate:.1f} KB/s")
# Store stats in Redis client metadata
if proxy_server.redis_client:
# Store stats in Redis client metadata, throttled to avoid an
# hset on every chunk. Frontend stats panels poll on the order
# of seconds, so 1s resolution is sufficient.
if proxy_server.redis_client and (current_time - self.last_stats_write) >= self.stats_write_interval:
try:
self.last_stats_write = current_time
client_key = RedisKeys.client_metadata(self.channel_id, self.client_id)
stats = {
ChannelMetadataField.CHUNKS_SENT: str(self.chunks_sent),
@ -587,11 +600,10 @@ class StreamGenerator:
# Log client disconnect event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'client_disconnect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,

View file

@ -32,6 +32,12 @@ class StreamManager:
def __init__(self, channel_id, url, buffer, user_agent=None, transcode=False, stream_id=None, worker_id=None):
# Basic properties
self.channel_id = channel_id
# Cache channel name once to avoid repeated DB queries in hot retry/reconnect loops
try:
_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
self.channel_name = _name if _name else str(channel_id)
except Exception:
self.channel_name = str(channel_id)
self.url = url
self.buffer = buffer
self.running = True
@ -274,11 +280,10 @@ class StreamManager:
# Log reconnection event if this is a retry (not first attempt)
if self.retry_count > 0:
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_reconnect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
attempt=self.retry_count + 1,
max_attempts=self.max_retries
)
@ -317,11 +322,10 @@ class StreamManager:
# Log connection error event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_error',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
error_type='connection_failed',
url=self.url[:100] if self.url else None,
attempts=self.max_retries
@ -344,11 +348,10 @@ class StreamManager:
# Log connection error event with exception details
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_error',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
error_type='connection_exception',
error_message=str(e)[:200],
url=self.url[:100] if self.url else None,
@ -826,11 +829,10 @@ class StreamManager:
# Log failover event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_failover',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
reason='buffering_timeout',
duration=buffering_duration
)
@ -846,11 +848,10 @@ class StreamManager:
# Log system event for buffering
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_buffering',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
speed=ffmpeg_speed
)
except Exception as e:
@ -1172,11 +1173,10 @@ class StreamManager:
# Log stream switch event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'stream_switch',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
new_url=new_url[:100] if new_url else None,
stream_id=stream_id
)
@ -1304,11 +1304,10 @@ class StreamManager:
# Log reconnection event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_reconnect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
channel_name=self.channel_name,
reason='health_monitor'
)
except Exception as e:

View file

@ -153,8 +153,11 @@ def transform_url(input_url: str, search_pattern: str, replace_pattern: str) ->
logger.debug(f" safe replace: {safe_replace_pattern}")
# Apply the transformation (regex module accepts JS-style (?<name>...) natively)
stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url)
logger.info(f"Generated stream url: {stream_url}")
stream_url, match_count = regex.subn(search_pattern, safe_replace_pattern, input_url)
if match_count == 0:
logger.warning(f"URL pattern '{search_pattern}' did not match, falling back to original URL: {input_url}")
else:
logger.info(f"Generated stream url: {stream_url}")
return stream_url
except Exception as e:
@ -271,7 +274,8 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
'transcode': transcode,
'stream_profile': profile_value,
'stream_id': stream_id,
'm3u_profile_id': m3u_profile_id
'm3u_profile_id': m3u_profile_id,
'stream_name': stream.name,
}
except Exception as e:
logger.error(f"Error getting stream info for switch: {e}", exc_info=True)

View file

@ -373,6 +373,7 @@ def stream_ts(request, channel_id, user=None):
profile_value,
stream_id,
m3u_profile_id,
channel_name=channel.name,
)
if not success:

View file

@ -83,6 +83,10 @@ def attempt_stream_termination(user_id, requesting_client_id, active_connections
return False
def get_user_active_connections(user_id):
"""Return active stream connections for a single user.
Pass `user_id=None` to return all active connections across the system.
"""
redis_client = RedisClient.get_client()
connections = []
@ -101,7 +105,7 @@ def get_user_active_connections(user_id):
logger.debug(f"[stream limits] channel_id = {channel_id}")
logger.debug(f"[stream limits] client_id = {client_id}")
if client_user_id and int(client_user_id) == user_id:
if user_id is None or (client_user_id and int(client_user_id) == user_id):
try:
logger.debug(f"[stream limits] Found LIVE connection for user {user_id} on channel {channel_id} with client ID {client_id}")
connected_at = float(connected_at) if connected_at else 0
@ -127,7 +131,7 @@ def get_user_active_connections(user_id):
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] client_id = {client_id}")
if client_user_id and int(client_user_id) == user_id:
if user_id is None or (client_user_id and int(client_user_id) == user_id):
try:
logger.debug(f"[stream limits] Found VOD connection for user {user_id} on content {content_uuid} with client ID {client_id}")
connected_at = float(connected_at) if connected_at else 0

View file

@ -797,6 +797,67 @@ class MultiWorkerVODConnectionManager:
logger.error(f"Error incrementing profile connections: {e}")
return None
def _trigger_vod_stats_update(self):
"""Trigger a VOD stats WebSocket update in a background thread."""
threading.Thread(target=self._do_vod_stats_update, daemon=True).start()
def _send_vod_event(self, event_type, session_id, content_name, content_uuid, client_ip, user_id, username=None):
"""Send a vod_started or vod_stopped WebSocket event, log a system event, then update stats."""
try:
from core.utils import send_websocket_update, log_system_event
if not self.redis_client:
return
send_websocket_update(
"updates",
"update",
{
"type": event_type,
"content_name": content_name,
"content_uuid": content_uuid,
"client_ip": client_ip,
"user_id": user_id,
}
)
system_event_type = 'vod_start' if event_type == 'vod_started' else 'vod_stop'
try:
log_system_event(
system_event_type,
content_name=content_name,
content_uuid=content_uuid,
client_ip=client_ip,
username=username,
)
except Exception as e:
logger.error(f"Could not log system event {system_event_type}: {e}")
self._trigger_vod_stats_update()
except Exception as e:
logger.error(f"Failed to send {event_type}: {e}")
def _do_vod_stats_update(self):
"""Collect active VOD connections (with full DB metadata) and push via WebSocket."""
try:
from core.utils import send_websocket_update
from apps.proxy.vod_proxy.views import build_vod_stats_data
if not self.redis_client:
return
stats = build_vod_stats_data(self.redis_client)
send_websocket_update(
"updates",
"update",
{
"type": "vod_stats",
"stats": json.dumps(stats)
}
)
except Exception as e:
logger.error(f"Failed to trigger VOD stats update: {e}")
def _decrement_profile_connections(self, m3u_profile_id: int):
"""Decrement profile connection count.
@ -1005,6 +1066,16 @@ class MultiWorkerVODConnectionManager:
# to prevent cleanup race conditions with GeneratorExit.
if not existing_state:
redis_connection.increment_active_streams()
threading.Thread(
target=self._send_vod_event,
args=(
'vod_started', client_id, content_name,
content_uuid, client_ip,
str(user.id) if user else '0',
user.username if user else None
),
daemon=True
).start()
else:
logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path")
@ -1061,12 +1132,18 @@ class MultiWorkerVODConnectionManager:
def delayed_cleanup():
time.sleep(1) # Wait 1 second
# Smart cleanup: check active streams and ownership
# Re-check active_streams: a seeking/reconnecting client may
# have incremented it within the settle window.
if not redis_connection.has_active_streams():
self._send_vod_event(
'vod_stopped', client_id, content_name,
content_uuid, client_ip,
str(user.id) if user else '0',
user.username if user else None
)
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion")
# 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)
cleanup_thread.daemon = True
cleanup_thread.start()
@ -1090,12 +1167,18 @@ class MultiWorkerVODConnectionManager:
def delayed_cleanup():
time.sleep(1) # Wait 1 second
# Smart cleanup: check active streams and ownership
# Re-check active_streams: a seeking/reconnecting client may
# have incremented it within the settle window.
if not redis_connection.has_active_streams():
self._send_vod_event(
'vod_stopped', client_id, content_name,
content_uuid, client_ip,
str(user.id) if user else '0',
user.username if user else None
)
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect")
# 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)
cleanup_thread.daemon = True
cleanup_thread.start()
@ -1142,7 +1225,6 @@ class MultiWorkerVODConnectionManager:
# 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)
cleanup_thread.daemon = True
cleanup_thread.start()

View file

@ -684,17 +684,13 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None
logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True)
return HttpResponse(f"HEAD error: {str(e)}", status=500)
@api_view(["GET"])
@permission_classes([IsAdmin])
def vod_stats(request):
"""Get current VOD connection statistics"""
def build_vod_stats_data(redis_client):
"""
Build the full VOD stats payload (with DB lookups) from Redis connection data.
Returns a dict: {'vod_connections': [...], 'total_connections': N, 'timestamp': T}
Used by both the vod_stats API view and the WebSocket push in _do_vod_stats_update.
"""
try:
connection_manager = MultiWorkerVODConnectionManager.get_instance()
redis_client = connection_manager.redis_client
if not redis_client:
return JsonResponse({'error': 'Redis not available'}, status=500)
# Get all VOD persistent connections (consolidated data)
pattern = "vod_persistent_connection:*"
cursor = 0
@ -936,11 +932,29 @@ def vod_stats(request):
content_stats[content_key]['connection_count'] += 1
content_stats[content_key]['connections'].append(conn)
return JsonResponse({
return {
'vod_connections': list(content_stats.values()),
'total_connections': len(connections),
'timestamp': current_time
})
}
except Exception as e:
logger.error(f"Error building VOD stats: {e}")
return {'vod_connections': [], 'total_connections': 0, 'timestamp': time.time()}
@api_view(["GET"])
@permission_classes([IsAdmin])
def vod_stats(request):
"""Get current VOD connection statistics"""
try:
connection_manager = MultiWorkerVODConnectionManager.get_instance()
redis_client = connection_manager.redis_client
if not redis_client:
return JsonResponse({'error': 'Redis not available'}, status=500)
return JsonResponse(build_vod_stats_data(redis_client))
except Exception as e:
logger.error(f"Error getting VOD stats: {e}")

View file

@ -25,6 +25,14 @@ class CoreConfig(AppConfig):
import core.signals
from dispatcharr.app_initialization import should_skip_initialization
# Force UTC0 on every new DB connection.
from django.db.backends.signals import connection_created
def _force_utc0(sender, connection, **kwargs):
connection.cursor().execute("SET TIME ZONE 'UTC0'")
connection_created.connect(_force_utc0, dispatch_uid='force_db_utc0')
# Sync developer notifications and check for version updates on startup
# Only run in the main process (not in management commands, migrations, or workers)
if should_skip_initialization():

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-23 22:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '022_default_user_limit_settings'),
]
operations = [
migrations.AlterField(
model_name='systemevent',
name='event_type',
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], db_index=True, max_length=50),
),
]

View file

@ -399,6 +399,8 @@ class SystemEvent(models.Model):
('logout', 'User Logged Out'),
('m3u_blocked', 'M3U Download Blocked'),
('epg_blocked', 'EPG Download Blocked'),
('vod_start', 'VOD Started'),
('vod_stop', 'VOD Stopped'),
]
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)

View file

@ -4,6 +4,8 @@ from celery import Celery
import logging
from celery.signals import task_postrun, worker_ready
logger = logging.getLogger(__name__)
# Initialize with defaults before Django settings are loaded
DEFAULT_LOG_LEVEL = 'DEBUG'
@ -49,6 +51,11 @@ app.conf.update(
worker_task_log_format='%(asctime)s %(levelname)s %(task_name)s: %(message)s',
)
# Route long-running DVR recordings to a dedicated `dvr` queue consumed by a thread-pool worker.
app.conf.task_routes = {
'apps.channels.tasks.run_recording': {'queue': 'dvr'},
}
# Add memory cleanup after task completion
@task_postrun.connect # Use the imported signal
def cleanup_task_memory(**kwargs):
@ -153,9 +160,44 @@ def setup_celery_logging(**kwargs):
@worker_ready.connect
def on_worker_ready(**kwargs):
"""Tasks to run once the worker is fully connected and ready."""
from apps.channels.tasks import recover_recordings_on_startup
recover_recordings_on_startup.delay()
"""Tasks to run once the worker is fully connected and ready.
from core.tasks import check_for_version_update
check_for_version_update.delay()
NOTE: when multiple Celery worker processes share a container (e.g. the
`dvr` and `default` workers in the AIO image), this signal fires once per
worker. We must guard the one-shot startup tasks with a short-lived
Redis NX lock so they are dispatched exactly once per cluster startup,
otherwise `recover_recordings_on_startup` runs twice and re-dispatches
`run_recording` for any in-flight recording, producing duplicate ffmpeg
processes that race on the same HLS output directory.
"""
try:
from core.utils import RedisClient
redis_client = RedisClient.get_client()
except Exception:
redis_client = None
def _claim(lock_key, ttl_seconds=300):
"""Return True if this worker should run the one-shot dispatch."""
if redis_client is None:
# Redis unavailable: best-effort, allow dispatch (the in-task
# lock inside the recovery task itself is the second line of
# defense if Redis comes back online before the task runs).
return True
try:
claimed = bool(redis_client.set(lock_key, "1", ex=ttl_seconds, nx=True))
if not claimed:
logger.debug(
f"on_worker_ready: dispatch lock {lock_key!r} held by "
f"another worker, skipping one-shot dispatch."
)
return claimed
except Exception:
return True
if _claim("dvr:recover_dispatch_lock"):
from apps.channels.tasks import recover_recordings_on_startup
recover_recordings_on_startup.delay()
if _claim("core:version_check_dispatch_lock"):
from core.tasks import check_for_version_update
check_for_version_update.delay()

View file

@ -118,8 +118,12 @@ services:
- DISPATCHARR_ENV=modular
# Internal Service Communication
# Must match the web service port for DVR recording and internal API calls
# Celery uses these to reach the web container for DVR recording.
# DISPATCHARR_PORT must match the port exposed by the web service.
# DISPATCHARR_WEB_HOST defaults to "web" (the service name above).
# Only set DISPATCHARR_WEB_HOST if you rename the web service in this file.
- DISPATCHARR_PORT=9191
#- DISPATCHARR_WEB_HOST=web
# PostgreSQL — must match web service settings
- POSTGRES_HOST=db

View file

@ -67,4 +67,8 @@ NICE_LEVEL="${CELERY_NICE_LEVEL:-5}"
if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then
echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability"
fi
nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1
# DVR worker: thread pool for the long-running, I/O-bound run_recording task.
nice -n "$NICE_LEVEL" celery -A dispatcharr worker -Q dvr -n dvr@%h --pool=threads --concurrency=20 -l info &
# Default prefork worker: every queue except `dvr`.
nice -n "$NICE_LEVEL" celery -A dispatcharr worker -Q celery -n default@%h --autoscale=6,1 -l info

View file

@ -7,9 +7,10 @@ exec-before = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services with configurable nice level (default: 5 for low priority)
; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1
; Default prefork worker: every queue except `dvr`.
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q celery -n default@%%h --autoscale=6,1
; DVR worker: thread pool for the long-running, I/O-bound run_recording task.
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q dvr -n dvr@%%h --pool=threads --concurrency=20
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
attach-daemon = cd /app/frontend && npm run dev

View file

@ -9,9 +9,10 @@ exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server --protected-mode no
; Then start other services with configurable nice level (default: 5 for low priority)
; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1
; Default prefork worker: every queue except `dvr`.
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q celery -n default@%%h --autoscale=6,1
; DVR worker: thread pool for the long-running, I/O-bound run_recording task.
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q dvr -n dvr@%%h --pool=threads --concurrency=20
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
attach-daemon = cd /app/frontend && npm run dev

View file

@ -9,9 +9,10 @@ exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services with configurable nice level (default: 5 for low priority)
; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1
; Default prefork worker: every queue except `dvr`.
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q celery -n default@%%h --autoscale=6,1
; DVR worker: thread pool for the long-running, I/O-bound run_recording task.
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q dvr -n dvr@%%h --pool=threads --concurrency=20
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application

View file

@ -2518,9 +2518,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -4387,9 +4387,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{

View file

@ -16,6 +16,7 @@ import { Box, Button, Stack, Alert, Group } from '@mantine/core';
import API from './api';
import useSettingsStore from './store/settings';
import useAuthStore from './store/auth';
import useUsersStore from './store/users';
export const WebsocketContext = createContext([false, () => {}, null]);
@ -211,13 +212,18 @@ export const WebsocketProvider = ({ children }) => {
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',
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: reasonMap[parsedEvent.data.reason] || parsedEvent.data.reason || '',
message:
reasonMap[parsedEvent.data.reason] ||
parsedEvent.data.reason ||
'',
color: 'teal',
loading: false,
autoClose: 3000,
@ -311,6 +317,36 @@ export const WebsocketProvider = ({ children }) => {
setChannelStats(JSON.parse(parsedEvent.data.stats));
break;
case 'vod_stats':
setVodStats(JSON.parse(parsedEvent.data.stats));
break;
case 'vod_started':
case 'vod_stopped': {
const { content_name, client_ip, user_id } = parsedEvent.data;
const isStart = parsedEvent.data.type === 'vod_started';
let identity = client_ip || 'unknown';
if (user_id && user_id !== '0') {
const allUsers = useUsersStore.getState().users;
const matched = allUsers.find(
(u) => String(u.id) === String(user_id)
);
if (matched?.username)
identity = `${matched.username} (${client_ip})`;
}
notifications.show({
title: isStart ? 'VOD started' : 'VOD ended',
message: (
<>
<div>{content_name}</div>
<div style={{ marginTop: 2 }}>{identity}</div>
</>
),
color: 'blue.5',
});
break;
}
case 'epg_channels':
notifications.show({
message: 'EPG channels updated!',
@ -559,7 +595,9 @@ export const WebsocketProvider = ({ children }) => {
case 'recording_cancelled':
notifications.show({
title: parsedEvent.data.was_in_progress ? 'Recording cancelled' : 'Recording deleted',
title: parsedEvent.data.was_in_progress
? 'Recording cancelled'
: 'Recording deleted',
message: parsedEvent.data.was_in_progress
? 'Recording cancelled and content removed.'
: 'Recording deleted.',
@ -568,7 +606,9 @@ export const WebsocketProvider = ({ children }) => {
// 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);
useChannelsStore
.getState()
.removeRecording(parsedEvent.data.recording_id);
} else {
scheduleRecordingFetch();
}
@ -990,6 +1030,7 @@ export const WebsocketProvider = ({ children }) => {
}, [connectWebSocket, clearReconnectTimer, isAuthenticated, accessToken]);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const setVodStats = useChannelsStore((s) => s.setVodStats);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview);

View file

@ -637,6 +637,77 @@ export default class API {
}
}
/**
* PATCHes only the stream order for a channel
* without triggering requeryStreams or requeryChannels. The caller is
* responsible for optimistic UI updates.
*/
static async reorderChannelStreams(channelId, streamIds) {
try {
await request(`${host}/api/channels/channels/${channelId}/`, {
method: 'PATCH',
body: { id: channelId, streams: streamIds },
});
// Update the channelsTable store in-place with the new stream order
const store = useChannelsTableStore.getState();
const channel = store.channels.find((c) => c.id === channelId);
if (channel) {
// Reorder the existing stream objects to match streamIds
const streamMap = new Map(channel.streams.map((s) => [s.id, s]));
const reorderedStreams = streamIds
.map((id) => streamMap.get(id))
.filter(Boolean);
store.updateChannel({ ...channel, streams: reorderedStreams });
}
} catch (e) {
errorNotification('Failed to reorder streams', e);
// On failure, requery to restore correct state
await API.requeryChannels();
}
}
/**
* PATCHes the channel with the
* combined stream list and updates the channelsTable store in-place
* using the stream objects the caller already has. Skips requeryStreams
* (stream data doesn't change) and requeryChannels (we build the
* result locally).
*
* @param {number} channelId
* @param {Array} existingStreams - current channel.streams (full objects)
* @param {Array} newStreams - stream objects to append
*/
static async addStreamsToChannel(channelId, existingStreams, newStreams) {
try {
const existing = existingStreams || [];
// Deduplicate by ID, preserving order (existing first, new appended)
const seen = new Set(existing.map((s) => s.id));
const merged = [...existing];
for (const s of newStreams) {
if (!seen.has(s.id)) {
seen.add(s.id);
merged.push(s);
}
}
await request(`${host}/api/channels/channels/${channelId}/`, {
method: 'PATCH',
body: { id: channelId, streams: merged.map((s) => s.id) },
});
// Update the channelsTable store in-place with the merged streams
const store = useChannelsTableStore.getState();
const channel = store.channels.find((c) => c.id === channelId);
if (channel) {
store.updateChannel({ ...channel, streams: merged });
}
} catch (e) {
errorNotification('Failed to add streams to channel', e);
// On failure, requery to restore correct state
await API.requeryChannels();
}
}
static async updateChannels(ids, values) {
const body = [];
for (const id of ids) {
@ -869,7 +940,6 @@ export default class API {
useChannelsStore.getState().addChannel(response);
}
await API.requeryStreams();
return response;
} catch (e) {
errorNotification('Failed to create channel', e);
@ -937,6 +1007,27 @@ export default class API {
}
}
/**
* Fetches a stats delta for a channel's streams. Errors are swallowed
* since this is a background refresh.
*/
static async getChannelStreamStats(channelId, since, ids) {
try {
const params = new URLSearchParams();
if (since) params.set('since', since);
if (Array.isArray(ids) && ids.length > 0) {
params.set('ids', ids.join(','));
}
const qs = params.toString();
const response = await request(
`${host}/api/channels/channels/${channelId}/streams/stats/${qs ? `?${qs}` : ''}`
);
return Array.isArray(response) ? response : [];
} catch (e) {
return [];
}
}
static async queryStreams(params) {
try {
const response = await request(

View file

@ -4,6 +4,7 @@ import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import useAuthStore from '../store/auth';
import mpegts from 'mpegts.js';
import Hls from 'hls.js';
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
import {
applyConstraints,
@ -118,7 +119,6 @@ export default function FloatingVideo() {
const contentType = useVideoStore((s) => s.contentType);
const metadata = useVideoStore((s) => s.metadata);
const hideVideo = useVideoStore((s) => s.hideVideo);
const accessToken = useAuthStore((s) => s.accessToken);
const videoRef = useRef(null);
const playerRef = useRef(null);
@ -241,10 +241,46 @@ export default function FloatingVideo() {
const { volume: savedVolume, muted: savedMuted } = getPlayerPrefs();
if (typeof savedVolume === 'number') video.volume = savedVolume;
if (typeof savedMuted === 'boolean') video.muted = savedMuted;
// Always start playback from the beginning of the seekable range.
let hasSeekedToStart = false;
const seekToStart = () => {
if (hasSeekedToStart) return;
try {
let target = 0;
if (video.seekable && video.seekable.length > 0) {
target = video.seekable.start(0);
}
// Only apply if we're not already at/near the start. Avoid
// setting currentTime when the video has no duration yet.
if (
Number.isFinite(target) &&
Math.abs((video.currentTime || 0) - target) > 0.25
) {
video.currentTime = target;
}
hasSeekedToStart = true;
} catch {
// ignore
}
};
const handleLoadStart = () => setIsLoading(true);
const handleLoadedData = () => setIsLoading(false);
const handleLoadedMetadata = () => {
seekToStart();
};
const handleLoadedData = () => {
setIsLoading(false);
// hls.js applies its `startPosition` after MEDIA_ATTACHED, which can
// run later than `loadedmetadata`. Re-seek here as a safety net so a
// hls.js live playlist doesn't snap to the live edge after our first
// seek attempt happened against an empty seekable range.
seekToStart();
};
const handleCanPlay = () => {
setIsLoading(false);
// Final fallback for the Safari native-HLS path where seekable.start(0)
// is sometimes only valid by the time `canplay` fires.
seekToStart();
// Auto-play for VOD content
video.play().catch((e) => {
console.log('Auto-play prevented:', e);
@ -274,23 +310,114 @@ export default function FloatingVideo() {
// Add event listeners
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('error', handleError);
video.addEventListener('progress', handleProgress);
// Set the source
video.src = streamUrl;
video.load();
// HLS handling: in-progress recordings expose .m3u8 playlists (and ended
// recordings whose MKV concat hasn't completed yet still serve via /hls/).
// Native <video src="*.m3u8"> only works on Safari/iOS, and even there it
// cannot follow a growing playlist with a useful seekable window. Use
// hls.js when supported so the user gets a full DVR/timeshift window
// across all browsers. The HLS playlist uses #EXT-X-PLAYLIST-TYPE=EVENT-
// style growth (omit_endlist + hls_list_size 0), so hls.js will treat the
// entire recorded duration as the seekable range while the recording is
// still in progress and as a complete VOD once it ends.
const isHls =
typeof streamUrl === 'string' &&
(streamUrl.includes('.m3u8') ||
streamUrl.includes('/hls/index') ||
streamUrl.includes('application/vnd.apple.mpegurl'));
let hls = null;
if (isHls && Hls.isSupported()) {
hls = new Hls({
// Open at the very beginning of the recording rather than the live
// edge. Without this, an in-progress recording would start at "now"
// and hide everything already recorded. hls.js applies this AFTER
// MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is
// also kept as a safety net for the Safari native-HLS path and for
// edge cases where this initial-position logic loses to the user's
// first interaction.
startPosition: 0,
// Allow seeking back to the start of the recording, regardless of
// current playhead position. Recordings can be hours long and the
// user may want to scrub anywhere; we explicitly disable buffer
// eviction by setting a very large back-buffer length.
backBufferLength: 90 * 60, // 90 minutes
maxBufferLength: 60,
maxMaxBufferLength: 600,
// For an in-progress recording, hls.js refreshes the playlist on
// its target-duration cadence; let it follow the live edge but keep
// the full DVR window seekable.
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10,
enableWorker: true,
lowLatencyMode: false,
// Inject the JWT into every playlist + segment XHR. Read the token
// from the auth store at request time rather than capturing the
// closure value at hls.js init, so a refreshed access token mid-
// playback is picked up on the next segment fetch.
xhrSetup: (xhr) => {
const token = useAuthStore.getState().accessToken;
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
},
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) {
// eslint-disable-next-line no-console
console.error('HLS fatal error:', data.type, data.details);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
try {
hls.startLoad();
} catch {
// ignore
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
try {
hls.recoverMediaError();
} catch {
// ignore
}
} else {
setLoadError(`HLS playback error: ${data.details || data.type}`);
}
}
});
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(streamUrl);
});
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari path: native HLS support, including seekable DVR windows.
video.src = streamUrl;
video.load();
} else {
// Plain progressive file (MKV/MP4): native HTML5.
video.src = streamUrl;
video.load();
}
// Store cleanup function
playerRef.current = {
destroy: () => {
video.removeEventListener('loadstart', handleLoadStart);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('loadeddata', handleLoadedData);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('error', handleError);
video.removeEventListener('progress', handleProgress);
if (hls) {
try {
hls.destroy();
} catch {
// ignore
}
}
video.removeAttribute('src');
video.load();
},
@ -321,6 +448,14 @@ export default function FloatingVideo() {
? `${window.location.origin}${streamUrl}`
: streamUrl;
// Read the JWT from the auth store at player-creation time rather than
// relying on the closure-captured `accessToken` value. mpegts.js has
// no per-request setup hook (unlike hls.js's xhrSetup), so this header
// is baked into the IO loader for the life of the player; we just want
// to be sure we use the freshest token available at the moment of
// connection rather than whatever React-render snapshot we closed over.
const liveAccessToken = useAuthStore.getState().accessToken;
const player = mpegts.createPlayer(
{
type: 'mpegts',
@ -337,8 +472,8 @@ export default function FloatingVideo() {
autoCleanupMaxBackwardDuration: 120,
autoCleanupMinBackwardDuration: 60,
reuseRedirectedURL: true,
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
headers: liveAccessToken
? { Authorization: `Bearer ${liveAccessToken}` }
: undefined,
}
);

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import {
ActionIcon,
Alert,
@ -12,8 +12,17 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, Ban, Check, Download, RefreshCw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import {
AlertTriangle,
Ban,
Download,
RefreshCw,
ShieldAlert,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { compareVersions } from './pluginUtils.js';
import { formatKB } from '../utils/networkUtils.js';
export const GitHubIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
@ -68,13 +77,19 @@ const PluginDetailPanel = ({
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading plugin details</Text>
<Text size="sm" c="dimmed">
Loading plugin details
</Text>
</Stack>
);
}
if (!detail?.manifest) {
return <Text size="sm" c="dimmed">Failed to load plugin details.</Text>;
return (
<Text size="sm" c="dimmed">
Failed to load plugin details.
</Text>
);
}
const manifest = detail.manifest;
@ -82,19 +97,28 @@ const PluginDetailPanel = ({
(v) => v.version === selectedVersion
);
const isSelSame = installedVersion && selectedVersion &&
const isSelSame =
installedVersion &&
selectedVersion &&
compareVersions(selectedVersion, installedVersion) === 0;
const isSelDowngrade = installedVersion && selectedVersion &&
const isSelDowngrade =
installedVersion &&
selectedVersion &&
compareVersions(selectedVersion, installedVersion) < 0;
const isInstalled = !!installedVersion;
const selMeetsMin = !selectedVersionData?.min_dispatcharr_version ||
compareVersions(appVersion, selectedVersionData.min_dispatcharr_version) >= 0;
const selMeetsMax = !selectedVersionData?.max_dispatcharr_version ||
compareVersions(appVersion, selectedVersionData.max_dispatcharr_version) <= 0;
const selMeetsMin =
!selectedVersionData?.min_dispatcharr_version ||
compareVersions(appVersion, selectedVersionData.min_dispatcharr_version) >=
0;
const selMeetsMax =
!selectedVersionData?.max_dispatcharr_version ||
compareVersions(appVersion, selectedVersionData.max_dispatcharr_version) <=
0;
const selCompatible = selMeetsMin && selMeetsMax;
const isOverwrite = installStatus === 'unmanaged' || installStatus === 'different_repo';
const isOverwrite =
installStatus === 'unmanaged' || installStatus === 'different_repo';
const handleInstallClick = () => {
if (isSelSame && onUninstall) {
@ -122,9 +146,10 @@ const PluginDetailPanel = ({
color: 'orange',
icon: installing ? <Loader size={14} /> : <Download size={14} />,
variant: 'filled',
tooltip: installStatus === 'unmanaged'
? 'Installed manually installing will take over management'
: `Managed by ${installedSourceRepoName || 'another repo'} installing will transfer management to this repo`,
tooltip:
installStatus === 'unmanaged'
? 'Installed manually installing will take over management'
: `Managed by ${installedSourceRepoName || 'another repo'} installing will transfer management to this repo`,
};
}
if (isSelSame) {
@ -168,13 +193,13 @@ const PluginDetailPanel = ({
};
const btnProps = getButtonProps();
const btnDisabled = (isSelSame ? uninstalling : (!selCompatible || installing || !selectedVersionData?.url));
const btnDisabled = isSelSame
? uninstalling
: !selCompatible || installing || !selectedVersionData?.url;
return (
<Stack gap="md">
{manifest.description && (
<Text size="sm">{manifest.description}</Text>
)}
{manifest.description && <Text size="sm">{manifest.description}</Text>}
<Group gap="xs" wrap="wrap">
{manifest.author && (
@ -197,8 +222,8 @@ const PluginDetailPanel = ({
{manifest.license}
</Badge>
)}
{detail.signature_verified != null && (
detail.signature_verified ? (
{detail.signature_verified != null &&
(detail.signature_verified ? (
<Badge
size="sm"
variant="default"
@ -217,8 +242,7 @@ const PluginDetailPanel = ({
Unverified
</Badge>
</Tooltip>
)
)}
))}
{manifest.repo_url && (
<Tooltip label="Source Repository">
<ActionIcon
@ -234,25 +258,36 @@ const PluginDetailPanel = ({
</ActionIcon>
</Tooltip>
)}
{manifest.discord_thread && (() => {
const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(manifest.discord_thread);
return (
<Tooltip label="Discord Discussion">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
component="a"
href={isDiscordChannel
? manifest.discord_thread.replace('https://', 'discord://')
: manifest.discord_thread}
{...(!isDiscordChannel && { target: '_blank', rel: 'noopener noreferrer' })}
>
<DiscordIcon size={16} />
</ActionIcon>
</Tooltip>
);
})()}
{manifest.discord_thread &&
(() => {
const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(
manifest.discord_thread
);
return (
<Tooltip label="Discord Discussion">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
component="a"
href={
isDiscordChannel
? manifest.discord_thread.replace(
'https://',
'discord://'
)
: manifest.discord_thread
}
{...(!isDiscordChannel && {
target: '_blank',
rel: 'noopener noreferrer',
})}
>
<DiscordIcon size={16} />
</ActionIcon>
</Tooltip>
);
})()}
</Group>
{manifest.deprecated && (
@ -262,61 +297,77 @@ const PluginDetailPanel = ({
variant="light"
title="Deprecated Plugin"
>
This plugin has been marked as deprecated by its maintainer. It may no longer receive
updates or fixes, and could stop working with future versions of Dispatcharr.
Consider looking for an alternative.
This plugin has been marked as deprecated by its maintainer. It may no
longer receive updates or fixes, and could stop working with future
versions of Dispatcharr. Consider looking for an alternative.
</Alert>
)}
{manifest.versions?.length > 0 && (() => {
const installedMissing = installedVersion &&
!manifest.versions.some((v) => compareVersions(v.version, installedVersion) === 0);
const buildLabel = (v) =>
`v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`;
{manifest.versions?.length > 0 &&
(() => {
const installedMissing =
installedVersion &&
!manifest.versions.some(
(v) => compareVersions(v.version, installedVersion) === 0
);
const buildLabel = (v) =>
`v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`;
let versions = [...manifest.versions];
if (installedVersionIsPrerelease) {
const prereleases = versions.filter((v) => v.prerelease);
const stable = versions.filter((v) => !v.prerelease);
versions = [...prereleases, ...stable];
}
const versionItems = versions.map((v) => ({
value: v.version,
label: buildLabel(v),
disabled: false,
}));
if (installedMissing) {
const ghostItem = {
value: installedVersion,
label: `v${installedVersion} (installed)`,
disabled: true,
};
// Insert in sorted position (newest first, matching manifest order convention)
const idx = versionItems.findIndex(
(item) => compareVersions(installedVersion, item.value) > 0
);
if (idx === -1) {
versionItems.push(ghostItem);
} else {
versionItems.splice(idx, 0, ghostItem);
let versions = [...manifest.versions];
if (installedVersionIsPrerelease) {
const prereleases = versions.filter((v) => v.prerelease);
const stable = versions.filter((v) => !v.prerelease);
versions = [...prereleases, ...stable];
}
}
return (
<>
<Group gap="xs" align="flex-end">
<Select
label="Version"
size="xs"
allowDeselect={false}
value={selectedVersion}
onChange={onVersionChange}
data={versionItems}
style={{ maxWidth: 240 }}
/>
<Group gap="xs" align="center">
{btnProps.tooltip ? (
<Tooltip label={btnProps.tooltip}>
const versionItems = versions.map((v) => ({
value: v.version,
label: buildLabel(v),
disabled: false,
}));
if (installedMissing) {
const ghostItem = {
value: installedVersion,
label: `v${installedVersion} (installed)`,
disabled: true,
};
// Insert in sorted position (newest first, matching manifest order convention)
const idx = versionItems.findIndex(
(item) => compareVersions(installedVersion, item.value) > 0
);
if (idx === -1) {
versionItems.push(ghostItem);
} else {
versionItems.splice(idx, 0, ghostItem);
}
}
return (
<>
<Group gap="xs" align="flex-end">
<Select
label="Version"
size="xs"
allowDeselect={false}
value={selectedVersion}
onChange={onVersionChange}
data={versionItems}
style={{ maxWidth: 240 }}
/>
<Group gap="xs" align="center">
{btnProps.tooltip ? (
<Tooltip label={btnProps.tooltip}>
<Button
size="xs"
variant={btnProps.variant}
color={btnProps.color}
leftSection={btnProps.icon}
disabled={btnDisabled}
onClick={handleInstallClick}
>
{btnProps.label}
</Button>
</Tooltip>
) : (
<Button
size="xs"
variant={btnProps.variant}
@ -327,102 +378,140 @@ const PluginDetailPanel = ({
>
{btnProps.label}
</Button>
</Tooltip>
) : (
<Button
size="xs"
variant={btnProps.variant}
color={btnProps.color}
leftSection={btnProps.icon}
disabled={btnDisabled}
onClick={handleInstallClick}
>
{btnProps.label}
</Button>
)}
{!selCompatible && selectedVersionData && !isSelSame && (() => {
const parts = [];
if (!selMeetsMin) parts.push(`${selectedVersionData.min_dispatcharr_version} or newer`);
if (!selMeetsMax) parts.push(`${selectedVersionData.max_dispatcharr_version} or older`);
const label = !selMeetsMin
? `Min ${selectedVersionData.min_dispatcharr_version}`
: `Max ${selectedVersionData.max_dispatcharr_version}`;
return (
<Tooltip label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow">{label}</Text>
</Group>
</Tooltip>
);
})()}
</Group>
</Group>
{selectedVersionData && (
<Table fontSize="xs" striped highlightOnHover style={{ tableLayout: 'auto' }}>
<Table.Tbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Built</Table.Td>
<Table.Td>{new Date(selectedVersionData.build_timestamp).toLocaleString()}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Min Version</Table.Td>
<Table.Td>{selectedVersionData.min_dispatcharr_version}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Max Version</Table.Td>
<Table.Td>{selectedVersionData.max_dispatcharr_version}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Commit</Table.Td>
<Table.Td>
{manifest.registry_url ? (
<Text
size="xs"
component="a"
href={`${manifest.registry_url}/commit/${selectedVersionData.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
c="blue"
)}
{!selCompatible &&
selectedVersionData &&
!isSelSame &&
(() => {
const parts = [];
if (!selMeetsMin)
parts.push(
`${selectedVersionData.min_dispatcharr_version} or newer`
);
if (!selMeetsMax)
parts.push(
`${selectedVersionData.max_dispatcharr_version} or older`
);
const label = !selMeetsMin
? `Min ${selectedVersionData.min_dispatcharr_version}`
: `Max ${selectedVersionData.max_dispatcharr_version}`;
return (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
>
{selectedVersionData.commit_sha_short}
</Text>
) : (
selectedVersionData.commit_sha_short
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
size={14}
color="var(--mantine-color-yellow-6)"
/>
<Text size="xs" c="yellow">
{label}
</Text>
</Group>
</Tooltip>
);
})()}
</Group>
</Group>
{selectedVersionData && (
<Table
fontSize="xs"
striped
highlightOnHover
style={{ tableLayout: 'auto' }}
>
<Table.Tbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Built
</Table.Td>
<Table.Td>
{new Date(
selectedVersionData.build_timestamp
).toLocaleString()}
</Table.Td>
</Table.Tr>
)}
{Number.isFinite(selectedVersionData.size) &&
selectedVersionData.size > 0 && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
File Size
</Table.Td>
<Table.Td>
{formatKB(selectedVersionData.size)}
</Table.Td>
</Table.Tr>
)}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Download</Table.Td>
<Table.Td>
<Text
size="xs"
component="a"
href={selectedVersionData.url}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
</>
);
})()}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Min Version
</Table.Td>
<Table.Td>
{selectedVersionData.min_dispatcharr_version}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Max Version
</Table.Td>
<Table.Td>
{selectedVersionData.max_dispatcharr_version}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Commit
</Table.Td>
<Table.Td>
{manifest.registry_url ? (
<Text
size="xs"
component="a"
href={`${manifest.registry_url}/commit/${selectedVersionData.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.commit_sha_short}
</Text>
) : (
selectedVersionData.commit_sha_short
)}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Download
</Table.Td>
<Table.Td>
<Text
size="xs"
component="a"
href={selectedVersionData.url}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
</>
);
})()}
</Stack>
);
};

View file

@ -0,0 +1,120 @@
import React from 'react';
import { Box, Text } from '@mantine/core';
import { AlertTriangle, Info, OctagonAlert } from 'lucide-react';
import dispatcharrLogo from '../images/logo.png';
export const PluginSecurityWarning = ({ children }) => (
<Box
style={{
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--mantine-radius-sm)',
padding: '10px 14px',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
}}
>
<Box style={{ color: '#ef4444', flexShrink: 0, paddingTop: 1 }}>
<OctagonAlert size={16} />
</Box>
<Text size="xs" style={{ color: '#f87171' }}>
{children}
</Text>
</Box>
);
export const PluginSupportDisclaimer = () => (
<Box
style={{
background: 'rgba(20, 145, 126, 0.1)',
border: '1px solid rgba(20, 145, 126, 0.35)',
borderRadius: 'var(--mantine-radius-sm)',
padding: '10px 14px',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
}}
>
<Box style={{ flexShrink: 0, paddingTop: 1 }}>
<img
src={dispatcharrLogo}
alt="Dispatcharr"
width={16}
height={16}
draggable={false}
style={{ display: 'block', objectFit: 'contain' }}
/>
</Box>
<Text size="xs" style={{ color: '#4db8a8' }}>
Dispatcharr community support cannot assist with third-party plugin
issues. For help, use the plugin&apos;s Discord thread or submit an issue
on the plugin&apos;s repository.
</Text>
</Box>
);
export const PluginDowngradeWarning = ({ children }) => (
<Box
style={{
background: 'rgba(249, 115, 22, 0.1)',
border: '1px solid rgba(249, 115, 22, 0.35)',
borderRadius: 'var(--mantine-radius-sm)',
padding: '10px 14px',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
}}
>
<Box style={{ color: '#f97316', flexShrink: 0, paddingTop: 1 }}>
<AlertTriangle size={16} />
</Box>
<Text size="xs" style={{ color: '#fb923c' }}>
{children}
</Text>
</Box>
);
export const PluginInfoNote = ({ children }) => (
<Box
style={{
background: 'rgba(148, 163, 184, 0.08)',
border: '1px solid rgba(148, 163, 184, 0.25)',
borderRadius: 'var(--mantine-radius-sm)',
padding: '10px 14px',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
}}
>
<Box style={{ color: '#94a3b8', flexShrink: 0, paddingTop: 1 }}>
<Info size={16} />
</Box>
<Text size="xs" style={{ color: '#cbd5e1' }}>
{children}
</Text>
</Box>
);
export const PluginRestartWarning = () => (
<Box
style={{
background: 'rgba(234, 179, 8, 0.1)',
border: '1px solid rgba(234, 179, 8, 0.35)',
borderRadius: 'var(--mantine-radius-sm)',
padding: '10px 14px',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
}}
>
<Box style={{ color: '#eab308', flexShrink: 0, paddingTop: 1 }}>
<AlertTriangle size={16} />
</Box>
<Text size="xs" style={{ color: '#ca8a04' }}>
Importing a plugin may briefly restart the backend (you might see a
temporary disconnect). Please wait a few seconds and the app will
reconnect automatically.
</Text>
</Box>
);

View file

@ -53,6 +53,10 @@ const getEventIcon = (eventType) => {
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'vod_start':
return <CirclePlay size={16} />;
case 'vod_stop':
return <SquareX size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
@ -83,6 +87,7 @@ const getEventColor = (eventType) => {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'vod_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
@ -90,6 +95,7 @@ const getEventColor = (eventType) => {
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'vod_stop':
case 'logout':
return 'gray';
case 'channel_buffering':
@ -116,7 +122,7 @@ const getEventColor = (eventType) => {
const getSystemEvents = (eventsLimit, offset) => {
return API.getSystemEvents(eventsLimit, offset);
}
};
const Event = ({ event }) => {
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
@ -147,16 +153,14 @@ const Event = ({ event }) => {
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(([key]) =>
!['stream_url', 'new_url'].includes(key))
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
{event.details && Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(([key]) => !['stream_url', 'new_url'].includes(key))
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
@ -321,9 +325,7 @@ const SystemEvents = () => {
No events recorded yet
</Text>
) : (
events.map((event) => (
<Event key={event.id} event={event} />
))
events.map((event) => <Event key={event.id} event={event} />)
)}
</Stack>
</>

View file

@ -14,12 +14,31 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, Ban, Check, Download, FlaskConical, Info, RefreshCw, RotateCcw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import {
AlertTriangle,
Ban,
Check,
Download,
FlaskConical,
Info,
RefreshCw,
RotateCcw,
ShieldAlert,
ShieldCheck,
Trash2,
} from 'lucide-react';
import {
PluginDowngradeWarning,
PluginInfoNote,
PluginSecurityWarning,
PluginSupportDisclaimer,
} from '../PluginWarnings.jsx';
import { useNavigate, useLocation } from 'react-router-dom';
import API from '../../api';
import { usePluginStore } from '../../store/plugins';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import { compareVersions } from '../pluginUtils.js';
import SizedInstallButton from '../theme/SizedInstallButton.jsx';
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
if (isOfficial) {
@ -27,15 +46,34 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: signatureVerified === false ? 'var(--mantine-color-red-9)' : '#14917E' }}
leftSection={signatureVerified != null ? (signatureVerified ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
style={{
backgroundColor:
signatureVerified === false
? 'var(--mantine-color-red-9)'
: '#14917E',
}}
leftSection={
signatureVerified != null ? (
signatureVerified ? (
<ShieldCheck size={10} />
) : (
<ShieldAlert size={10} />
)
) : undefined
}
>
Official Repo
</Badge>
);
return signatureVerified != null ? (
<Tooltip label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}>{badge}</Tooltip>
) : badge;
<Tooltip
label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}
>
{badge}
</Tooltip>
) : (
badge
);
}
if (!repoName) return null;
const badge = (
@ -43,58 +81,118 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
size="xs"
variant="filled"
color={signatureVerified === false ? 'red.9' : 'gray'}
leftSection={signatureVerified != null ? (signatureVerified ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
leftSection={
signatureVerified != null ? (
signatureVerified ? (
<ShieldCheck size={10} />
) : (
<ShieldAlert size={10} />
)
) : undefined
}
>
{repoName}
</Badge>
);
return signatureVerified != null ? (
<Tooltip label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}>{badge}</Tooltip>
) : badge;
<Tooltip
label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}
>
{badge}
</Tooltip>
) : (
badge
);
};
const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, installedSourceRepoName }) => {
const StatusBadge = ({
status,
deprecated,
isPrerelease,
isLatestDowngrade,
installedSourceRepoName,
}) => {
if (status === 'installed') {
const baseLabel = isPrerelease ? 'Prerelease' : 'Installed';
if (!deprecated) {
return (
<Badge size="xs" variant="light" color={isPrerelease ? 'violet' : 'green'} leftSection={isPrerelease ? <FlaskConical size={8} /> : <Check size={8} />}>
<Badge
size="xs"
variant="light"
color={isPrerelease ? 'violet' : 'green'}
leftSection={
isPrerelease ? <FlaskConical size={8} /> : <Check size={8} />
}
>
{baseLabel}
</Badge>
);
}
return (
<Tooltip label={`${isPrerelease ? 'Prerelease installed' : 'Installed'}, but this plugin has been deprecated by its maintainer`}>
<Badge size="xs" variant="light" color={isPrerelease ? 'red' : 'orange'} leftSection={<Ban size={8} />}>
<Tooltip
label={`${isPrerelease ? 'Prerelease installed' : 'Installed'}, but this plugin has been deprecated by its maintainer`}
>
<Badge
size="xs"
variant="light"
color={isPrerelease ? 'red' : 'orange'}
leftSection={<Ban size={8} />}
>
{baseLabel} · Deprecated
</Badge>
</Tooltip>
);
}
if (status === 'update_available') {
const baseLabel = isLatestDowngrade ? 'Newer Installed' : 'Update Available';
const baseLabel = isLatestDowngrade
? 'Newer Installed'
: 'Update Available';
if (!deprecated) {
return (
<Badge size="xs" variant="light" color={isLatestDowngrade ? 'orange' : 'yellow'} leftSection={isLatestDowngrade ? <AlertTriangle size={8} /> : <RefreshCw size={8} />}>
<Badge
size="xs"
variant="light"
color={isLatestDowngrade ? 'orange' : 'yellow'}
leftSection={
isLatestDowngrade ? (
<AlertTriangle size={8} />
) : (
<RefreshCw size={8} />
)
}
>
{baseLabel}
</Badge>
);
}
return (
<Tooltip label="Update available, but this plugin has been deprecated by its maintainer">
<Badge size="xs" variant="light" color="red" leftSection={<Ban size={8} />}>
<Badge
size="xs"
variant="light"
color="red"
leftSection={<Ban size={8} />}
>
{baseLabel} · Deprecated
</Badge>
</Tooltip>
);
}
if (status === 'unmanaged' || status === 'different_repo') {
const tooltip = status === 'unmanaged'
? (deprecated ? 'Installed manually (deprecated) - installing from this repo will take over management' : 'Installed manually - installing from this repo will take over management')
: `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`;
const tooltip =
status === 'unmanaged'
? deprecated
? 'Installed manually (deprecated) - installing from this repo will take over management'
: 'Installed manually - installing from this repo will take over management'
: `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`;
return (
<Tooltip label={tooltip}>
<Badge size="xs" variant="light" color={deprecated ? 'red' : 'orange'} leftSection={deprecated ? <Ban size={8} /> : <Check size={8} />}>
<Badge
size="xs"
variant="light"
color={deprecated ? 'red' : 'orange'}
leftSection={deprecated ? <Ban size={8} /> : <Check size={8} />}
>
{deprecated ? 'Installed · Deprecated' : 'Installed'}
</Badge>
</Tooltip>
@ -103,7 +201,12 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst
if (deprecated) {
return (
<Tooltip label="This plugin has been marked as deprecated by its maintainer">
<Badge size="xs" variant="light" color="red" leftSection={<Ban size={8} />}>
<Badge
size="xs"
variant="light"
color="red"
leftSection={<Ban size={8} />}
>
Deprecated
</Badge>
</Tooltip>
@ -112,9 +215,22 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst
return null;
};
const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDetail = false, onDetailClose, onInstalled, onUninstalled, onBeforeInstall }) => {
const meetsMinVersion = !plugin.min_dispatcharr_version || compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0;
const meetsMaxVersion = !plugin.max_dispatcharr_version || compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0;
const AvailablePluginCard = ({
plugin,
appVersion,
multiRepo = false,
autoOpenDetail = false,
onDetailClose,
onInstalled,
onUninstalled,
onBeforeInstall,
}) => {
const meetsMinVersion =
!plugin.min_dispatcharr_version ||
compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0;
const meetsMaxVersion =
!plugin.max_dispatcharr_version ||
compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0;
const meetsVersion = meetsMinVersion && meetsMaxVersion;
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState(null);
@ -133,14 +249,17 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
const [uninstallConfirmOpen, setUninstallConfirmOpen] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [deprecationWarnOpen, setDeprecationWarnOpen] = useState(false);
const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = useState(null);
const [pendingDeprecatedInstall, setPendingDeprecatedInstall] =
useState(null);
const installPlugin = usePluginStore((s) => s.installPlugin);
const navigate = useNavigate();
const { pathname } = useLocation();
const onMyPlugins = pathname === '/plugins';
const isLatestDowngrade = plugin.install_status === 'update_available' &&
plugin.latest_version && plugin.installed_version &&
const isLatestDowngrade =
plugin.install_status === 'update_available' &&
plugin.latest_version &&
plugin.installed_version &&
compareVersions(plugin.latest_version, plugin.installed_version) < 0;
const doInstall = (params) => {
@ -169,7 +288,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
const executeInstall = async (params) => {
const wasInstalled = plugin.installed;
const wasDowngrade = plugin.installed_version && params.version &&
const wasDowngrade =
plugin.installed_version &&
params.version &&
compareVersions(params.version, plugin.installed_version) < 0;
onBeforeInstall?.(plugin.slug);
setInstalling(true);
@ -177,7 +298,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
setInstalling(false);
setPendingInstall(null);
if (result?.success) {
setInstallAction(wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed');
setInstallAction(
wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed'
);
setInstalledKey(result.plugin?.key || params.slug);
setPluginIsDisabled(result.plugin?.enabled === false);
setEnableNow(false);
@ -229,15 +352,22 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
description: plugin.description,
author: plugin.author,
license: plugin.license,
versions: plugin.latest_version ? [{
version: plugin.latest_version,
url: plugin.latest_url,
checksum_sha256: plugin.latest_sha256,
min_dispatcharr_version: plugin.min_dispatcharr_version,
max_dispatcharr_version: plugin.max_dispatcharr_version,
build_timestamp: plugin.last_updated,
}] : [],
latest: plugin.latest_version ? { version: plugin.latest_version } : null,
versions: plugin.latest_version
? [
{
version: plugin.latest_version,
url: plugin.latest_url,
checksum_sha256: plugin.latest_sha256,
min_dispatcharr_version: plugin.min_dispatcharr_version,
max_dispatcharr_version: plugin.max_dispatcharr_version,
build_timestamp: plugin.last_updated,
size: plugin.latest_size,
},
]
: [],
latest: plugin.latest_version
? { version: plugin.latest_version }
: null,
},
signature_verified: plugin.signature_verified ?? null,
});
@ -245,7 +375,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
return;
}
setDetailLoading(true);
const result = await API.getPluginDetailManifest(plugin.repo_id, plugin.manifest_url);
const result = await API.getPluginDetailManifest(
plugin.repo_id,
plugin.manifest_url
);
if (result) {
setDetail(result);
if (result.manifest?.versions?.length) {
@ -257,7 +390,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
React.useEffect(() => {
if (autoOpenDetail) handleMoreInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const latestInstallParams = {
@ -280,16 +413,24 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
flexDirection: 'column',
minHeight: 220,
backgroundColor: '#27272A',
...(multiRepo && plugin.is_official_repo ? { borderColor: '#0e6459' } : {}),
...(multiRepo && plugin.is_official_repo
? { borderColor: '#0e6459' }
: {}),
}}
>
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<Avatar
src={plugin.icon_url}
radius="sm"
size={48}
alt={`${plugin.name} logo`}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
@ -299,7 +440,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Text>
<Group gap={6} align="center" wrap="nowrap">
{plugin.author && (
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0, maxWidth: '100%' }}>
<Text
size="xs"
c="dimmed"
truncate
style={{ minWidth: 0, maxWidth: '100%' }}
>
{plugin.author}
</Text>
)}
@ -322,7 +468,15 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Group>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box
style={{
flex: 1,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ overflow: 'hidden' }}>
<Text size="sm" c="dimmed" lineClamp={3} mb={0}>
{plugin.description}
@ -330,11 +484,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</div>
<Stack gap={2} mt="auto" pt={4} style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="wrap">
<Group gap="xs" wrap="wrap">
{plugin.latest_version && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>LATEST</span>
v{plugin.latest_version}
<span style={{ opacity: 0.5, marginRight: 4 }}>LATEST</span>v
{plugin.latest_version}
</Badge>
)}
{plugin.license && (
@ -374,103 +528,144 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Box>
<Group justify="space-between" mt="sm" align="center" wrap="nowrap">
{!meetsVersion && (() => {
const parts = [];
if (!meetsMinVersion) parts.push(`${plugin.min_dispatcharr_version} or newer`);
if (!meetsMaxVersion) parts.push(`${plugin.max_dispatcharr_version} or older`);
const label = !meetsMinVersion
? `Min ${plugin.min_dispatcharr_version}`
: `Max ${plugin.max_dispatcharr_version}`;
return (
<Tooltip label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow">{label}</Text>
</Group>
</Tooltip>
);
})()}
{!meetsVersion &&
(() => {
const parts = [];
if (!meetsMinVersion)
parts.push(`${plugin.min_dispatcharr_version} or newer`);
if (!meetsMaxVersion)
parts.push(`${plugin.max_dispatcharr_version} or older`);
const label = !meetsMinVersion
? `Min ${plugin.min_dispatcharr_version}`
: `Max ${plugin.max_dispatcharr_version}`;
return (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
size={14}
color="var(--mantine-color-yellow-6)"
/>
<Text size="xs" c="yellow">
{label}
</Text>
</Group>
</Tooltip>
);
})()}
{meetsVersion && <span />}
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="default"
leftSection={<Info size={14} />}
onClick={handleMoreInfo}
>
More Info
</Button>
{(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && (
<Tooltip label="Installed manually - installing from this repo will take over management">
<Button
size="xs"
variant="default"
leftSection={<Info size={14} />}
onClick={handleMoreInfo}
>
More Info
</Button>
{plugin.install_status === 'unmanaged' &&
plugin.latest_version &&
plugin.latest_url && (
<Tooltip label="Installed manually - installing from this repo will take over management">
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</Tooltip>
)}
{plugin.install_status === 'different_repo' && plugin.latest_url && (
<Tooltip
label={`Managed by ${plugin.installed_source_repo_name || 'another repo'} - installing will transfer management to this repo`}
>
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</Tooltip>
)}
{plugin.install_status === 'installed' && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setUninstallConfirmOpen(true)}
>
Uninstall
</Button>
)}
{plugin.install_status === 'update_available' && (
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
color={isLatestDowngrade ? 'orange' : 'yellow'}
latest_size={plugin.latest_size}
leftSection={
installing ? (
<Loader size={14} />
) : isLatestDowngrade ? (
<AlertTriangle size={14} />
) : (
<RefreshCw size={14} />
)
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</Button>
</Tooltip>
)}
{(plugin.install_status === 'different_repo') && plugin.latest_url && (
<Tooltip label={`Managed by ${plugin.installed_source_repo_name || 'another repo'} - installing will transfer management to this repo`}>
<Button
size="xs"
variant="filled"
color="orange"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</Button>
</Tooltip>
)}
{(plugin.install_status === 'installed') && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setUninstallConfirmOpen(true)}
>
Uninstall
</Button>
)}
{(plugin.install_status === 'update_available') && (
<Button
size="xs"
variant="filled"
color={isLatestDowngrade ? 'orange' : 'yellow'}
leftSection={installing ? <Loader size={14} /> : isLatestDowngrade ? <AlertTriangle size={14} /> : <RefreshCw size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing
? (isLatestDowngrade ? 'Downgrading...' : 'Updating...')
: (isLatestDowngrade ? 'Downgrade' : 'Update')}
</Button>
)}
{(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && (
<Button
size="xs"
variant="filled"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
</Button>
)}
{installing
? isLatestDowngrade
? 'Downgrading...'
: 'Updating...'
: isLatestDowngrade
? 'Downgrade'
: 'Update'}
</SizedInstallButton>
)}
{(!plugin.install_status ||
plugin.install_status === 'not_installed') &&
plugin.latest_url && (
<SizedInstallButton
size="xs"
variant="filled"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
</SizedInstallButton>
)}
</Group>
</Group>
{/* Detail Modal */}
<Modal
opened={detailOpen}
onClose={() => { setDetailOpen(false); onDetailClose?.(); }}
onClose={() => {
setDetailOpen(false);
onDetailClose?.();
}}
title={
<Group gap="xs" align="center">
<Avatar
@ -478,6 +673,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
radius="sm"
size={28}
alt={`${plugin.name} logo`}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
@ -485,7 +681,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
<RepoBadge
isOfficial={plugin.is_official_repo}
repoName={plugin.repo_name}
signatureVerified={detail?.signature_verified ?? plugin.signature_verified}
signatureVerified={
detail?.signature_verified ?? plugin.signature_verified
}
/>
</Group>
}
@ -497,7 +695,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
selectedVersion={selectedVersion}
onVersionChange={setSelectedVersion}
installedVersion={plugin.installed_version}
installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease}
installedVersionIsPrerelease={
!!plugin.installed_version_is_prerelease
}
appVersion={appVersion}
installing={installing}
uninstalling={uninstalling}
@ -513,7 +713,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
{/* Deprecation warning modal */}
<Modal
opened={deprecationWarnOpen}
onClose={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
onClose={() => {
setDeprecationWarnOpen(false);
setPendingDeprecatedInstall(null);
}}
zIndex={300}
title={
<Group gap="xs" align="center">
@ -525,18 +728,32 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been marked as <b>deprecated</b> by its maintainer.
<b>{plugin.name}</b> has been marked as <b>deprecated</b> by its
maintainer.
</Text>
<Text size="sm" c="dimmed">
Deprecated plugins may no longer receive updates or fixes, and could stop working with future
versions of Dispatcharr. It is recommended to look for an alternative.
Deprecated plugins may no longer receive updates or fixes, and could
stop working with future versions of Dispatcharr. It is recommended
to look for an alternative.
</Text>
<PluginSecurityWarning>
Plugins run server-side code with full access to your Dispatcharr
instance and its data. Only install plugins from developers you
trust. Malicious plugins could read or modify data, call internal
APIs, or perform unwanted actions.
</PluginSecurityWarning>
<PluginSupportDisclaimer />
<Text size="sm" fw={500}>
Do you still want to proceed?
</Text>
<Text size="sm" fw={500}>Do you still want to proceed?</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
onClick={() => {
setDeprecationWarnOpen(false);
setPendingDeprecatedInstall(null);
}}
>
Cancel
</Button>
@ -554,26 +771,49 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
{/* Unified install confirmation modal */}
{(() => {
const isDowngrade = pendingInstall && plugin.installed_version &&
const isDowngrade =
pendingInstall &&
plugin.installed_version &&
compareVersions(pendingInstall.version, plugin.installed_version) < 0;
const isUpdate = pendingInstall && plugin.installed_version &&
const isUpdate =
pendingInstall &&
plugin.installed_version &&
!isDowngrade &&
compareVersions(pendingInstall.version, plugin.installed_version) > 0;
const isBadSig = plugin.signature_verified === false;
const actionLabel = isDowngrade ? 'Downgrade' : isUpdate ? 'Update' : 'Install';
const btnColor = (isDowngrade && isBadSig) ? 'red' : isDowngrade ? 'orange' : isBadSig ? 'red' : undefined;
const actionLabel = isDowngrade
? 'Downgrade'
: isUpdate
? 'Update'
: 'Install';
const btnColor =
isDowngrade && isBadSig
? 'red'
: isDowngrade
? 'orange'
: isBadSig
? 'red'
: undefined;
return (
<Modal
opened={confirmOpen}
onClose={() => { setConfirmOpen(false); setPendingInstall(null); }}
onClose={() => {
setConfirmOpen(false);
setPendingInstall(null);
}}
zIndex={300}
title={
<Group gap="xs" align="center">
{isBadSig
? <ShieldAlert size={18} color="var(--mantine-color-red-6)" />
: isDowngrade
? <AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
: <Download size={18} />}
{isBadSig ? (
<ShieldAlert size={18} color="var(--mantine-color-red-6)" />
) : isDowngrade ? (
<AlertTriangle
size={18}
color="var(--mantine-color-orange-6)"
/>
) : (
<Download size={18} />
)}
<Text fw={600}>Confirm {actionLabel}</Text>
</Group>
}
@ -581,55 +821,75 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
You are about to {actionLabel.toLowerCase()} <b>{plugin.name}</b>{' '}
{isUpdate || isDowngrade
? <>from <b>v{plugin.installed_version}</b> to <b>v{pendingInstall?.version}</b></>
: <><b>v{pendingInstall?.version}</b></>}
{plugin.repo_name ? <> from <b>{plugin.repo_name}</b></> : ''}.
</Text>
<Text size="sm" c="dimmed">
Plugins run server-side code with full access to your Dispatcharr instance and its
data. Only install plugins from developers you trust. Malicious plugins could read
or modify data, call internal APIs, or perform unwanted actions.
You are about to {actionLabel.toLowerCase()}{' '}
<b>{plugin.name}</b>{' '}
{isUpdate || isDowngrade ? (
<>
from <b>v{plugin.installed_version}</b> to{' '}
<b>v{pendingInstall?.version}</b>
</>
) : (
<>
<b>v{pendingInstall?.version}</b>
</>
)}
{plugin.repo_name ? (
<>
{' '}
from <b>{plugin.repo_name}</b>
</>
) : (
''
)}
.
</Text>
<PluginSecurityWarning>
Plugins run server-side code with full access to your
Dispatcharr instance and its data. Only install plugins from
developers you trust. Malicious plugins could read or modify
data, call internal APIs, or perform unwanted actions.
</PluginSecurityWarning>
<PluginSupportDisclaimer />
{isDowngrade && (
<Text size="sm" c="orange">
<b>Warning:</b> Downgrading may cause issues with saved settings or data.
</Text>
<PluginDowngradeWarning>
Downgrading may cause issues with saved settings or data.
</PluginDowngradeWarning>
)}
{isBadSig && (
<Text size="sm" c="red">
<b>Warning:</b> This repository has an invalid or unverified signature.
<PluginSecurityWarning>
This repository has an invalid or unverified signature.
Installing plugins from unverified sources may be risky.
</Text>
</PluginSecurityWarning>
)}
{plugin.install_status === 'unmanaged' && (
<Text size="sm" c="orange">
<b>Note:</b> This plugin was installed manually. Installing from this repo
will bring it under repo management and enable future update checks.
</Text>
<PluginInfoNote>
This plugin was installed manually. Installing from this repo
will bring it under repo management and enable future update
checks.
</PluginInfoNote>
)}
{plugin.install_status === 'different_repo' && (
<Text size="sm" c="orange">
<b>Note:</b> This plugin is currently managed
by <b>{plugin.installed_source_repo_name || 'another repo'}</b>.
<PluginInfoNote>
This plugin is currently managed by{' '}
<b>{plugin.installed_source_repo_name || 'another repo'}</b>.
Installing will transfer management to this repo.
</Text>
</PluginInfoNote>
)}
<Text size="sm" fw={500}>Are you sure you want to proceed?</Text>
<Text size="sm" fw={500}>
Are you sure you want to proceed?
</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => { setConfirmOpen(false); setPendingInstall(null); }}
onClick={() => {
setConfirmOpen(false);
setPendingInstall(null);
}}
>
Cancel
</Button>
<Button
size="xs"
color={btnColor}
onClick={confirmAndInstall}
>
<Button size="xs" color={btnColor} onClick={confirmAndInstall}>
{actionLabel}
</Button>
</Group>
@ -697,7 +957,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
A restart of Dispatcharr may be required to fully unload the plugin.
</Text>
<Group justify="flex-end">
<Button size="xs" variant="default" onClick={() => setUninstallDoneOpen(false)}>
<Button
size="xs"
variant="default"
onClick={() => setUninstallDoneOpen(false)}
>
Done
</Button>
</Group>
@ -713,7 +977,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
<Group gap="xs" align="center">
<RotateCcw size={18} color="var(--mantine-color-blue-6)" />
<Text fw={600}>
Plugin {installAction === 'installed' ? 'Installed' : installAction === 'downgraded' ? 'Downgraded' : 'Updated'}
Plugin{' '}
{installAction === 'installed'
? 'Installed'
: installAction === 'downgraded'
? 'Downgraded'
: 'Updated'}
</Text>
</Group>
}
@ -721,15 +990,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been {installAction || 'installed'} successfully.
<b>{plugin.name}</b> has been {installAction || 'installed'}{' '}
successfully.
</Text>
<Text size="sm">
A restart of Dispatcharr may be required for the plugin to be fully loaded.
A restart of Dispatcharr may be required for the plugin to be fully
loaded.
</Text>
{pluginIsDisabled && (
<>
<Text size="xs" c="dimmed">
This plugin is currently disabled. You can enable it now or at any time from My Plugins.
This plugin is currently disabled. You can enable it now or at
any time from My Plugins.
</Text>
<Group justify="space-between" align="center">
<Text size="sm">Enable plugin</Text>

View file

@ -17,7 +17,7 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { Ban, Check, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react';
import { Ban, Check, Download, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
import useSettingsStore from '../../store/settings.jsx';
@ -25,6 +25,11 @@ import { usePluginStore } from '../../store/plugins.jsx';
import API from '../../api';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import { compareVersions } from '../pluginUtils.js';
import {
PluginDowngradeWarning,
PluginSecurityWarning,
PluginSupportDisclaimer,
} from '../PluginWarnings.jsx';
const PluginFieldList = ({ plugin, settings, updateField }) => {
return plugin.fields.map((f) => (
@ -128,6 +133,8 @@ const PluginCard = ({
const [selectedVersion, setSelectedVersion] = useState(null);
const [installing, setInstalling] = useState(false);
const [uninstalling] = useState(false);
const [installConfirmOpen, setInstallConfirmOpen] = useState(false);
const [pendingInstallParams, setPendingInstallParams] = useState(null);
const installPlugin = usePluginStore((s) => s.installPlugin);
@ -177,6 +184,7 @@ const PluginCard = ({
min_dispatcharr_version: avail.min_dispatcharr_version,
max_dispatcharr_version: avail.max_dispatcharr_version,
build_timestamp: avail.last_updated,
size: avail.latest_size,
}] : [],
latest: avail.latest_version ? { version: avail.latest_version } : null,
},
@ -310,14 +318,18 @@ const PluginCard = ({
};
const handleDetailInstall = async (params) => {
setPendingInstallParams(params);
setInstallConfirmOpen(true);
};
const confirmAndInstall = async () => {
if (!pendingInstallParams) return;
const params = pendingInstallParams;
const selVer = params.version;
const isDown = plugin.version && compareVersions(selVer, plugin.version) < 0;
const action = isDown ? 'downgrade' : 'update';
const confirmed = await onRequestConfirm(
`${isDown ? 'Downgrade' : 'Update'} ${plugin.name}?`,
`${isDown ? 'Downgrade' : 'Update'} from v${plugin.version} to v${selVer}?`
);
if (!confirmed) return;
setInstallConfirmOpen(false);
setPendingInstallParams(null);
setInstalling(true);
try {
const result = await installPlugin(params);
@ -363,6 +375,7 @@ const PluginCard = ({
alt={`${plugin.name} logo`}
onClick={isManaged ? () => openModal('details') : undefined}
style={isManaged ? { cursor: 'pointer' } : undefined}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
@ -529,7 +542,7 @@ const PluginCard = ({
onClose={() => setModalOpen(false)}
title={
<Group gap="xs" align="center">
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`}>
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`} imageProps={{ draggable: false }}>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Text fw={600}>{plugin.name}</Text>
@ -614,6 +627,71 @@ const PluginCard = ({
)}
</Tabs>
</Modal>
{/* Install confirmation modal */}
{(() => {
const selVer = pendingInstallParams?.version;
const isDown = plugin.version && selVer && compareVersions(selVer, plugin.version) < 0;
const actionLabel = isDown ? 'Downgrade' : 'Update';
return (
<Modal
opened={installConfirmOpen}
onClose={() => {
setInstallConfirmOpen(false);
setPendingInstallParams(null);
}}
zIndex={300}
title={
<Group gap="xs" align="center">
{isDown
? <Download size={18} color="var(--mantine-color-orange-6)" />
: <Download size={18} />}
<Text fw={600}>Confirm {actionLabel}</Text>
</Group>
}
size="sm"
>
<Stack gap="md">
<Text size="sm">
You are about to {actionLabel.toLowerCase()} <b>{plugin.name}</b>{' '}
from <b>v{plugin.version}</b> to <b>v{selVer}</b>.
</Text>
<PluginSecurityWarning>
Plugins run server-side code with full access to your Dispatcharr
instance and its data. Only install plugins from developers you
trust. Malicious plugins could read or modify data, call internal
APIs, or perform unwanted actions.
</PluginSecurityWarning>
<PluginSupportDisclaimer />
{isDown && (
<PluginDowngradeWarning>
Downgrading may cause issues with saved settings or data.
</PluginDowngradeWarning>
)}
<Text size="sm" fw={500}>Are you sure you want to proceed?</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => {
setInstallConfirmOpen(false);
setPendingInstallParams(null);
}}
>
Cancel
</Button>
<Button
size="xs"
color={isDown ? 'orange' : undefined}
onClick={confirmAndInstall}
>
{actionLabel}
</Button>
</Group>
</Stack>
</Modal>
);
})()}
</div>
);
};

View file

@ -285,7 +285,9 @@ const RecordingCard = ({
<Tooltip
label={
customProps.file_url || customProps.output_file_url
? 'Watch recording'
? isInProgress
? 'Watch in progress recording'
: 'Watch recording'
: 'Recording playback not available yet'
}
>
@ -296,10 +298,7 @@ const RecordingCard = ({
e.stopPropagation();
handleWatchRecording();
}}
disabled={
customProps.status === 'recording' ||
!(customProps.file_url || customProps.output_file_url)
}
disabled={!(customProps.file_url || customProps.output_file_url)}
>
Watch
</Button>

View file

@ -32,6 +32,7 @@ import {
} from 'lucide-react';
import {
toFriendlyDuration,
formatExactDuration,
useDateTimeFormat,
} from '../../utils/dateTimeUtils.js';
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
@ -123,6 +124,7 @@ const StreamConnectionCard = ({
// Get M3U account data from the playlists store
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
const m3uProfiles = usePlaylistsStore((s) => s.profiles);
// Get users for resolving user_id username on client rows
const users = useUsersStore((s) => s.users);
// Get settings for speed threshold and environment mode
@ -135,6 +137,19 @@ const StreamConnectionCard = ({
// Get user's date/time format preferences
const { fullDateTimeFormat } = useDateTimeFormat();
// Flat map of profile id -> profile for quick lookup
const m3uProfilesById = useMemo(() => {
const map = {};
Object.values(m3uProfiles).forEach((profileList) => {
if (Array.isArray(profileList)) {
profileList.forEach((p) => {
map[p.id] = p;
});
}
});
return map;
}, [m3uProfiles]);
// Create a map of M3U account IDs to names for quick lookup
const m3uAccountsMap = useMemo(() => {
return getM3uAccountsMap(m3uAccounts);
@ -151,16 +166,29 @@ const StreamConnectionCard = ({
// Update M3U profile information when channel data changes
useEffect(() => {
// If the channel data includes M3U profile information, update our state
if (channel.m3u_profile || channel.m3u_profile_name) {
if (
channel.m3u_profile ||
channel.m3u_profile_name ||
channel.m3u_profile_id
) {
const profileFromStore = channel.m3u_profile_id
? m3uProfilesById[channel.m3u_profile_id]
: null;
setCurrentM3UProfile({
name:
channel.m3u_profile?.name ||
profileFromStore?.name ||
channel.m3u_profile_name ||
'Default M3U',
});
}
}, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]);
}, [
channel.m3u_profile,
channel.m3u_profile_name,
channel.m3u_profile_id,
channel.stream_id,
m3uProfilesById,
]);
// Fetch available streams for this channel
useEffect(() => {
@ -376,13 +404,14 @@ const StreamConnectionCard = ({
minSize: 60,
accessorFn: durationAccessor(),
cell: ({ cell, row }) => {
const exactDuration =
row.original.connected_since || row.original.connection_duration;
const exactDuration = row.original.connected_at
? Date.now() / 1000 - row.original.connected_at
: null;
return (
<Tooltip
label={
exactDuration
? `${exactDuration.toFixed(1)} seconds`
? formatExactDuration(exactDuration)
: 'Unknown duration'
}
>

View file

@ -16,10 +16,14 @@ vi.mock('../../../store/settings.jsx', () => ({
vi.mock('../../../store/useVideoStore', () => ({
default: vi.fn(),
}));
vi.mock('../../../store/users.jsx', () => ({
default: vi.fn(),
}));
// dateTimeUtils
vi.mock('../../../utils/dateTimeUtils.js', () => ({
toFriendlyDuration: vi.fn(() => '1h 23m'),
formatExactDuration: vi.fn((s) => `${s.toFixed(1)} seconds`),
useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })),
}));
@ -167,7 +171,6 @@ vi.mock('lucide-react', () => ({
SquareX: () => <svg data-testid="icon-square-x" />,
Timer: () => <svg data-testid="icon-timer" />,
Users: () => <svg data-testid="icon-users" />,
Video: () => <svg data-testid="icon-video" />,
Package: () => <svg data-testid="icon-package" />,
Download: () => <svg data-testid="icon-download" />,
}));
@ -177,6 +180,7 @@ import { useLocation } from 'react-router-dom';
import usePlaylistsStore from '../../../store/playlists.jsx';
import useSettingsStore from '../../../store/settings.jsx';
import useVideoStore from '../../../store/useVideoStore';
import useUsersStore from '../../../store/users.jsx';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
getChannelStreams,
@ -216,8 +220,7 @@ const makeClients = (channelId = 'ch-uuid-1') => [
client_id: 'client-1',
channel: { channel_id: channelId, uuid: 'ch-uuid-1' },
ip_address: '192.168.1.10',
connected_since: 330,
connection_duration: 330,
connected_at: Date.now() / 1000 - 330,
user_agent: 'VLC/3.0',
streams: [{ id: 1 }],
},
@ -254,18 +257,27 @@ const setupLocation = (pathname = '/stats') => {
});
};
const mockShowVideo = vi.fn();
const mockPlaylistsState = { playlists: [], profiles: {} };
const mockSettingsState = {
settings: { proxy_settings: {} },
environment: { env_mode: 'production' },
};
const mockVideoState = { showVideo: mockShowVideo };
const mockUsersState = { users: [] };
const setupStores = () => {
vi.mocked(usePlaylistsStore).mockImplementation((selector) =>
selector({ playlists: [] })
selector(mockPlaylistsState)
);
vi.mocked(useSettingsStore).mockImplementation((selector) =>
selector({
settings: { proxy_settings: {} },
environment: { env_mode: 'production' },
})
selector(mockSettingsState)
);
vi.mocked(useVideoStore).mockImplementation((selector) =>
selector({ showVideo: vi.fn() })
selector(mockVideoState)
);
vi.mocked(useUsersStore).mockImplementation((selector) =>
selector(mockUsersState)
);
};

View file

@ -293,7 +293,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const defaultValues = useMemo(
() => getChannelFormDefaultValues(channel, channelGroups),
[channel, channelGroups]
// eslint-disable-next-line react-hooks/exhaustive-deps
[channel]
);
const {

View file

@ -4,6 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import {
Alert,
Flex,
Modal,
TextInput,
@ -17,6 +18,7 @@ import {
NumberInput,
SegmentedControl,
} from '@mantine/core';
import { TriangleAlert } from 'lucide-react';
import { DateTimePicker } from '@mantine/dates';
import { useWebSocket } from '../../WebSocket';
@ -115,11 +117,13 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
}
}
// For default profiles, only send name and custom_properties (notes)
// Build submit values
let submitValues;
if (isDefaultProfile) {
submitValues = {
name: values.name,
search_pattern: searchPattern || '',
replace_pattern: replacePattern || '',
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
@ -290,15 +294,35 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
setXcMode(mode);
};
// Local regex for the live demo preview
// Local regex for the live demo preview. Returns an array of strings and
// <mark> React nodes so user-supplied text is never interpolated into raw
// HTML (avoids self-XSS via dangerouslySetInnerHTML).
const getHighlightedSearchText = () => {
if (!searchPattern || !sampleInput) return sampleInput;
try {
const regex = new RegExp(searchPattern, 'g');
return sampleInput.replace(
regex,
(match) => `<mark style="background-color: #ffee58;">${match}</mark>`
);
const parts = [];
let lastIndex = 0;
let m;
while ((m = regex.exec(sampleInput)) !== null) {
if (m.index > lastIndex) {
parts.push(sampleInput.slice(lastIndex, m.index));
}
parts.push(
<mark
key={`${m.index}-${parts.length}`}
style={{ backgroundColor: '#ffee58' }}
>
{m[0]}
</mark>
);
lastIndex = m.index + m[0].length;
if (m[0].length === 0) regex.lastIndex++;
}
if (lastIndex < sampleInput.length) {
parts.push(sampleInput.slice(lastIndex));
}
return parts;
} catch {
return sampleInput;
}
@ -318,11 +342,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
<Modal
opened={isOpen}
onClose={onClose}
title={
isDefaultProfile
? 'Edit Default Profile (Name & Notes Only)'
: 'M3U Profile'
}
title={isDefaultProfile ? 'Edit Default Profile' : 'M3U Profile'}
size="lg"
>
<form onSubmit={handleSubmit(onSubmit)}>
@ -348,8 +368,39 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
/>
)}
{/* Only show search/replace fields for non-default profiles */}
{!isDefaultProfile && (
{/* Search/replace fields */}
{isDefaultProfile ? (
<>
<Alert
icon={<TriangleAlert size={16} />}
color="yellow"
title="Default Profile"
mt="xs"
>
<Text size="sm">
These patterns are applied to every stream in this playlist. If
the search pattern doesn&apos;t match a stream URL, the original
URL is used as-is.
</Text>
</Alert>
<TextInput
label="Search Pattern (Regex)"
description="A regular expression matching the part of the stream URL you want to replace."
placeholder="e.g. 10\.0\.0\.10"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={errors.search_pattern?.message}
/>
<TextInput
label="Replace Pattern"
description="The value to substitute in place of the matched text. Use $1, $2, etc. to reference regex capture groups."
placeholder="e.g. 192.168.1.100"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={errors.replace_pattern?.message}
/>
</>
) : (
<>
{isXC && (
<SegmentedControl
@ -438,23 +489,38 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
<Flex
mih={50}
gap="xs"
justify="flex-end"
justify="space-between"
align="flex-end"
style={{ marginBottom: 5 }}
>
{isDefaultProfile && (
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => {
setSearchPattern('^(.*)$');
setReplacePattern('$1');
setValue('search_pattern', '^(.*)$');
setValue('replace_pattern', '$1');
}}
>
Reset to Defaults
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
size="xs"
style={{ width: isSubmitting ? 'auto' : 'auto' }}
style={{ marginLeft: isDefaultProfile ? undefined : 'auto' }}
>
Submit
</Button>
</Flex>
</form>
{/* Only show regex demonstration for non-default profiles in advanced mode */}
{!isDefaultProfile && (!isXC || xcMode === 'advanced') && (
{/* Show regex demonstration for default profiles and non-default profiles in advanced mode */}
{(isDefaultProfile || !isXC || xcMode === 'advanced') && (
<>
<Title order={4} mt={15} mb={10}>
Live Regex Demonstration
@ -483,11 +549,10 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
</Text>
<Text
size="sm"
dangerouslySetInnerHTML={{
__html: getHighlightedSearchText(),
}}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
>
{getHighlightedSearchText()}
</Text>
</Paper>
</Grid.Col>

View file

@ -122,7 +122,8 @@ const RecordingDetailsModal = ({
const canWatchRecording =
(customProps.status === 'completed' ||
customProps.status === 'stopped' ||
customProps.status === 'interrupted') &&
customProps.status === 'interrupted' ||
customProps.status === 'recording') &&
Boolean(fileUrl);
const isSeriesGroup = Boolean(

File diff suppressed because it is too large Load diff

View file

@ -17,13 +17,11 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import ChannelBatchForm from '../forms/ChannelBatch';
import RecordingForm from '../forms/Recording';
import { useDebounce, copyToClipboard } from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import {
@ -65,7 +63,6 @@ import {
Tooltip,
Skeleton,
} from '@mantine/core';
import { getCoreRowModel, flexRender } from '@tanstack/react-table';
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
import ChannelTableStreams from './ChannelTableStreams';
@ -243,7 +240,12 @@ const ChannelRowActions = React.memo(
</Center>
</Box>
);
}
},
// Custom comparator: only re-render when the actual channel changes.
// The row object is a new TanStack Table reference on each render, but
// row.original.id is stable. Callbacks read fresh data at call time.
(prevProps, nextProps) =>
prevProps.row.original.id === nextProps.row.original.id
);
const ChannelsTable = ({ onReady }) => {
@ -286,27 +288,24 @@ const ChannelsTable = ({ onReady }) => {
(s) => s.setSelectedChannelIds
);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const setExpandedChannelId = useChannelsTableStore(
(s) => s.setExpandedChannelId
);
const pagination = useChannelsTableStore((s) => s.pagination);
const setPagination = useChannelsTableStore((s) => s.setPagination);
const sorting = useChannelsTableStore((s) => s.sorting);
const setSorting = useChannelsTableStore((s) => s.setSorting);
const totalCount = useChannelsTableStore((s) => s.totalCount);
const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams);
const allRowIds = useChannelsTableStore((s) => s.allQueryIds);
const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds);
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
// store/channels
const channels = useChannelsStore((s) => s.channels);
const channelIds = useChannelsStore((s) => s.channelIds);
const hasChannels = useChannelsStore((s) => s.channelIds.length > 0);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
const [, setTablePrefs] = useLocalStorage('channel-table-prefs', {
pageSize: 50,
});
const selectedProfileChannels = useChannelsStore(
(s) => s.profiles[selectedProfileId]?.channels
);
// store/settings
const env_mode = useSettingsStore((s) => s.environment.env_mode);
@ -316,14 +315,6 @@ const ChannelsTable = ({ onReady }) => {
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
/**
* useMemo
*/
const selectedProfileChannelIds = useMemo(
() => new Set(selectedProfileChannels),
[selectedProfileChannels]
);
/**
* useState
*/
@ -331,9 +322,6 @@ const ChannelsTable = ({ onReady }) => {
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState(
profiles[selectedProfileId]
);
const [showDisabled, setShowDisabled] = useState(true);
const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] =
useState(false);
@ -345,7 +333,7 @@ const ChannelsTable = ({ onReady }) => {
channel_group: '',
epg: '',
});
const [isLoading, setIsLoading] = useState(true);
const [, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
@ -490,7 +478,7 @@ const ChannelsTable = ({ onReady }) => {
setIsLoading(true);
try {
const [results, ids] = await Promise.all([
const [, ids] = await Promise.all([
API.queryChannels(params),
API.getAllChannelIds(params),
]);
@ -673,42 +661,50 @@ const ChannelsTable = ({ onReady }) => {
}
};
const createRecording = (channel) => {
const createRecording = useCallback((channel) => {
console.log(`Recording channel ID: ${channel.id}`);
setChannel(channel);
setRecordingModalOpen(true);
};
}, []);
const getChannelURL = (channel) => {
// Make sure we're using the channel UUID consistently
if (!channel || !channel.uuid) {
console.error('Invalid channel object or missing UUID:', channel);
return '';
}
const getChannelURL = useCallback(
(channel) => {
// Make sure we're using the channel UUID consistently
if (!channel || !channel.uuid) {
console.error('Invalid channel object or missing UUID:', channel);
return '';
}
const uri = `/proxy/ts/stream/${channel.uuid}`;
let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`;
if (env_mode == 'dev') {
channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
}
const uri = `/proxy/ts/stream/${channel.uuid}`;
let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`;
if (env_mode == 'dev') {
channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
}
return channelUrl;
};
return channelUrl;
},
[env_mode]
);
const handleWatchStream = (channel) => {
// Add additional logging to help debug issues
console.log(
`Watching stream for channel: ${channel.name} (${channel.id}), UUID: ${channel.uuid}`
);
const url = getChannelURL(channel);
console.log(`Stream URL: ${url}`);
showVideo(url, 'live', { name: channel.name });
};
const handleWatchStream = useCallback(
(channel) => {
const url = getChannelURL(channel);
showVideo(url, 'live', { name: channel.name, channelId: channel.id });
},
[getChannelURL, showVideo]
);
const onRowSelectionChange = (newSelection) => {
setSelectedChannelIds(newSelection);
};
const onRowExpansionChange = useCallback(
(expandedIds) => {
setExpandedChannelId(expandedIds.length > 0 ? expandedIds[0] : null);
},
[setExpandedChannelId]
);
const onPageSizeChange = (e) => {
setPagination({
...pagination,
@ -861,8 +857,6 @@ const ChannelsTable = ({ onReady }) => {
}, [fetchData]);
useEffect(() => {
setSelectedProfile(profiles[selectedProfileId]);
const profileString =
selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : '';
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
@ -1152,6 +1146,7 @@ const ChannelsTable = ({ onReady }) => {
enableRowSelection: true,
enableDragDrop: true,
onRowSelectionChange: onRowSelectionChange,
onRowExpansionChange: onRowExpansionChange,
state: {
pagination,
sorting,
@ -1167,7 +1162,7 @@ const ChannelsTable = ({ onReady }) => {
className="tr"
style={{ display: 'flex', width: '100%' }}
>
<ChannelTableStreams channel={row.original} isExpanded={true} />
<ChannelTableStreams channel={row.original} />
</Box>
);
},
@ -1524,12 +1519,12 @@ const ChannelsTable = ({ onReady }) => {
{/* Table or ghost empty state inside Paper */}
<Box>
{channelsTableLength === 0 && channelIds.length === 0 && (
{channelsTableLength === 0 && !hasChannels && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
</Box>
{(channelsTableLength > 0 || channelIds.length > 0) && (
{(channelsTableLength > 0 || hasChannels) && (
<Box
style={{
display: 'flex',

View file

@ -27,6 +27,20 @@ const CustomTable = ({ table }) => {
return width;
}, [table, columnSizing]);
// CSS custom properties for each fixed-width column's current size.
// These are injected on the table wrapper and cascade to all descendant cells,
// so body cells (which are memoized and don't re-render on resize) still pick
// up the new width via CSS cascade without needing a React re-render.
const columnSizeVars = useMemo(() => {
void columnSizing;
return table.getFlatHeaders().reduce((vars, header) => {
if (!header.column.columnDef.grow) {
vars[`--header-${header.id}-size`] = `${header.getSize()}px`;
}
return vars;
}, {});
}, [table, columnSizing]);
return (
<Box
className={`divTable table-striped table-size-${tableSize}`}
@ -36,6 +50,7 @@ const CustomTable = ({ table }) => {
minWidth: `${minTableWidth}px`,
display: 'flex',
flexDirection: 'column',
...columnSizeVars,
}}
>
<CustomTableHeader
@ -57,11 +72,10 @@ const CustomTable = ({ table }) => {
expandedRowIds={table.expandedRowIds}
expandedRowRenderer={table.expandedRowRenderer}
renderBodyCell={table.renderBodyCell}
getExpandedRowHeight={table.getExpandedRowHeight}
getRowStyles={table.getRowStyles}
tableBodyProps={table.tableBodyProps}
tableCellProps={table.tableCellProps}
enableDragDrop={table.enableDragDrop}
selectedTableIdsSet={table.selectedTableIdsSet}
/>
</Box>
);

View file

@ -1,115 +1,35 @@
import { Box, Flex } from '@mantine/core';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useMemo } from 'react';
import table from '../../../helpers/table';
import React, { useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical } from 'lucide-react';
import useChannelsTableStore from '../../../store/channelsTable';
const CustomTableBody = ({
getRowModel,
expandedRowIds,
expandedRowRenderer,
renderBodyCell,
getExpandedRowHeight,
getRowStyles,
tableBodyProps,
tableCellProps,
enableDragDrop = false,
}) => {
const renderExpandedRow = (row) => {
if (expandedRowRenderer) {
return expandedRowRenderer({ row });
}
return <></>;
};
const rows = getRowModel().rows;
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (rows.length === 0) return 0;
return rows[0].getVisibleCells().reduce((total, cell) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = cell.column.columnDef.size
? cell.column.getSize()
: cell.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0);
}, [rows]);
const renderTableBodyContents = () => {
const virtualized = false;
if (virtualized) {
return (
<Box
className="tbody"
style={{ flex: 1, ...(tableBodyProps && tableBodyProps()) }}
>
<AutoSizer disableWidth>
{({ height }) => {
const getItemSize = (index) => {
const row = rows[index];
const isExpanded = expandedRowIds.includes(row.original.id);
console.log(isExpanded);
// Default row height
let rowHeight = 28;
if (isExpanded && getExpandedRowHeight) {
// If row is expanded, adjust the height to be larger (based on your logic)
// You can get this height from your state, or calculate based on number of items in the expanded row
rowHeight += getExpandedRowHeight(row); // This function would calculate the expanded row's height
}
return rowHeight;
};
return (
<List
height={height}
itemCount={rows.length}
itemSize={getItemSize}
width="100%"
overscanCount={10}
>
{({ index, style }) => {
const row = rows[index];
return renderTableBodyRow(row, index, style);
}}
</List>
);
}}
</AutoSizer>
</Box>
);
}
return (
<Box className="tbody" style={{ flex: 1 }}>
{rows.map((row, index) => renderTableBodyRow(row, index))}
</Box>
);
};
const renderTableBodyRow = (row, index, style = {}) => {
// Get custom styles for this row if the function exists
// Memoized row only re-renders when this specific row's data, expansion
// state, or drag-drop config actually changes. Callback functions are read
// from refs so the memoized row always uses the latest version when it *does*
// re-render, without needing them as comparator inputs.
const MemoizedTableRow = React.memo(
({
row,
index,
isExpanded,
isSelected,
renderBodyCellRef,
expandedRowRendererRef,
getRowStyles,
tableCellProps,
enableDragDrop,
}) => {
const renderBodyCell = renderBodyCellRef.current;
const customRowStyles = getRowStyles ? getRowStyles(row) : {};
// Extract any className from customRowStyles
const customClassName = customRowStyles.className || '';
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
delete customRowStyles.className;
return (
<DraggableRowWrapper
row={row}
key={`row-${row.id}`}
style={style}
enableDragDrop={enableDragDrop}
>
<Box
@ -118,11 +38,11 @@ const CustomTableBody = ({
style={{
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
minWidth: '100%',
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
...customRowStyles, // Apply the remaining custom styles here
...customRowStyles,
}}
>
{row.getVisibleCells().map((cell) => {
@ -140,9 +60,9 @@ const CustomTableBody = ({
}),
}
: {
flex: `0 0 ${cell.column.getSize ? cell.column.getSize() : 150}px`,
width: `${cell.column.getSize ? cell.column.getSize() : 150}px`,
maxWidth: `${cell.column.getSize ? cell.column.getSize() : 150}px`,
flex: `0 0 var(--header-${cell.column.id}-size)`,
width: `var(--header-${cell.column.id}-size)`,
maxWidth: `var(--header-${cell.column.id}-size)`,
}),
...(tableCellProps && tableCellProps({ cell })),
}}
@ -154,12 +74,63 @@ const CustomTableBody = ({
);
})}
</Box>
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
{isExpanded && expandedRowRendererRef.current({ row })}
</DraggableRowWrapper>
);
};
},
(prev, next) => {
return (
prev.row.original === next.row.original &&
prev.index === next.index &&
prev.isExpanded === next.isExpanded &&
prev.isSelected === next.isSelected &&
prev.enableDragDrop === next.enableDragDrop
);
}
);
return renderTableBodyContents();
const CustomTableBody = ({
getRowModel,
expandedRowIds,
expandedRowRenderer,
renderBodyCell,
getRowStyles,
tableCellProps,
enableDragDrop = false,
selectedTableIdsSet,
}) => {
// Store callbacks in refs so memoized rows always access the latest versions
// without the function references themselves triggering re-renders.
const renderBodyCellRef = useRef(renderBodyCell);
renderBodyCellRef.current = renderBodyCell;
const expandedRowRendererRef = useRef(expandedRowRenderer);
expandedRowRendererRef.current = expandedRowRenderer;
const rows = getRowModel().rows;
return (
<Box className="tbody" style={{ flex: 1 }}>
{rows.map((row, index) => (
<MemoizedTableRow
key={`row-${row.id}`}
row={row}
index={index}
isExpanded={expandedRowIds.includes(row.original.id)}
isSelected={
selectedTableIdsSet
? selectedTableIdsSet.has(row.original.id)
: false
}
renderBodyCellRef={renderBodyCellRef}
expandedRowRendererRef={expandedRowRendererRef}
getRowStyles={getRowStyles}
tableCellProps={tableCellProps}
enableDragDrop={enableDragDrop}
/>
))}
</Box>
);
};
const DraggableRowWrapper = ({

View file

@ -117,9 +117,9 @@ const CustomTableHeader = ({
}),
}
: {
flex: `0 0 ${header.getSize ? header.getSize() : 150}px`,
width: `${header.getSize ? header.getSize() : 150}px`,
maxWidth: `${header.getSize ? header.getSize() : 150}px`,
flex: `0 0 var(--header-${header.id}-size)`,
width: `var(--header-${header.id}-size)`,
maxWidth: `var(--header-${header.id}-size)`,
}),
position: 'relative',
// ...(tableCellProps && tableCellProps({ cell: header })),

View file

@ -8,7 +8,7 @@ import {
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
const useTable = ({
@ -17,6 +17,7 @@ const useTable = ({
bodyCellRenderFns = {},
expandedRowRenderer = () => <></>,
onRowSelectionChange = null,
onRowExpansionChange = null,
getExpandedRowHeight = null,
state = {},
columnSizing,
@ -25,6 +26,8 @@ const useTable = ({
...options
}) => {
const [selectedTableIds, setSelectedTableIds] = useState([]);
const selectedTableIdsRef = useRef(selectedTableIds);
selectedTableIdsRef.current = selectedTableIds;
const [expandedRowIds, setExpandedRowIds] = useState([]);
const [lastClickedId, setLastClickedId] = useState(null);
const [isShiftKeyDown, setIsShiftKeyDown] = useState(false);
@ -146,12 +149,15 @@ const useTable = ({
};
const onRowExpansion = (row) => {
let isExpanded = false;
const rowId = row.original.id;
let newIds;
setExpandedRowIds((prev) => {
isExpanded = prev.includes(row.original.id) ? [] : [row.original.id];
return isExpanded;
newIds = prev.includes(rowId) ? [] : [rowId];
return newIds;
});
updateSelectedTableIds([row.original.id]);
if (onRowExpansionChange) {
onRowExpansionChange(newIds);
}
};
// Handle the shift+click selection
@ -174,7 +180,7 @@ const useTable = ({
const rangeIds = allRowIds.slice(startIndex, endIndex + 1);
// Preserve existing selections outside the range
const idsOutsideRange = selectedTableIds.filter(
const idsOutsideRange = selectedTableIdsRef.current.filter(
(id) => !rangeIds.includes(id)
);
const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])];
@ -206,7 +212,7 @@ const useTable = ({
// Try to handle with shift-select logic first
if (!handleShiftSelect(rowId, isShiftKey)) {
// If not handled by shift-select, do regular toggle
const newSet = new Set(selectedTableIds);
const newSet = new Set(selectedTableIdsRef.current);
if (e.target.checked) {
newSet.add(rowId);
} else {

View file

@ -74,26 +74,24 @@ const StreamRowActions = ({
editStream,
deleteStream,
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
table,
}) => {
const tableSize = table?.tableSize ?? 'default';
const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const targetChannelId =
expandedChannelId ||
(selectedChannelIds.length === 1 ? selectedChannelIds[0] : null);
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
state.channels.find((chan) => chan.id === targetChannelId)?.streams
);
const addStreamToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
streams: [
...new Set(
channelSelectionStreams.map((s) => s.id).concat([row.original.id])
),
],
});
await API.requeryChannels();
await API.addStreamsToChannel(targetChannelId, channelSelectionStreams, [
row.original,
]);
};
const onEdit = useCallback(() => {
@ -129,7 +127,7 @@ const StreamRowActions = ({
onClick={addStreamToChannel}
style={{ background: 'none' }}
disabled={
selectedChannelIds.length !== 1 ||
!targetChannelId ||
(channelSelectionStreams &&
channelSelectionStreams
.map((s) => s.id)
@ -204,6 +202,9 @@ const StreamsTable = ({ onReady }) => {
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
const initialDataCountRef = useRef(null); // First page count, kept in a ref so the page fetcher doesn't recreate when set
const lastIdsParamsRef = useRef(null); // De-dupe StrictMode double-fire of the IDs fetch
const lastFilterOptionsParamsRef = useRef(null); // De-dupe StrictMode double-fire of the filter-options fetch
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
@ -331,10 +332,14 @@ const StreamsTable = ({ onReady }) => {
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const targetChannelId =
expandedChannelId ||
(selectedChannelIds.length === 1 ? selectedChannelIds[0] : null);
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
state.channels.find((chan) => chan.id === targetChannelId)?.streams
);
const channelProfiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
@ -584,16 +589,30 @@ const StreamsTable = ({ onReady }) => {
}));
};
const fetchData = useCallback(
// Build a URLSearchParams object containing only the filter portion of the
// query. Page-rows fetches add page/page_size/ordering on top of this.
const buildFilterParams = useCallback(() => {
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (typeof value === 'boolean') {
if (value) params.append(key, 'true');
} else if (value !== null && value !== undefined && value !== '') {
params.append(key, String(value));
}
});
return params;
}, [debouncedFilters]);
// Fetch the visible page of stream rows. Depends on pagination, sorting,
// and filters.
const fetchPageData = useCallback(
async ({ showLoader = true } = {}) => {
const params = new URLSearchParams();
const params = buildFilterParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
// Apply sorting
if (sorting.length > 0) {
const columnId = sorting[0].id;
// Map frontend column IDs to backend field names
const fieldMapping = {
name: 'name',
group: 'channel_group__name',
@ -605,15 +624,6 @@ const StreamsTable = ({ onReady }) => {
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters; send boolean filters as 'true' when set
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (typeof value === 'boolean') {
if (value) params.append(key, 'true');
} else if (value !== null && value !== undefined && value !== '') {
params.append(key, String(value));
}
});
const paramsString = params.toString();
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
@ -624,7 +634,6 @@ const StreamsTable = ({ onReady }) => {
return;
}
// Increment fetch version to track this specific fetch request
const currentFetchVersion = ++fetchVersionRef.current;
lastFetchParamsRef.current = paramsString;
fetchInProgressRef.current = true;
@ -634,45 +643,19 @@ const StreamsTable = ({ onReady }) => {
}
try {
const [result, ids, filterOptions] = await Promise.all([
API.queryStreamsTable(params),
API.getAllStreamIds(params),
API.getStreamFilterOptions(params),
]);
const result = await API.queryStreamsTable(params);
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
setAllRowIds(ids);
// Set filtered options based on current filters
// Ensure groupOptions is always an array of valid strings
if (filterOptions && typeof filterOptions === 'object') {
setGroupOptions(
(filterOptions.groups || [])
.filter((group) => group != null && group !== '')
.map((group) => String(group))
);
// Ensure m3uOptions is always an array of valid objects
setM3uOptions(
(filterOptions.m3u_accounts || [])
.filter((m3u) => m3u && m3u.id != null && m3u.name)
.map((m3u) => ({
label: String(m3u.name),
value: String(m3u.id),
}))
);
}
if (initialDataCount === null) {
if (initialDataCountRef.current === null) {
initialDataCountRef.current = result.count;
setInitialDataCount(result.count);
}
// Signal that initial data load is complete
if (!hasSignaledReady.current && onReady) {
hasSignaledReady.current = true;
onReady();
@ -680,14 +663,12 @@ const StreamsTable = ({ onReady }) => {
} catch (error) {
fetchInProgressRef.current = false;
// Skip logging if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
console.error('Error fetching data:', error);
}
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
@ -697,7 +678,7 @@ const StreamsTable = ({ onReady }) => {
setIsLoading(false);
}
},
[pagination, sorting, debouncedFilters, onReady]
[pagination, sorting, buildFilterParams, onReady]
);
// Bulk creation: create channels from selected streams asynchronously
@ -1032,15 +1013,14 @@ const StreamsTable = ({ onReady }) => {
};
const addStreamsToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
streams: [
...new Set(
channelSelectionStreams.map((s) => s.id).concat(selectedStreamIds)
),
],
});
await API.requeryChannels();
// Look up full stream objects from the current page data
const selectedIdSet = new Set(selectedStreamIds);
const newStreams = data.filter((s) => selectedIdSet.has(s.id));
await API.addStreamsToChannel(
targetChannelId,
channelSelectionStreams,
newStreams
);
};
const onRowSelectionChange = (updatedIds) => {
@ -1267,20 +1247,12 @@ const StreamsTable = ({ onReady }) => {
editStream={editStream}
deleteStream={deleteStream}
handleWatchStream={handleWatchStream}
selectedChannelIds={selectedChannelIds}
createChannelFromStream={createChannelFromStream}
/>
);
}
},
[
selectedChannelIds,
channelSelectionStreams,
theme,
editStream,
deleteStream,
handleWatchStream,
]
[theme, editStream, deleteStream, handleWatchStream]
);
const table = useTable({
@ -1327,19 +1299,73 @@ const StreamsTable = ({ onReady }) => {
* useEffects
*/
useEffect(() => {
// Load data independently, don't wait for logos or other data
fetchData();
}, [fetchData]);
// Load page rows independently, don't wait for logos or other data
fetchPageData();
}, [fetchPageData]);
// Refetch data when video player closes to update stream stats
// The full ID list and filter options only depend on filters, not pagination
// or sort order, so they get their own effects to avoid refetching on every
// page change or sort toggle.
useEffect(() => {
const params = buildFilterParams();
const paramsString = params.toString();
if (lastIdsParamsRef.current === paramsString) {
return;
}
lastIdsParamsRef.current = paramsString;
let cancelled = false;
(async () => {
const ids = await API.getAllStreamIds(params);
if (!cancelled && ids) {
setAllRowIds(ids);
}
})();
return () => {
cancelled = true;
};
}, [buildFilterParams, setAllRowIds]);
useEffect(() => {
const params = buildFilterParams();
const paramsString = params.toString();
if (lastFilterOptionsParamsRef.current === paramsString) {
return;
}
lastFilterOptionsParamsRef.current = paramsString;
let cancelled = false;
(async () => {
const filterOptions = await API.getStreamFilterOptions(params);
if (cancelled || !filterOptions || typeof filterOptions !== 'object') {
return;
}
setGroupOptions(
(filterOptions.groups || [])
.filter((group) => group != null && group !== '')
.map((group) => String(group))
);
setM3uOptions(
(filterOptions.m3u_accounts || [])
.filter((m3u) => m3u && m3u.id != null && m3u.name)
.map((m3u) => ({
label: String(m3u.name),
value: String(m3u.id),
}))
);
})();
return () => {
cancelled = true;
};
}, [buildFilterParams]);
// Refetch page rows when video player closes to update stream stats
const prevVideoVisible = useRef(false);
useEffect(() => {
if (prevVideoVisible.current && !videoIsVisible) {
// Video was closed, refetch to get updated stream stats
fetchData({ showLoader: false });
fetchPageData({ showLoader: false });
}
prevVideoVisible.current = videoIsVisible;
}, [videoIsVisible, fetchData]);
}, [videoIsVisible, fetchPageData]);
useEffect(() => {
if (
@ -1462,14 +1488,13 @@ const StreamsTable = ({ onReady }) => {
>
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip
label="Add selected stream(s) to the selected channel"
label="Add selected stream(s) to the target channel"
openDelay={500}
>
<Button
leftSection={<SquarePlus size={18} />}
variant={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
selectedStreamIds.length > 0 && targetChannelId
? 'light'
: 'default'
}
@ -1477,14 +1502,12 @@ const StreamsTable = ({ onReady }) => {
onClick={addStreamsToChannel}
p={5}
color={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
selectedStreamIds.length > 0 && targetChannelId
? theme.tailwind.green[5]
: undefined
}
style={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
selectedStreamIds.length > 0 && targetChannelId
? {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
@ -1492,12 +1515,7 @@ const StreamsTable = ({ onReady }) => {
}
: undefined
}
disabled={
!(
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
)
}
disabled={!(selectedStreamIds.length > 0 && targetChannelId)}
>
Add to Channel
</Button>

View file

@ -2,6 +2,7 @@ import React, { useMemo, useCallback, useState } from 'react';
import API from '../../api';
import UserForm from '../forms/User';
import useUsersStore from '../../store/users';
import useChannelsStore from '../../store/channels';
import useAuthStore from '../../store/auth';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
import useWarningsStore from '../../store/warnings';
@ -17,12 +18,55 @@ import {
useMantineTheme,
LoadingOverlay,
Stack,
Badge,
Tooltip,
} from '@mantine/core';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
const XCPasswordCell = ({ getValue }) => {
const [isVisible, setIsVisible] = useState(false);
const customProps = getValue() || {};
const password = customProps.xc_password || 'N/A';
return (
<Group
gap={4}
style={{
alignItems: 'center',
overflow: 'hidden',
flexWrap: 'nowrap',
}}
>
<Text
size="sm"
style={{
fontFamily: 'monospace',
flex: '1 1 0',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{password === 'N/A' ? 'N/A' : isVisible ? password : '••••••••'}
</Text>
{password !== 'N/A' && (
<ActionIcon
size="xs"
variant="transparent"
color="gray"
onClick={() => setIsVisible((v) => !v)}
>
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
</ActionIcon>
)}
</Group>
);
};
const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const authUser = useAuthStore((s) => s.user);
@ -39,32 +83,30 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Group gap={2} justify="center">
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
disabled={authUser.user_level !== USER_LEVELS.ADMIN}
>
<SquarePen size="18" />
</ActionIcon>
<Group gap={2} justify="center" wrap="nowrap">
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
disabled={authUser.user_level !== USER_LEVELS.ADMIN}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
disabled={
authUser.user_level !== USER_LEVELS.ADMIN ||
authUser.id === row.original.id
}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
</Box>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
disabled={
authUser.user_level !== USER_LEVELS.ADMIN ||
authUser.id === row.original.id
}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
);
};
@ -76,6 +118,7 @@ const UsersTable = () => {
* STORES
*/
const users = useUsersStore((s) => s.users);
const profiles = useChannelsStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -90,17 +133,6 @@ const UsersTable = () => {
const [userToDelete, setUserToDelete] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [visiblePasswords, setVisiblePasswords] = useState({});
/**
* Functions
*/
const togglePasswordVisibility = useCallback((userId) => {
setVisiblePasswords((prev) => ({
...prev,
[userId]: !prev[userId],
}));
}, []);
const executeDeleteUser = useCallback(async (id) => {
setIsLoading(true);
@ -137,12 +169,22 @@ const UsersTable = () => {
/**
* useMemo
*/
// Create a profile ID to name lookup map for efficient rendering
const profileIdToName = useMemo(() => {
const map = {};
Object.values(profiles).forEach((profile) => {
map[profile.id] = profile.name;
});
return map;
}, [profiles]);
const columns = useMemo(
() => [
{
header: 'User Level',
accessorKey: 'user_level',
size: 120,
minSize: 80,
cell: ({ getValue }) => (
<Text size="sm">{USER_LEVEL_LABELS[getValue()]}</Text>
),
@ -150,7 +192,8 @@ const UsersTable = () => {
{
header: 'Username',
accessorKey: 'username',
size: 150,
size: 120,
minSize: 75,
cell: ({ getValue }) => (
<Box
style={{
@ -166,6 +209,8 @@ const UsersTable = () => {
{
id: 'name',
header: 'Name',
size: 125,
minSize: 50,
accessorFn: (row) =>
`${row.first_name || ''} ${row.last_name || ''}`.trim(),
cell: ({ getValue }) => (
@ -183,7 +228,8 @@ const UsersTable = () => {
{
header: 'Email',
accessorKey: 'email',
grow: true,
size: 125,
minSize: 50,
cell: ({ getValue }) => (
<Box
style={{
@ -199,7 +245,8 @@ const UsersTable = () => {
{
header: 'Date Joined',
accessorKey: 'date_joined',
size: 125,
size: 90,
minSize: 90,
cell: ({ getValue }) => {
const date = getValue();
return (
@ -211,6 +258,7 @@ const UsersTable = () => {
header: 'Last Login',
accessorKey: 'last_login',
size: 175,
minSize: 85,
cell: ({ getValue }) => {
const date = getValue();
return (
@ -224,33 +272,35 @@ const UsersTable = () => {
header: 'XC Password',
accessorKey: 'custom_properties',
size: 125,
minSize: 95,
enableSorting: false,
cell: ({ getValue, row }) => {
const userId = row.original.id;
const isVisible = visiblePasswords[userId];
// Extract xc_password from custom_properties
let password = 'N/A';
const customProps = getValue() || {};
password = customProps.xc_password || 'N/A';
cell: XCPasswordCell,
},
{
header: 'Channel Profiles',
accessorKey: 'channel_profiles',
size: 120,
minSize: 116,
grow: true,
cell: ({ getValue }) => {
const userProfiles = getValue() || [];
const profileNames = userProfiles
.map((id) => profileIdToName[id])
.filter(Boolean); // Filter out any undefined values
return (
<Group gap={4} style={{ alignItems: 'center' }}>
<Text
size="sm"
style={{ fontFamily: 'monospace', minWidth: '60px' }}
>
{password === 'N/A' ? 'N/A' : isVisible ? password : '••••••••'}
</Text>
{password !== 'N/A' && (
<ActionIcon
size="xs"
variant="transparent"
color="gray"
onClick={() => togglePasswordVisibility(userId)}
>
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
</ActionIcon>
<Group gap={4} wrap="wrap" py={4}>
{profileNames.length > 0 ? (
profileNames.map((name, index) => (
<Tooltip key={index} label={name} withArrow>
<Badge size="sm" variant="light" color="gray">
{name}
</Badge>
</Tooltip>
))
) : (
<Badge size="sm" variant="light" color="gray">
All
</Badge>
)}
</Group>
);
@ -258,9 +308,10 @@ const UsersTable = () => {
},
{
id: 'actions',
size: 80,
size: 65,
header: 'Actions',
enableSorting: false,
enableResizing: false,
cell: ({ row }) => (
<UserRowActions
theme={theme}
@ -271,15 +322,7 @@ const UsersTable = () => {
),
},
],
[
theme,
editUser,
deleteUser,
visiblePasswords,
togglePasswordVisibility,
fullDateFormat,
fullDateTimeFormat,
]
[theme, editUser, deleteUser, fullDateFormat, fullDateTimeFormat]
);
const closeUserForm = () => {
@ -318,6 +361,7 @@ const UsersTable = () => {
user_level: renderHeaderCell,
last_login: renderHeaderCell,
date_joined: renderHeaderCell,
channel_profiles: renderHeaderCell,
custom_properties: renderHeaderCell,
},
});

View file

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Box, Button, Group } from '@mantine/core';
import { formatKB } from '../../utils/networkUtils.js';
const SizedInstallButton = ({
latest_size,
children,
color,
loading,
disabled,
onClick,
...buttonProps
}) => {
const [hovered, setHovered] = useState(false);
if (!Number.isFinite(latest_size) || latest_size <= 0) {
return (
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children}
</Button>
);
}
const isDisabled = disabled || loading;
const colorVar = color
? `var(--mantine-color-${color}-filled)`
: 'var(--mantine-primary-color-filled)';
return (
<Group
gap={0}
align="stretch"
wrap="nowrap"
onMouseEnter={() => {
if (!isDisabled) setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
>
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
styles={
hovered && !isDisabled
? {
root: {
background: color
? `var(--mantine-color-${color}-filled-hover)`
: 'var(--mantine-primary-color-filled-hover)',
},
}
: undefined
}
{...buttonProps}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{children}
</Button>
<Box
onClick={!isDisabled ? onClick : undefined}
style={{
background: isDisabled
? colorVar
: hovered
? color
? `var(--mantine-color-${color}-filled-hover)`
: 'var(--mantine-primary-color-filled-hover)'
: colorVar,
filter: isDisabled
? 'brightness(0.65) saturate(0.7)'
: hovered
? 'brightness(0.86)'
: 'brightness(0.82)',
borderLeft: '1px solid rgba(0,0,0,0.2)',
borderTopRightRadius: 'var(--mantine-radius-sm)',
borderBottomRightRadius: 'var(--mantine-radius-sm)',
display: 'flex',
alignItems: 'center',
padding: '0 9px',
fontSize: 11,
color: '#fff',
cursor: isDisabled ? 'default' : 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{formatKB(latest_size)}
</Box>
</Group>
);
};
export default SizedInstallButton;

View file

@ -403,4 +403,6 @@ export const SUBSCRIPTION_EVENTS = {
login_failed: 'Login Failed',
epg_blocked: 'EPG Blocked',
m3u_blocked: 'M3U Blocked',
vod_start: 'VOD Started',
vod_stop: 'VOD Stopped',
};

View file

@ -26,6 +26,7 @@ import {
KeyRound,
ShieldCheck,
ShieldAlert,
Package,
} from 'lucide-react';
import { usePluginStore } from '../store/plugins.jsx';
import useSettingsStore from '../store/settings.jsx';
@ -364,6 +365,18 @@ export default function PluginBrowsePage() {
)}
</Group>
<Group>
<Button
size="xs"
variant="light"
color="teal"
component="a"
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
target="_blank"
rel="noopener noreferrer"
leftSection={<Package size={14} />}
>
Publish Your Plugin
</Button>
<Button
size="xs"
variant="light"

View file

@ -41,6 +41,11 @@ import {
} from '../utils/pages/PluginsUtils.js';
import { RefreshCcw, Search } from 'lucide-react';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
import {
PluginRestartWarning,
PluginSecurityWarning,
PluginSupportDisclaimer,
} from '../components/PluginWarnings.jsx';
const PluginCard = React.lazy(
() => import('../components/cards/PluginCard.jsx')
);
@ -426,11 +431,8 @@ export default function PluginsPage() {
<Text size="sm" c="dimmed">
Upload a ZIP containing your plugin folder or package.
</Text>
<Alert color="yellow" variant="light" title="Heads up">
Importing a plugin may briefly restart the backend (you might see a
temporary disconnect). Please wait a few seconds and the app will
reconnect automatically.
</Alert>
<PluginRestartWarning />
<PluginSupportDisclaimer />
<Dropzone
onDrop={(files) => files[0] && setImportFile(files[0])}
onReject={() => {}}
@ -536,16 +538,14 @@ export default function PluginsPage() {
zIndex={300}
>
<Stack>
<Text size="sm">
<PluginSecurityWarning>
Plugins run server-side code with full access to your Dispatcharr
instance and its data. Only enable plugins from developers you
trust.
</Text>
<Text size="sm" c="dimmed">
Why: Malicious plugins could read or modify data, call internal
trust. Malicious plugins could read or modify data, call internal
APIs, or perform unwanted actions. Review the source or trust the
author before enabling.
</Text>
</PluginSecurityWarning>
<PluginSupportDisclaimer />
<Group justify="flex-end">
<Button
variant="default"

View file

@ -99,10 +99,11 @@ const Connections = ({
const StatsPage = () => {
const channelStats = useChannelsStore((s) => s.stats);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const vodConnections = useChannelsStore((s) => s.activeVodConnections);
const setVodStats = useChannelsStore((s) => s.setVodStats);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const [clients, setClients] = useState([]);
const [vodConnections, setVodConnections] = useState([]);
const [channelHistory, setChannelHistory] = useState({});
const [isPollingActive, setIsPollingActive] = useState(false);
const [currentPrograms, setCurrentPrograms] = useState({});
@ -197,7 +198,7 @@ const StatsPage = () => {
try {
const response = await getVODStats();
if (response) {
setVodConnections(response.vod_connections || []);
setVodStats(response);
} else {
console.log('VOD API response was empty or null');
}
@ -209,7 +210,7 @@ const StatsPage = () => {
body: error.body,
});
}
}, []);
}, [setVodStats]);
// Set up polling for stats when on stats page
useEffect(() => {

View file

@ -159,12 +159,14 @@ describe('StatsPage', () => {
let mockSetChannelStats;
let mockSetRefreshInterval;
let mockSetVodStats;
beforeEach(() => {
vi.clearAllMocks();
mockSetChannelStats = vi.fn();
mockSetRefreshInterval = vi.fn();
mockSetVodStats = vi.fn();
// Setup store mocks
useChannelsStore.mockImplementation((selector) => {
@ -173,6 +175,8 @@ describe('StatsPage', () => {
channelsByUUID: mockChannelsByUUID,
stats: { channels: mockChannelStats.channels },
setChannelStats: mockSetChannelStats,
activeVodConnections: mockVODStats.vod_connections,
setVodStats: mockSetVodStats,
};
return selector ? selector(state) : state;
});
@ -505,6 +509,18 @@ describe('StatsPage', () => {
};
getVODStats.mockResolvedValue(multiVODStats);
useChannelsStore.mockImplementation((selector) => {
const state = {
channels: mockChannels,
channelsByUUID: mockChannelsByUUID,
stats: { channels: mockChannelStats.channels },
setChannelStats: mockSetChannelStats,
activeVodConnections: multiVODStats.vod_connections,
setVodStats: mockSetVodStats,
};
return selector ? selector(state) : state;
});
render(<StatsPage />);
await waitFor(() => {

View file

@ -310,7 +310,9 @@ describe('useChannelsStore', () => {
});
const newStats = {
channels: [{ channel_id: 'uuid-1', clients: [] }],
channels: [
{ channel_id: 'uuid-1', clients: [{ client_id: 'client-1' }] },
],
};
act(() => {

View file

@ -1,9 +1,44 @@
import { create } from 'zustand';
import api from '../api';
import { showNotification } from '../utils/notificationUtils.js';
import useUsersStore from './users';
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
// Seconds-precision timestamp recorded when this module is first loaded.
// Compared against client.connected_at (also in seconds) to distinguish connections
// that were already active before the page loaded from ones that started after.
const pageLoadTime = Date.now() / 1000;
// Returns true when a client connected after the page was loaded (genuinely new).
// Falls back to true when connected_at is absent so we don't silently drop notifications.
const isClientNewSincePageLoad = (client) =>
!client.connected_at || client.connected_at >= pageLoadTime;
// Resolve identity info for a client: { username, ip }.
// username is null when no user account is linked.
const getClientIdentity = (client) => {
let username = null;
if (client?.user_id && client.user_id !== '0') {
const users = useUsersStore.getState().users;
const user = users.find((u) => String(u.id) === String(client.user_id));
if (user?.username) username = user.username;
}
return { username, ip: client?.ip_address || 'unknown' };
};
// Build a two-line notification message: channel name on top, identity below.
const clientMessage = (channelName, client) => {
const { username, ip } = getClientIdentity(client);
const identity = username ? `${username} (${ip})` : ip;
return (
<>
<div>{channelName}</div>
<div style={{ marginTop: 2 }}>{identity}</div>
</>
);
};
const reduceChannels = (channels) => {
const channelsByUUID = {};
const channelsByID = channels.reduce((acc, channel) => {
@ -14,90 +49,60 @@ const reduceChannels = (channels) => {
return { channelsByUUID, channelsByID };
};
const showNotificationIfNewChannel = (
currentStats,
const showNotificationIfChannelStopped = (
oldChannels,
ch,
newChannels,
channelsByUUID,
channels
) => {
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
// Add null checks to prevent accessing properties on undefined
const channelId = channelsByUUID[ch.channel_id];
const channel = channelId ? channels[channelId] : null;
if (channel) {
showNotification({
title: 'New channel streaming',
message: channel.name,
color: 'blue.5',
});
}
}
}
};
const showNotificationIfNewClient = (currentStats, oldClients, client) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
if (oldClients[client.client_id] === undefined) {
// Safe on first poll: oldChannels is {} so the loop body never runs and no false "stopped" notifications fire.
for (const uuid in oldChannels) {
if (newChannels[uuid] === undefined) {
const channelId = channelsByUUID[uuid];
const channel = channelId && channels[channelId];
const channelName =
channel?.name || oldChannels[uuid]?.channel_name || `Channel (${uuid})`;
showNotification({
title: 'New client started streaming',
message: `Client streaming from ${client.ip_address}`,
title: 'Channel streaming stopped',
message: channelName,
color: 'blue.5',
});
}
}
};
const showNotificationIfChannelStopped = (
currentStats,
oldChannels,
newChannels,
const showNotificationIfClientStopped = (
oldClients,
newClients,
channelsByUUID,
channels
) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
if (newChannels[uuid] === undefined) {
// Add null check for channel name
const channelId = channelsByUUID[uuid];
const channel = channelId && channels[channelId];
if (channel) {
showNotification({
title: 'Channel streaming stopped',
message: channel.name,
color: 'blue.5',
});
} else {
showNotification({
title: 'Channel streaming stopped',
message: `Channel (${uuid})`,
color: 'blue.5',
});
}
}
}
}
};
const showNotificationIfClientStopped = (
currentStats,
oldClients,
newClients
) => {
if (currentStats.channels) {
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
showNotification({
title: 'Client stopped streaming',
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
color: 'blue.5',
});
}
// Safe on first poll: oldClients is {} so the loop body never runs and no false "stopped" notifications fire.
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
const client = oldClients[clientId];
const channelId = client?.channel_id
? channelsByUUID[client.channel_id]
: undefined;
const channel = channelId && channels[channelId];
const channelName =
channel?.name ||
client?.channel_name ||
(client?.channel_id ? `Channel (${client.channel_id})` : null);
const { username, ip } = getClientIdentity(client);
const identity = username ? `${username} (${ip})` : ip;
showNotification({
title: 'Client stopped streaming',
message: channelName ? (
<>
<div>{channelName}</div>
<div style={{ marginTop: 2 }}>{identity}</div>
</>
) : (
identity
),
color: 'blue.5',
});
}
}
};
@ -113,6 +118,7 @@ const useChannelsStore = create((set, get) => ({
stats: {},
activeChannels: {},
activeClients: {},
activeVodConnections: [],
recordings: [],
recurringRules: [],
isLoading: false,
@ -414,7 +420,6 @@ const useChannelsStore = create((set, get) => ({
return set((state) => {
const {
channels,
stats: currentStats,
activeChannels: oldChannels,
activeClients: oldClients,
channelsByUUID,
@ -427,28 +432,68 @@ const useChannelsStore = create((set, get) => ({
}, {});
stats.channels.forEach((ch) => {
showNotificationIfNewChannel(
currentStats,
oldChannels,
ch,
channelsByUUID,
channels
);
const channelId = channelsByUUID[ch.channel_id];
const channel = channelId ? channels[channelId] : null;
const channelName =
channel?.name || ch.channel_name || `Channel (${ch.channel_id})`;
const isNewChannel = oldChannels[ch.channel_id] === undefined;
ch.clients.forEach((client) => {
newClients[client.client_id] = client;
showNotificationIfNewClient(currentStats, oldClients, client);
});
if (isNewChannel) {
// Only notify for clients that connected after the page loaded.
// This naturally suppresses pre-existing connections on the first poll
// while still firing for connections that started mid-session.
const genuinelyNewClients = ch.clients.filter(
(client) =>
oldClients[client.client_id] === undefined &&
isClientNewSincePageLoad(client)
);
if (genuinelyNewClients.length > 0) {
showNotification({
title: 'Channel started streaming',
message: clientMessage(channelName, genuinelyNewClients[0]),
color: 'blue.5',
});
genuinelyNewClients.slice(1).forEach((client) => {
showNotification({
title: 'New client started streaming',
message: clientMessage(channelName, client),
color: 'blue.5',
});
});
}
} else {
// Existing channel, notify only for clients that just joined.
ch.clients.forEach((client) => {
if (
oldClients[client.client_id] === undefined &&
isClientNewSincePageLoad(client)
) {
showNotification({
title: 'New client started streaming',
message: clientMessage(channelName, client),
color: 'blue.5',
});
}
});
}
});
showNotificationIfChannelStopped(
currentStats,
oldChannels,
newChannels,
channelsByUUID,
channels
);
showNotificationIfClientStopped(currentStats, oldClients, newClients);
showNotificationIfClientStopped(
oldClients,
newClients,
channelsByUUID,
channels
);
return {
stats,
@ -458,6 +503,10 @@ const useChannelsStore = create((set, get) => ({
});
},
setVodStats: (stats) => {
set({ activeVodConnections: stats.vod_connections || [] });
},
fetchRecordings: async () => {
try {
set({ recordings: await api.getRecordings() });
@ -498,7 +547,8 @@ const useChannelsStore = create((set, get) => ({
};
}
if (current && typeof current === 'object') {
if (!Object.values(current).some((r) => String(r?.id) === target)) return {};
if (!Object.values(current).some((r) => String(r?.id) === target))
return {};
const next = { ...current };
for (const k of Object.keys(next)) {
try {

View file

@ -1,5 +1,8 @@
import { create } from 'zustand';
// Stable empty array to avoid creating new references in getChannelStreams
const emptyStreams = [];
const useChannelsTableStore = create((set, get) => ({
channels: [],
pageCount: 0,
@ -12,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({
JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
},
selectedChannelIds: [],
expandedChannelId: null,
allQueryIds: [],
isUnlocked: false,
@ -38,9 +42,15 @@ const useChannelsTableStore = create((set, get) => ({
});
},
setExpandedChannelId: (expandedChannelId) => {
set({
expandedChannelId,
});
},
getChannelStreams: (id) => {
const channel = get().channels.find((c) => c.id === id);
return channel?.streams ?? [];
return channel?.streams || emptyStreams;
},
setPagination: (pagination) => {
@ -66,6 +76,45 @@ const useChannelsTableStore = create((set, get) => ({
),
}));
},
/**
* Merges stream-stats deltas into the target channel's streams. Preserves
* object identity for unchanged streams and channels so memoized rows
* don't re-render.
*/
patchChannelStreamStats: (channelId, updates) => {
if (!Array.isArray(updates) || updates.length === 0) return;
set((state) => {
const updateMap = new Map(updates.map((u) => [u.id, u]));
let channelChanged = false;
const nextChannels = state.channels.map((channel) => {
if (channel.id !== channelId) return channel;
const streams = channel.streams || [];
let streamsChanged = false;
const nextStreams = streams.map((stream) => {
const u = updateMap.get(stream.id);
if (!u) return stream;
if (
stream.stream_stats_updated_at === u.stream_stats_updated_at &&
stream.stream_stats === u.stream_stats
) {
return stream;
}
streamsChanged = true;
return {
...stream,
stream_stats: u.stream_stats,
stream_stats_updated_at: u.stream_stats_updated_at,
};
});
if (!streamsChanged) return channel;
channelChanged = true;
return { ...channel, streams: nextStreams };
});
if (!channelChanged) return state;
return { channels: nextChannels };
});
},
}));
export default useChannelsTableStore;

View file

@ -203,6 +203,52 @@ describe('dateTimeUtils', () => {
});
});
describe('formatExactDuration', () => {
it('should show seconds with one decimal place under a minute', () => {
expect(dateTimeUtils.formatExactDuration(45.6)).toBe('45.6 seconds');
});
it('should use singular second when exactly 1 second', () => {
expect(dateTimeUtils.formatExactDuration(1.0)).toBe('1.0 seconds');
});
it('should show minutes and seconds between 1 and 60 minutes', () => {
expect(dateTimeUtils.formatExactDuration(5 * 60 + 23)).toBe(
'5 minutes, 23 seconds'
);
});
it('should use singular minute/second at exactly 1m 1s', () => {
expect(dateTimeUtils.formatExactDuration(61)).toBe('1 minute, 1 second');
});
it('should show hours and minutes between 1 and 24 hours', () => {
expect(dateTimeUtils.formatExactDuration(2 * 3600 + 15 * 60)).toBe(
'2 hours, 15 minutes'
);
});
it('should use singular hour/minute at exactly 1h 1m', () => {
expect(dateTimeUtils.formatExactDuration(3660)).toBe('1 hour, 1 minute');
});
it('should show days and hours at 24 hours or more', () => {
expect(dateTimeUtils.formatExactDuration(2 * 86400 + 4 * 3600)).toBe(
'2 days, 4 hours'
);
});
it('should use singular day/hour at exactly 1d 1h', () => {
expect(dateTimeUtils.formatExactDuration(86400 + 3600)).toBe(
'1 day, 1 hour'
);
});
it('should show 0 seconds correctly', () => {
expect(dateTimeUtils.formatExactDuration(0)).toBe('0.0 seconds');
});
});
describe('fromNow', () => {
it('should return relative time from now', () => {
const pastDate = dayjs().subtract(1, 'hour').toISOString();

View file

@ -1,9 +1,7 @@
import API from '../../api.js';
import {
format,
getNow,
initializeTime,
subtract,
toFriendlyDuration,
} from '../dateTimeUtils.js';
@ -70,33 +68,24 @@ export const switchStream = (channel, streamId) => {
export const connectedAccessor = (fullDateTimeFormat) => {
return (row) => {
// Check for connected_since (which is seconds since connection)
if (row.connected_since) {
// Calculate the actual connection time by subtracting the seconds from current time
const connectedTime = subtract(getNow(), row.connected_since, 'second');
return format(connectedTime, fullDateTimeFormat);
}
// Fallback to connected_at if it exists
if (row.connected_at) {
const connectedTime = initializeTime(row.connected_at * 1000);
return format(connectedTime, fullDateTimeFormat);
return format(
initializeTime(row.connected_at * 1000),
fullDateTimeFormat
);
}
return 'Unknown';
};
};
export const durationAccessor = () => {
return (row) => {
if (row.connected_since) {
return toFriendlyDuration(row.connected_since, 'seconds');
if (row.connected_at) {
return toFriendlyDuration(
Date.now() / 1000 - row.connected_at,
'seconds'
);
}
if (row.connection_duration) {
return toFriendlyDuration(row.connection_duration, 'seconds');
}
return '-';
};
};

View file

@ -168,42 +168,22 @@ describe('StreamConnectionCardUtils', () => {
});
describe('connectedAccessor', () => {
it('should format connected_since correctly', () => {
const mockNow = new Date('2024-01-01T12:00:00');
const mockConnectedTime = new Date('2024-01-01T10:00:00');
dateTimeUtils.getNow.mockReturnValue(mockNow);
dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
const accessor = StreamConnectionCardUtils.connectedAccessor(
'MM/DD/YYYY, HH:mm:ss'
);
const result = accessor({ connected_since: 7200 });
expect(dateTimeUtils.subtract).toHaveBeenCalledWith(
mockNow,
7200,
'second'
);
expect(dateTimeUtils.format).toHaveBeenCalledWith(
mockConnectedTime,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/01/2024 10:00:00');
});
it('should fallback to connected_at when connected_since is missing', () => {
it('should format connected_at correctly', () => {
const mockTime = new Date('2024-01-01T10:00:00');
dateTimeUtils.initializeTime.mockReturnValue(mockTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
const accessor =
StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const accessor = StreamConnectionCardUtils.connectedAccessor(
'MM/DD/YYYY, HH:mm:ss'
);
const result = accessor({ connected_at: 1704103200 });
expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
expect(dateTimeUtils.format).toHaveBeenCalledWith(
mockTime,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/01/2024 10:00:00');
});
@ -216,33 +196,22 @@ describe('StreamConnectionCardUtils', () => {
});
describe('durationAccessor', () => {
it('should format connected_since duration', () => {
it('should compute duration from connected_at', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('2h 30m');
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connected_since: 9000 });
// connected_at 9000 seconds before "now" (Date.now() / 1000)
const connectedAt = Date.now() / 1000 - 9000;
const result = accessor({ connected_at: connectedAt });
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
9000,
expect.closeTo(9000, 1),
'seconds'
);
expect(result).toBe('2h 30m');
});
it('should fallback to connection_duration', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 15m');
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connection_duration: 4500 });
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
4500,
'seconds'
);
expect(result).toBe('1h 15m');
});
it('should return - when no duration data available', () => {
it('should return - when no connected_at available', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({});
expect(result).toBe('-');

View file

@ -43,6 +43,23 @@ export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) =>
dayjs.duration(dateTime, unit).humanize();
export const formatExactDuration = (seconds) => {
if (seconds < 60) return `${seconds.toFixed(1)} seconds`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m} minute${m !== 1 ? 's' : ''}, ${s} second${s !== 1 ? 's' : ''}`;
}
if (seconds < 86400) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h} hour${h !== 1 ? 's' : ''}, ${m} minute${m !== 1 ? 's' : ''}`;
}
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
return `${d} day${d !== 1 ? 's' : ''}, ${h} hour${h !== 1 ? 's' : ''}`;
};
export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
export const setTz = (dateTime, timeZone) => dayjs(dateTime).tz(timeZone);
@ -336,4 +353,4 @@ export const MONTH_ABBR = [
'oct',
'nov',
'dec',
];
];

View file

@ -15,6 +15,16 @@ export function formatBytes(bytes) {
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
// Converts a size in KB (as used in plugin manifests) to a human-readable string.
// Trailing zeros are stripped (e.g. "1 KB" not "1.00 KB").
export function formatKB(kb) {
const bytes = kb * 1024;
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
export function formatSpeed(bytes) {
if (bytes === 0) return '0 Bytes';