Merge branch 'dev' into feature/schedules-direct-epg

This commit is contained in:
Shokkstokk 2026-06-03 23:38:54 -04:00 committed by GitHub
commit 4eec542ef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 16103 additions and 2785 deletions

View file

@ -0,0 +1,31 @@
on:
issues:
types: [opened, reopened]
jobs:
check:
runs-on: ubuntu-latest
steps:
# Request a bot user token
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
# Do the actual check
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
event-type: issue
required-type: "Bug, Feature"
enforcement: close-and-lock
bypass-for-members: true
close-comment: |
## Issue not opened from a template
This issue was closed because it was not opened using one of the available issue templates.
Please [open a new issue]({new-issue-url}) and select the appropriate template. This helps us triage and address issues efficiently.

View file

@ -0,0 +1,65 @@
on:
pull_request_target:
types: [opened, reopened, edited]
jobs:
check:
runs-on: ubuntu-latest
steps:
# Request a bot user token
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
# Delete old bot comments before posting fresh ones
- uses: Dispatcharr/repo-bot/actions/comment-collapse@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
mode: delete
# Template + Agreement Check
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
event-type: pull_request
required-markers: "## How was it tested?, ## Checklist, - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)"
enforcement: comment-only
bypass-for-members: true
close-comment: |
## PR requirements not met
Your PR description is missing one or more required sections. Please ensure all of the following are present and filled out exactly as they appear in the [PR template](../blob/dev/.github/pull_request_template.md).:
- **How was it tested?** Heading (describe how you verified your changes)
- **Checklist** Heading (completed from the [pull request template](../blob/dev/.github/pull_request_template.md))
- **Contributor License Agreement** Checklist Item (the following item must appear checked in your description):
> - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)
Edit your PR description to add any missing items. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) for full contribution guidelines. Pull requests that do not follow the template, or that are wholly AI-generated or CLI-created, will be closed.
# Target Check
- uses: Dispatcharr/repo-bot/actions/branch-guard@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
allowed-targets: "dev"
enforcement: comment-only
bypass-for-members: true
comment: |
## Wrong target branch
This PR targets `{target-branch}`, but all contributions must target the `dev` branch.
> **To fix this:**
> 1. Open the PR and click **Edit** next to the title
> 2. Change the base branch from `{target-branch}` to `dev`
> 3. Save the change
Unsure about our contribution guidelines? See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md).

View file

@ -9,6 +9,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Live EPG program preview in the channel create/edit modal.** When selecting an EPG channel in the channel form, a "Current Program" card appears showing the currently-airing program title, description, and a progress bar. The backend builds a byte-offset index over the raw XMLTV file after each EPG refresh so lookups seek directly to the relevant file positions rather than parsing the full file - returning results in 1-10ms regardless of source size. If the index has not yet been built for a source, the API dispatches an async background build and the frontend retries up to 20 times over a 3-minute window. The `ProgramPreview` component was extracted into a shared component reused by the Stats page. The `programme_index` field on `EPGSource` is excluded from all API list responses to avoid returning multi-MB blobs. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
- **Public IP display in the sidebar is now blurred by default and reveals on click.** Prevents accidental exposure in screenshots and screen shares. A new toggle in Settings > System Settings lets users disable IP and geolocation fetching entirely. A `DISPATCHARR_ENABLE_IP_LOOKUP` environment variable provides a container-level override; when set to `false` the toggle is hidden from the UI and cannot be changed. (Closes #1302) — Thanks [@sethwv](https://github.com/sethwv)
- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv)
- **VOD basic sync now stores additional metadata from the provider's stream list.** When a provider includes `director`, `cast`, `release_date`, or `trailer`/`youtube_trailer` in its `get_vod_streams` response, Dispatcharr now captures and stores those fields in `custom_properties` during the initial sync pass. Previously, this data was discarded and only populated if an advanced per-movie refresh was triggered. - Thanks [@nemesbak](https://github.com/nemesbak)
### Changed
- **Channel edit form reorganized into three semantic columns.** Fields are now grouped as Identity (name, number, group, logo), Guide Data (TVG-ID, Gracenote StationId, EPG picker, current program preview), and Behavior/Access (stream profile, user level, mature content, hidden). The EPG section having its own column gives the `ProgramPreview` card enough width to truncate long titles correctly rather than expanding the column.
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
- **Docker base image now uses a multi-stage build.** The builder stage installs compilers and dev headers (`gcc`, `g++`, `gfortran`, `build-essential`, `libopenblas-dev`, `libpcre3-dev`, `python3.13-dev`, `ninja-build`) to create the virtual environment and compile the legacy NumPy wheel (cpu-baseline=none for old hardware). The final runtime image starts from the same ffmpeg base, copies only the prebuilt venv and wheel from the builder, and installs only the runtime libraries needed at runtime - eliminating compiler binaries from production containers and reducing final image size. — Thanks [@kensac](https://github.com/kensac)
- **`libpq-dev` removed from both build and runtime stages.** The project uses `psycopg[binary]` (psycopg3), which bundles its own statically linked copy of libpq inside the wheel. No system libpq headers or shared library are needed at build or runtime.
- **Database driver upgraded from `psycopg2` to `psycopg3` (`psycopg[binary]`).** psycopg3 rewrote its network I/O layer in Python, so `gevent`'s `monkey.patch_all()` makes it gevent-cooperative without any additional patching. The `psycogreen` dependency and its driver-patching block in `gevent_patch.py` have been removed. Django's native psycopg3 connection pool is explicitly disabled (`pool: False`) so `django-db-geventpool` remains in sole control of connection lifecycle. The `dropdb` management command was updated to the `psycopg` API (`psycopg.connect`, `psycopg.sql`).
- **Frontend unit tests extended to additional form components and the Guide page.** `ProgramRecordingModal`, `Recording`, `RecordingDetailsModal`, `RecurringRuleModal`, `ScheduleInput`, `SeriesRecordingModal`, `SeriesRuleEditorModal`, `Stream`, `StreamProfile`, `SuperuserForm`, `User`, `UserAgent`, and `VODCategoryFilter` now have Vitest + Testing Library test suites. Business logic was extracted from these components into corresponding utility modules and is exercised through the new tests. The Guide page was similarly refactored with extracted utils, and `dateTimeUtils.js` was extended to support the new test coverage. — Thanks [@nick4810](https://github.com/nick4810)
### Performance
- **`get_vod_streams` and `get_series` XC API response times reduced significantly for large libraries.** Both endpoints previously loaded all active relations per title (one row per account that carries the same movie/series), then discarded duplicates in Python. With large libraries across multiple active accounts this fetched a multiple of N rows. Both queries now use a single `DISTINCT ON (movie_id/series_id)` pass over the relation table ordered by account priority, returning exactly one row per title. Logo URL construction (`reverse()` + `build_absolute_uri_with_port()`) is also computed once and reused via prefix/suffix string concatenation, matching the existing pattern in `get_live_streams`. The `m3u_account` JOIN in `get_series` was also removed (it was fetched but never read in the loop). Additionally, `get_series` previously had no `order_by` on `M3USeriesRelation`, so output order was non-deterministic (physical storage order). Both endpoints now sort alphabetically by title. Observed improvements: 18s → 12s for 48k movies; 9.5s → 3.5s for 11k series.
- **Database connections are now managed by a persistent per-worker pool.** Previously each request opened a fresh TCP connection to PostgreSQL, paid a full authentication handshake, and closed the connection at request end. `django-db-geventpool` now maintains a pool of warm connections per uWSGI worker; requests borrow a connection and return it when done, eliminating connection-setup overhead on every request. Pool size is bounded (`MAX_CONNS=8` per worker, `REUSE_CONNS=3` warm connections kept idle) to stay comfortably within PostgreSQL's default `max_connections=100` across all uWSGI workers, Celery workers, and Daphne. — Thanks [@JCBird1012](https://github.com/JCBird1012)
- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012)
### Security
- **M3U endpoint no longer reflects POST body content in error responses.** The error message for disallowed POST requests previously echoed the raw request body back to the caller in a `text/html` response, which could be used for reflected XSS. The body is no longer included in the response. - Thanks [@sebastiondev](https://github.com/sebastiondev)
### Fixed
- **Null TS keepalive packets during channel initialization caused Emby (and Jellyfin) to force a deinterlace transcode or fail playback entirely.** While waiting for a channel to become ready, the generator sent a null TS packet (PID `0x1FFF`) every 0.5 s to keep the HTTP connection alive. Emby probes the initial bytes of the response via ffprobe; receiving a null packet instead of real stream data produced incorrect codec metadata and triggered a forced transcode. The null-packet yield has been replaced with a plain `gevent.sleep(0.1)`. (Fixes #1280)
- **VOD proxy stream URLs survive M3U import refresh cycles.** When `process_movie_batch` / `process_series_batch` create duplicate content records during a refresh, existing `M3UMovieRelation` / `M3UEpisodeRelation` rows are repointed at the new records, orphaning the old UUIDs. External players (Emby, Jellyfin, ChannelsDVR) that cached `.strm` URLs with the dead UUID would then 404 even though the same request carried a stable `stream_id` that maps to an active relation. The proxy now falls back to stream_id resolution when the UUID lookup misses, using strictest-match-first logic (account-scoped relation preferred, then highest-priority account). A `[STREAMID-FALLBACK]` WARNING log keeps the underlying import churn. — Thanks [@R3XCHRIS](https://github.com/R3XCHRIS)
- **IP lookup no longer blocks the settings page load.** The `environment` endpoint previously made up to three sequential HTTP calls (ipify, ipapi.co, ip-api.com) with 5s timeouts each, blocking the page for up to 15s if any were unreachable. The lookups now run in a background thread on first request and results are cached in Redis for 1 hour, so the endpoint returns immediately on every call. — Thanks [@sethwv](https://github.com/sethwv)
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)
- **Per-channel stream profile override was ignored during streaming.** `Channel.get_stream_profile()` read `self.stream_profile` directly, bypassing any `ChannelOverride.stream_profile` set by the user on auto-synced channels. The method now resolves through `effective_stream_profile_obj`, which checks the channel's override record first and falls back to the channel's own field. Channels without an override continue to behave identically to before. (Fixes #1268) - Thanks [@nemesbak](https://github.com/nemesbak)
- **Web-player output profile was ignored for live streams started outside the Channels page.** `getShowVideoUrl` in `RecordingCardUtils.js` returned a raw proxy URL without calling `buildLiveStreamUrl`, so the output profile preference stored in `localStorage` was not applied when launching a stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, or Recording Card. Only the Channels page (StreamsTable) was calling `buildLiveStreamUrl` correctly. One additional import and one changed return value fix all affected entry points. (Fixes #1304) - Thanks [@nemesbak](https://github.com/nemesbak)
- **Plugins with available updates were not sorting to the top of the Plugin Browse list.** The sort weight function previously treated `update_available` plugins the same as any other installed plugin, leaving them buried. They now receive the highest sort priority (below the search/filter results header) so users can spot pending updates immediately. — Thanks [@sethwv](https://github.com/sethwv)
- **Disabled state on the size-labeled install button rendered with a warm tint instead of appearing clearly disabled.** The CSS filter was `brightness(0.65) saturate(0.7)`, which left a faint color cast. It is now `grayscale(1) brightness(0.55)`, matching the standard disabled appearance. — Thanks [@sethwv](https://github.com/sethwv)
- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state.
- **Cast and actors lists were silently truncated to the first name.** When a provider returns `cast` or `actors` as a JSON array, the helper used during both movie and series basic sync and movie advanced refresh only extracted the first element. All names in the array are now joined into a comma-separated string. Providers that return cast as a plain string are unaffected.
- **Channel Group Override interactions in compact numbering and override display.** Two related bugs surfaced when Channel Group Override was used with auto-synced channels. First, compact numbering silently failed: with an override on the source `ChannelGroupM3UAccount`, sync stores channels under the override target group's id (not the source group id), so hide/unhide/repack operations all missed their channels and slot accounting broke silently (hidden channels kept their numbers, unhides got none, repack saw zero channels). The compact paths now include an override-aware fallback to match channels under both source and override-target group ids. Second, the clear-override reset button disappeared when an override's stored value coincidentally matched the provider value. The frontend `isFormFieldOverridden` function was value-based only; it now also checks for a persisted override row regardless of value, so the reset button remains available to clear any override. (Fixes #1263, Fixes #1276) — Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.25.1] - 2026-05-23
### Fixed
- **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260)
- **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259)
- **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly.
- **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback.
### Performance
- **M3U playlist URL building moved outside the channel loop.** `generate_m3u` previously rebuilt the query-string suffix (and for XC requests, called `build_absolute_uri_with_port`) on every channel iteration even though both inputs are request-level constants. The XC base URL and both query-string suffixes (`xc_qs_suffix`, `proxy_qs_suffix`) are now computed once before the channel loop; per-channel URL assembly is a single f-string interpolation.
## [0.25.0] - 2026-05-21
### Security
- Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs:
- **CVE-2026-6907**: Cache leak exposing sensitive information via `cache.get_or_set()` race condition.
- **CVE-2026-35192**: Persistent session cookies retaining sensitive information after logout.
- **CVE-2026-5766**: Improper handling of length parameter inconsistency in multipart form parsing.
### Added
- **About modal.** A `?` button in the sidebar footer opens an About dialog showing the current version, links to Documentation, Discord, GitHub, and Open Collective, a contributors acknowledgment, and a memorial note for Jesse Mann. The button is visible in both expanded and collapsed sidebar states.
- **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option:
- **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut.
- **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this.
- **Comskip hardware acceleration setting.** DVR Settings now includes a "Hardware acceleration" option that passes a hardware-decode flag to the comskip binary, reducing CPU load during commercial detection on capable hosts:
- **None** (default): software decode.
- **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container.
- **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container.
- **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted:
- `/hdhr/output_profile/<id>/lineup.json` - output profile only
- `/hdhr/<channel_profile>/output_profile/<id>/lineup.json` - channel profile + output profile
@ -27,9 +102,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`POST /api/channels/series-rules/preview/`** returns up to 25 (configurable, max 100) upcoming programs that a candidate rule would match within the standard 7-day evaluation horizon, without persisting anything. Used by the new rule editor to give live feedback as the user types.
- **`GET /api/epg/programs/search/?tvg_id=`** filter parameter for exact `epg.tvg_id` matches.
- **Series rule editor modal** with form (title + match mode, description + match mode, episodes mode, pinned channel) and a debounced (500ms) preview pane backed by an `AbortController` so per-keystroke calls don't pile up. A "Customize rule..." link in the program record-choice modal opens the editor pre-filled with the program's `tvg_id` and title; the series rules modal gains an "Add rule" button and an "Edit" button per existing rule.
- **Shift+click and Ctrl+click row selection in tables.** Clicking anywhere on a non-interactive area of a row now participates in selection:
- **Shift+click**: extends selection from the last-clicked row to the current row (range select), identical to shift+clicking the checkbox.
- **Ctrl+click** (Cmd+click on Mac): toggles the clicked row in or out of the current selection without disturbing other selected rows.
- Plain clicks on action buttons, checkboxes, inputs, links, and menus are unaffected. The expand chevron uses `stopPropagation` so expanding a row does not also trigger selection.
### Changed
- **Custom SVG icons extracted to `icons.jsx`.** `DiscordIcon` and `GitHubIcon` were moved from `PluginDetailPanel.jsx` into a new shared `frontend/src/components/icons.jsx` module so they can be reused across components without cross-importing from an unrelated file.
- **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations.
- **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected.
- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs.
- **Stream Settings descriptions added.** Default User Agent, Default Stream Profile, Default Output Format, M3U Hash Key, and HDHR Default Output Profile all now have inline description text below their labels explaining their purpose and effect.
- **System Settings descriptions added.** Maximum System Events, Preferred Region, and Auto-Import Mapped Files now have inline description text. The redundant description paragraph that duplicated the accordion header text has been removed.
@ -79,15 +162,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Channel-number duplicates are explicitly allowed.** Sync's `used_numbers` seed still avoids assigning the same number to two newly-created auto channels in the same run, so accidental duplication during sync remains impossible. Manual or override-driven duplication is permitted; downstream client behavior on duplicates varies by client.
- **gevent cooperative multitasking enabled in all uWSGI workers.** `gevent-early-monkey-patch = true` and `import = dispatcharr.gevent_patch` are now set in all four uWSGI configuration files (`uwsgi.ini`, `uwsgi.modular.ini`, `uwsgi.dev.ini`, `uwsgi.debug.ini`). The new `dispatcharr/gevent_patch.py` module ensures gevent's stdlib monkey-patching is applied before application code loads (replacing blocking socket, threading, and OS primitives with cooperative gevent equivalents) and installs psycogreen's wait-callback so psycopg2 database I/O yields to the gevent hub instead of blocking the OS thread. Without these settings, any blocking psycopg2, `requests`, or DNS call froze every greenlet on the affected worker for the duration of the call.
- **WebSocket group sends rewritten to bypass asyncio in gevent workers.** `gevent.monkey.patch_all()` removes `select.epoll` from the stdlib `select` module, which breaks asyncio event loop creation in threadpool threads. The previous `send_websocket_update` and `_send_async` paths dispatched via `async_to_sync(channel_layer.group_send)()` from a threadpool thread, which failed with `AttributeError: module 'select' has no attribute 'epoll'`; the exception was caught at WARNING level, so every WebSocket push from REST views was silently discarded. A new `_gevent_ws_send()` in `core/utils.py` replicates the channels_redis 4.x `group_send` wire format directly using the synchronous Redis client - group membership lookup from the `asgi:group:{name}` sorted set, msgpack serialization with a 12-byte random prefix, and `ZADD` to per-channel sorted sets. Both `send_websocket_update()` and `_send_async()` detect gevent patching at call time and dispatch via `gevent.spawn(_gevent_ws_send)` instead. Celery workers, which are not gevent-patched, continue to use the `async_to_sync` path unchanged.
- Dependency updates:
- `Django` 6.0.4 → 6.0.5 (security patch; see Security section)
### Removed
- **`python-gnupg` dependency dropped.** GPG manifest signature verification now calls the `gpg` binary directly via `os.posix_spawn` (see Fixed below). The `python-gnupg` Python library was the only consumer and has been removed from `pyproject.toml`. The `gpg` binary itself is still required on the host (it was always required since `python-gnupg` is just a wrapper around it).
### Fixed
- **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead.
- **`_cleanup_local_resources` skipped `ClientManager.stop()` on channel removal.** `ProxyServer._cleanup_local_resources` deleted each channel's `ClientManager` entry with `del`, which removed it from the dict but gave the object no signal to terminate. The per-channel heartbeat greenlet inside the manager continued running until it next checked its running flag and found the channel absent from Redis. The entry is now removed with `pop()` and `stop()` is called on the captured manager before it is discarded, terminating the heartbeat immediately on channel cleanup.
- **XC profile `exp_date` not updating on account refresh.** `refresh_account_profiles` saved the freshly-fetched `custom_properties` with `update_fields=['custom_properties']`, which excluded `exp_date` from the SQL `UPDATE`. The model's `save()` method parses the new expiry from `custom_properties` and assigns it to `self.exp_date`, but that value was silently dropped because the column was not listed in `update_fields`. Added `'exp_date'` to the `update_fields` list so both columns are written together.
- **~25-second transcode startup delay and worker freeze when starting ffmpeg under gevent+uWSGI.** Enabling gevent cooperative multitasking (see Changed above) exposed a deadlock: `fork()` hangs indefinitely in gevent's `_before_fork` pthread_atfork handler when called from any thread while gevent is running - including from real OS threads. `subprocess.Popen`, which all three ffmpeg spawn sites used, calls `fork()` internally, stalling the uWSGI worker for ~25 seconds or freezing it entirely and blocking all other clients on that worker.
- `input/manager.py`: replaced `subprocess.Popen` with `os.posix_spawn` + a minimal `_SpawnedProcess` wrapper. `os.posix_spawn` is POSIX-specified to skip pthread_atfork handlers entirely.
- `output/fmp4/manager.py`, `output/profile/manager.py`: replaced `subprocess.Popen` with a new shared `posix_spawn_proc()` helper in `live_proxy/utils.py`. The helper also sets `O_NONBLOCK` on the stdin pipe write-end: under gevent, `threading.Thread` is monkey-patched to greenlets, so a blocking write to a full 64 KB pipe would stall the entire hub. `_write_all()` in both output managers now treats a `None` return (EAGAIN on the non-blocking FD) as a cooperative wait via `select.select()` rather than a fatal error.
- `input/http_streamer.py`: set `O_NONBLOCK` on the HTTP-to-pipe relay write-end with an EAGAIN retry loop for the same reason.
- `core/views.py` (`stream_view`): replaced `subprocess.Popen` with `os.posix_spawn`; also fixed a pre-existing indentation bug where the `return StreamingHttpResponse(...)` was accidentally nested inside `stream_generator` (making every successful response return `None` and raise a Django error). Also corrected two `NameError` references to the undefined `stream_id` variable in log messages.
- `apps/connect/handlers/script.py` (`ScriptHandler`): replaced `subprocess.run` with a `_posix_run` helper that uses `os.posix_spawn` + cooperative `select.select` reads + non-blocking `waitpid` polling. Without this fix, any script-type connect integration configured for events fired from a uWSGI worker (e.g. `client_connect`) would deadlock the serving greenlet. Note: `cwd` is no longer set to the script's directory during execution (it inherits the worker's cwd); this was a minor convenience, not a documented guarantee.
- **`POST /api/plugins/repos/plugin-detail/` hung for up to 105 seconds under gevent+uWSGI.** The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures, triggering the same `fork()` atfork deadlock described above. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. `select.select()` drains stdout/stderr cooperatively (gevent-patched) and `os.waitpid(WNOHANG)` with `time.sleep(0.01)` reaps the child without blocking the hub. Results are cached in Redis for 5 minutes per manifest URL so repeat detail fetches skip GPG entirely. The cache is also invalidated per-plugin when the owning repo's hub manifest is refreshed, so a newly released version is visible immediately after a manual hub refresh.
- **XC server sub-path URLs now work correctly.** When a provider serves its XC API from a sub-path (e.g. `http://server/Pluto/gb/player_api.php`), Dispatcharr was stripping the path entirely and hitting the root (`/player_api.php`) instead. `_normalize_url` now preserves sub-path components and only strips any trailing `.php` segment (covering `player_api.php`, `get.php`, `xmltv.php`, and any future endpoint without a maintained list). The same fix is applied to `get_transformed_credentials` in the M3U profile transformation path. (Fixes #1218)
- **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810)
- **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810)
@ -100,9 +193,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Plugin monkey-patches and module-level hooks were never applied in uWSGI workers.** Both uWSGI configs use `lazy-apps=true`, meaning each worker boots independently and never inherits state from the master. `should_skip_initialization()` correctly skipped one-shot startup tasks in workers, but also blocked `discover_plugins`, so plugin modules were never imported in any of the 4 request-serving workers. Plugins relying on patching request handling (monkey-patches, signal registrations) were silently inactive until a Connect event lazily triggered discovery in that specific worker. Discovery is now run in every process that serves requests, gated only for Celery processes (which use `worker_ready`) and management commands that don't serve requests.
- **PostgreSQL connection pool exhausted under load in gevent workers.** uWSGI's gevent pool runs many greenlets concurrently on a single OS thread. With `CONN_MAX_AGE = 60`, each greenlet that touched the database retained its own open connection for the full 60 seconds (gevent thread-locals are greenlet-locals), rapidly exhausting PostgreSQL's `max_connections` limit under moderate concurrency. `DATABASE_CONN_MAX_AGE` is now `0` so connections are closed after each request. `close_old_connections()` is also called at the top of the long-running stream manager and cleanup watchdog loops so stale handles accumulated across greenlet switches are released promptly.
- **Stream proxy race: cleanup watchdog could stop a channel still in the connecting phase.** When the first viewer triggered channel initialization, the client was registered only after the connect-wait loop completed. The cleanup watchdog runs concurrently and stops channels with zero connected clients after a grace period; if the grace period elapsed during the connect-wait, the watchdog killed the channel and left the viewer stuck. The client is now registered before the connect-wait loop begins so the watchdog always sees at least one client.
- **2-second "stream thread did not terminate within timeout" warning on every channel stop.** `_close_socket` in `input/manager.py` was closing the relay pipe read-end (`self.socket`) before killing the ffmpeg process. The stream OS thread blocks in `select()` on that fd; on Linux, closing an fd from another thread while a `select()` is in progress on it does not reliably interrupt the call (POSIX allows this to be undefined). The thread stayed blocked for the full chunk-timeout (5 s), and `stream_thread.join(timeout=2.0)` always expired first. Fixed by killing ffmpeg first: when ffmpeg dies its copy of the relay write-end closes, delivering EOF to `select()` immediately. `self.socket` is closed afterward as cleanup only.
- **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger.
- **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block.
- **Channel card "started at" tooltip jumping by 1 second on every stats poll.** The Stats page channel card tooltip showed the channel start time by computing `Date.now() - uptime * 1000` on every render. Because `uptime` is a server-side elapsed-seconds value recomputed each response, this reconstruction drifted by up to 1 second per tick. The basic channel info path now emits `started_at` (the raw Unix timestamp from Redis) alongside `uptime`, matching what the detailed stats path already sent. The frontend `getStartDate` helper now accepts the stable `started_at` timestamp directly, so the displayed wall-clock time is fixed from the first poll and never changes.
- **Shift+click range selection in the channels table broken after row memoization.** After `MemoizedTableRow` was introduced, `handleShiftSelect` captured `lastClickedId` from its render-time closure. Because the memo comparator intentionally excludes callback function references, unselected rows retained the stale closure where `lastClickedId === null`, so every shift+click from a previously unchecked row fell through to a plain toggle instead of selecting the range. Added `lastClickedIdRef` and `allRowIdsRef` alongside the existing `selectedTableIdsRef`; `handleShiftSelect` now reads from those refs so every row uses the current anchor ID and full ID list regardless of which render produced the closure.
- **Selected rows in the channels table did not show the teal highlight.** `MemoizedTableRow` applied `backgroundColor: '#163632'` based on `row.getIsSelected()` from TanStack Table's API. Because `state.rowSelection` was never wired into `useReactTable`, `row.getIsSelected()` always returned `false` and selected rows remained unstyled. Changed to use the `isSelected` prop, which is correctly derived from `selectedTableIdsSet` and already tracked by the memo comparator.
- **`blur` event listener in `useTable` leaked on component unmount.** The `useEffect` cleanup function called `window.removeEventListener('blur', ...)` with a newly created anonymous function literal that never matched the handler registered at setup time, so the listener was never removed. Extracted to a named `handleBlur` constant so setup and cleanup reference the same function.
### Performance
- **EPG HTTP response cache replaced with `django-redis`.** The default Django cache backend was `LocMemCache`, an in-process memory store. With multiple uWSGI workers, each worker independently generated and cached the full EPG XML in its own heap; with 4 workers the peak memory cost was up to 4 times the size of the EPG document. The cache backend is now `django_redis.cache.RedisCache`, backed by the same Redis instance used by `channels_redis`, so a single cached EPG copy is shared across all workers.
- **`AutoSyncAdvanced` and `LogoForm` are now lazy-loaded in the M3U group filter.** Both components are large and only needed when the user opens the gear modal or logo upload modal. Wrapping them in `React.lazy` + `Suspense` removes them from the initial bundle and defers their parse/execute cost until first use. — Thanks [@nick4810](https://github.com/nick4810)
- **Auto-sync at scale**: the new override-aware sync flow is more capable than the prior path but the implementation choices below keep it viable on libraries with thousands of channels. — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Bulk writes throughout `sync_auto_channels`.** Per-row `Channel.objects.create()` and `.save()` calls were replaced with `bulk_create()` and `bulk_update()` paths that batch the entire group's create + update sets into single round-trips. The renumber pass collects all dirty channels into one list and flushes with a single `bulk_update` at the end of the loop. `ChannelStream.order` writes were similarly consolidated into a single `bulk_update`.
@ -117,6 +218,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`JsonResponse` for ID list and channel summary endpoints.** The `stream_ids`, `channel_ids`, and channel `summary` endpoints now use `django.http.JsonResponse` instead of DRF's `Response`, bypassing the DRF renderer pipeline (content-type negotiation, serializer dispatch) for responses that are already plain Python structures. Removes overhead on every channel-table load and stream-table load.
- **Logo queryset annotates `channel_count` to eliminate N+1 in `LogoSerializer`.** `LogoViewSet.get_queryset()` now annotates each row with `Count('channels')`. `LogoSerializer.get_channel_count()` and `get_is_used()` read the annotation directly instead of issuing a separate `COUNT(*)` per logo. The `used=true` and `used=false` list filters use the annotation for their conditions, removing the `DISTINCT` that was previously required.
- **`ChannelProfileSerializer` reads prefetched memberships.** `ChannelProfileViewSet.get_queryset()` now prefetches enabled `ChannelProfileMembership` rows into `enabled_memberships`. `ChannelProfileSerializer.get_channels()` uses the prefetched set when available, eliminating one query per profile in any response that lists multiple profiles.
- **Channel table selection re-render reduction.**
- Removed `isShiftKeyDown` React state from `useTable`. Setting it on every `keydown`/`keyup` event triggered a re-render of any table consumer (`ChannelsTable`, `CustomTableBody`, ~50 memoized row comparisons) each time shift was pressed or released. The visual shift-key effect is handled entirely by `document.body.classList` manipulation, so the React state served no purpose.
- Removed the `selectedChannelIds` reactive Zustand store subscription from `ChannelsTable`. Each checkbox click wrote to both local `selectedTableIds` state and the store via `onRowSelectionChange`, causing two sequential re-renders of `ChannelsTable` per click. The two consumers of this subscription (`deleteChannel` and `ChannelBatchForm`) now read from `table.selectedTableIds` directly.
- Removed the dead `rowSelection` useMemo that built a TanStack row-selection map from `selectedTableIds`. The map was placed in `tableInstance` but `state.rowSelection` was never passed to `useReactTable`, so TanStack never consumed it. Eliminated a full page-row iteration on every selection change.
## [0.24.0] - 2026-05-03

View file

@ -81,6 +81,7 @@ This is the simplest valid repo manifest - one plugin with enough info to show i
Dispatcharr accepts two top-level shapes:
**Wrapped (supports signing):**
```json
{
"manifest": { "plugins": [...], ... },
@ -89,6 +90,7 @@ Dispatcharr accepts two top-level shapes:
```
**Flat (no signing):**
```json
{
"plugins": [...],
@ -118,36 +120,36 @@ If the name contains any of these, the repo will be rejected on add and skipped
### Top-Level Metadata
| Field | Required | Description |
|-------|----------|-------------|
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
### Plugin Entry Fields
| Field | Required | Description |
|-------|----------|-------------|
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
| `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. |
| Field | Required | Description |
| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
| `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. |
Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything.
@ -160,6 +162,7 @@ If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`)
```
This lets you keep plugin entries compact:
```json
{
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
@ -175,6 +178,7 @@ This lets you keep plugin entries compact:
```
**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL:
```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
```
@ -186,6 +190,7 @@ This lets you keep plugin entries compact:
The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead.
Include a per-plugin manifest if you want to:
- Offer multiple downloadable versions
- Show per-version compatibility ranges
- Display build timestamps and commit links for each version
@ -196,6 +201,7 @@ Include a per-plugin manifest if you want to:
Same as the root manifest - both flat and wrapped formats are accepted:
**Flat (no signing):**
```json
{
"slug": "...",
@ -204,6 +210,7 @@ Same as the root manifest - both flat and wrapped formats are accepted:
```
**Wrapped (supports signing):**
```json
{
"manifest": {
@ -272,33 +279,33 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
### Per-Plugin Manifest Fields
| Field | Required | Description |
|-------|----------|-------------|
| `slug` | No | Plugin identifier (should match the repo entry). |
| `name` | No | Display name. |
| `description` | No | Full description shown in the detail modal. |
| `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. 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. |
| Field | Required | Description |
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug` | No | Plugin identifier (should match the repo entry). |
| `name` | No | Display name. |
| `description` | No | Full description shown in the detail modal. |
| `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. 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
| Field | Required | Description |
|-------|----------|-------------|
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
| `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. |
| Field | Required | Description |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
| `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. |
Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`.
@ -348,6 +355,7 @@ jq -c '.manifest' manifest.json | gpg --armor --detach-sign
```
In code terms:
```python
import json
canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n"
@ -371,11 +379,11 @@ Use the wrapped format so the signature sits alongside the manifest:
### Verification Results
| Result | Meaning | UI Badge |
|--------|---------|----------|
| `true` | Valid signature | Green checkmark |
| `false` | Invalid signature or verification error | Red X |
| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral |
| Result | Meaning | UI Badge |
| ------- | ------------------------------------------------------------------- | --------------- |
| `true` | Valid signature | Green checkmark |
| `false` | Invalid signature or verification error | Red X |
| `null` | Not attempted (no signature, no key, or `gpg` binary not installed) | Gray/neutral |
### Signing Workflow Example
@ -446,6 +454,7 @@ my_plugin-1.0.0.zip
```
Or with a subdirectory:
```
my_plugin-1.0.0.zip
my_plugin/
@ -477,6 +486,7 @@ The plugin is installed **disabled** by default. The user can enable it from the
Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection.
A plugin shows "Update Available" when:
- It is managed (installed from a repo)
- Its `installed_version` differs from `latest_version`
- It was installed from the same repo
@ -488,7 +498,9 @@ A plugin shows "Update Available" when:
A plugin repo manifest is just a JSON file served over HTTPS. Some options:
### GitHub Pages / Raw Content
Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs:
```
https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
```
@ -496,9 +508,11 @@ https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
Use `root_url` pointing to your releases branch/path so version URLs stay relative.
### Static File Server
Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed.
### GitHub Releases
You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch.
---

View file

@ -1,5 +1,7 @@
from rest_framework import authentication
from rest_framework import exceptions
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from django.conf import settings
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from .models import User
@ -84,3 +86,18 @@ class ApiKeyAuthentication(authentication.BaseAuthentication):
def authenticate_header(self, request):
return self.keyword
class QueryParamJWTAuthentication(JWTAuthentication):
"""Reads a JWT from the `token` query parameter. Used for media endpoints
where the browser cannot set Authorization headers (e.g. <video src>)."""
def authenticate(self, request):
raw_token = request.GET.get('token')
if not raw_token:
return None
try:
validated_token = self.get_validated_token(raw_token)
return self.get_user(validated_token), validated_token
except (InvalidToken, TokenError):
return None

View file

@ -23,8 +23,7 @@ def get_backup_dir() -> Path:
def _is_postgresql() -> bool:
"""Check if we're using PostgreSQL."""
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
return "postgresql" in settings.DATABASES["default"]["ENGINE"]
def _get_pg_env() -> dict:
@ -171,30 +170,25 @@ def _restore_postgresql(dump_file: Path) -> None:
def _dump_sqlite(output_file: Path) -> None:
"""Dump SQLite database using sqlite3 .backup command."""
logger.info("Dumping SQLite database with sqlite3 .backup...")
import sqlite3 as _sqlite3
logger.info("Dumping SQLite database...")
db_path = Path(settings.DATABASES["default"]["NAME"])
if not db_path.exists():
raise FileNotFoundError(f"SQLite database not found: {db_path}")
# Use sqlite3 .backup command via stdin for reliable execution
result = subprocess.run(
["sqlite3", str(db_path)],
input=f".backup '{output_file}'\n",
capture_output=True,
text=True,
)
src = _sqlite3.connect(str(db_path))
dst = _sqlite3.connect(str(output_file))
try:
src.backup(dst)
finally:
dst.close()
src.close()
if result.returncode != 0:
logger.error(f"sqlite3 backup failed: {result.stderr}")
raise RuntimeError(f"sqlite3 backup failed: {result.stderr}")
# Verify the backup file was created
if not output_file.exists():
raise RuntimeError("sqlite3 backup failed: output file not created")
raise RuntimeError("SQLite backup failed: output file not created")
logger.info(f"sqlite3 backup completed successfully: {output_file}")
logger.info(f"SQLite backup completed successfully: {output_file}")
def _restore_sqlite(dump_file: Path) -> None:
@ -216,23 +210,20 @@ def _restore_sqlite(dump_file: Path) -> None:
# We can simply copy it over the existing database
shutil.copy2(dump_file, db_path)
# Verify the restore worked by checking if sqlite3 can read it
result = subprocess.run(
["sqlite3", str(db_path)],
input=".tables\n",
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"sqlite3 verification failed: {result.stderr}")
# Try to restore from backup
# Verify the restore worked by checking if the file is a readable SQLite database
import sqlite3 as _sqlite3
try:
conn = _sqlite3.connect(str(db_path))
conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
conn.close()
except _sqlite3.DatabaseError as exc:
logger.error(f"SQLite verification failed: {exc}")
if backup_current and backup_current.exists():
shutil.copy2(backup_current, db_path)
logger.info("Restored original database from backup")
raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}")
raise RuntimeError(f"SQLite restore verification failed: {exc}") from exc
logger.info("sqlite3 restore completed successfully")
logger.info("SQLite restore completed successfully")
def create_backup() -> Path:

View file

@ -1,6 +1,7 @@
import logging
import traceback
from celery import shared_task
from django.core.management import call_command
from . import services
@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str):
backup_file = backup_dir / filename
logger.info(f"[RESTORE] Backup file path: {backup_file}")
services.restore_backup(backup_file)
logger.info(f"[RESTORE] Running migrations after restore...")
call_command('migrate', '--noinput', verbosity=1)
logger.info(f"[RESTORE] Task {self.request.id} completed successfully")
return {
"status": "completed",

View file

@ -44,20 +44,38 @@ def is_compact_group(group_relation):
def get_group_relation_for_channel(channel):
"""Resolve the ChannelGroupM3UAccount that owns this auto-created
channel. Returns None for manual channels, or when the relation has
been deleted."""
if (
not channel.auto_created
or not channel.auto_created_by_id
or not channel.channel_group_id
):
been deleted.
With a Channel Group Override active, sync stores the channel under
the override target group's id, not the source group's id recorded on
the relation. The direct lookup then misses, so fall back to scanning
the account's relations for one whose group_override points at the
channel's current group. The fallback runs only on a direct miss, so
the common no-override path keeps its single SELECT.
"""
if not channel.auto_created or not channel.auto_created_by_id:
return None
if not channel.channel_group_id:
return None
try:
return ChannelGroupM3UAccount.objects.get(
m3u_account_id=channel.auto_created_by_id,
channel_group_id=channel.channel_group_id,
)
except ChannelGroupM3UAccount.DoesNotExist:
return None
pass
# group_override may be stored as int or str; compare as strings so
# the match is type-agnostic.
target = str(channel.channel_group_id)
for rel in ChannelGroupM3UAccount.objects.filter(
m3u_account_id=channel.auto_created_by_id
):
cp = _custom_properties_as_dict(rel.custom_properties)
if str(cp.get("group_override", "")) == target:
return rel
return None
def build_reserved_set(exclude_channel_ids=None, range_start=None, range_end=None):
@ -151,6 +169,29 @@ def assign_compact_numbers_for_channels(channel_ids):
for rel in relations_qs:
relations_by_pair[(rel.m3u_account_id, rel.channel_group_id)] = rel
# Override fallback: pairs the direct lookup missed carry an override-
# target channel_group_id. Resolve them with one extra query over the
# unresolved accounts (not one per pair), so the common path keeps its
# single narrow query.
unresolved = [k for k in pair_keys if k not in relations_by_pair]
if unresolved:
override_relations = {}
for rel in ChannelGroupM3UAccount.objects.filter(
m3u_account_id__in={k[0] for k in unresolved}
):
cp = _custom_properties_as_dict(rel.custom_properties)
target = cp.get("group_override")
if not target:
continue
try:
override_relations[(rel.m3u_account_id, int(target))] = rel
except (TypeError, ValueError):
continue
for key in unresolved:
rel = override_relations.get(key)
if rel is not None:
relations_by_pair[key] = rel
by_relation = {}
for key, group_channels in by_pair.items():
rel = relations_by_pair.get(key)
@ -265,11 +306,29 @@ def _repack_inner(group_relation):
else None
)
# Match the override target group too: channels created under an
# override live under the target's id, not the source group's.
group_ids = {group_id}
override_group_id = cp.get("group_override")
if override_group_id:
try:
group_ids.add(int(override_group_id))
except (TypeError, ValueError):
logger.warning(
"Ignoring non-numeric group_override %r on relation %s",
override_group_id,
group_relation.id,
)
# Known limitation: if two source groups on the same account override
# into the SAME target group, their channels are indistinguishable
# here (channels carry no source-group back-reference), so each repack
# renumbers the shared target's channels into its own range.
channels = list(
Channel.objects.filter(
auto_created=True,
auto_created_by_id=account_id,
channel_group_id=group_id,
channel_group_id__in=group_ids,
).select_related("override")
)

View file

@ -62,6 +62,9 @@ def backfill_auto_created_by_null(apps, schema_editor):
f"(ambiguous/no streams): {demoted}"
)
with schema_editor.connection.cursor() as cursor:
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
def reverse_auto_created_by_null(apps, schema_editor):
# Forward decisions cannot be cleanly reverted (no record of the
@ -101,6 +104,9 @@ def reverse_backfill_channel_number_nulls(apps, schema_editor):
ch.save(update_fields=["channel_number"])
next_num += 1.0
with schema_editor.connection.cursor() as cursor:
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
class Migration(migrations.Migration):

View file

@ -453,7 +453,7 @@ class Channel(models.Model):
# @TODO: honor stream's stream profile
def get_stream_profile(self):
stream_profile = self.stream_profile
stream_profile = self.effective_stream_profile_obj
if not stream_profile:
stream_profile = StreamProfile.objects.get(
id=CoreSettings.get_default_stream_profile_id()

View file

@ -19,10 +19,9 @@ from apps.epg.serializers import EPGDataSerializer
from core.models import StreamProfile
from apps.epg.models import EPGData
from django.db import connection, transaction
from django.urls import reverse
from rest_framework import serializers
from django.utils import timezone
from core.utils import validate_flexible_url
from core.utils import validate_flexible_url, build_absolute_uri_with_port
class LogoSerializer(serializers.ModelSerializer):
@ -57,13 +56,12 @@ class LogoSerializer(serializers.ModelSerializer):
return instance
def get_cache_url(self, obj):
# return f"/api/channels/logos/{obj.id}/cache/"
request = self.context.get("request")
if request:
return request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[obj.id])
)
return reverse("api:channels:logo-cache", args=[obj.id])
if not request:
return f"/api/channels/logos/{obj.id}/cache/"
if not hasattr(self, "_cache_url_prefix"):
self._cache_url_prefix = build_absolute_uri_with_port(request, "")
return f"{self._cache_url_prefix}/api/channels/logos/{obj.id}/cache/"
def get_channel_count(self, obj):
"""Get the number of channels using this logo"""

View file

@ -1361,11 +1361,8 @@ def _evaluate_series_rules_locked(tvg_id, result):
# Notify frontend to refresh
try:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'updates',
{'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}},
)
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]})
except Exception:
pass
@ -1440,11 +1437,8 @@ def reschedule_upcoming_recordings_for_offset_change_impl():
# Notify frontend to refresh
try:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'updates',
{'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "rescheduled": changed}},
)
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "rescheduled": changed})
except Exception:
pass
@ -3180,7 +3174,11 @@ def comskip_process_recording(recording_id: int):
_ws('started', {"title": (cp.get('program') or {}).get('title') or os.path.basename(file_path)})
try:
comskip_mode = CoreSettings.get_dvr_comskip_mode()
hw_accel = CoreSettings.get_dvr_comskip_hw_accel()
cmd = [comskip_bin, "--output", os.path.dirname(file_path)]
if hw_accel != "none":
cmd.insert(1, f"--{hw_accel}")
# Prefer user-specified INI, fall back to known defaults
ini_candidates = []
try:
@ -3300,6 +3298,19 @@ def comskip_process_recording(recording_id: int):
_ws('skipped', {"reason": "no_commercials", "commercials": 0})
return "no_commercials"
if comskip_mode == "mark":
cp["comskip"] = {
"status": "completed",
"mode": "mark",
"edl": os.path.basename(edl_path),
"commercials": len(commercials),
}
if selected_ini:
cp["comskip"]["ini_path"] = selected_ini
_persist_custom_properties()
_ws('completed', {"commercials": len(commercials), "mode": "mark"})
return "ok"
workdir = os.path.dirname(file_path)
parts = []
try:
@ -3340,6 +3351,10 @@ def comskip_process_recording(recording_id: int):
for pth in parts:
try: os.remove(pth)
except Exception: pass
try:
os.remove(edl_path)
except Exception:
pass
cp["comskip"] = {
"status": "completed",

View file

@ -18,7 +18,7 @@ from .serializers import (
EPGDataSerializer,
ProgramSearchResultSerializer,
)
from .tasks import refresh_epg_data
from .tasks import refresh_epg_data, find_current_program_for_tvg_id
from .query_utils import parse_text_query
from apps.accounts.permissions import (
Authenticated,
@ -59,6 +59,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
from apps.channels.models import Channel
return EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).defer(
'programme_index'
).annotate(
has_channels=Exists(
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
@ -1068,19 +1070,101 @@ class CurrentProgramsAPIView(APIView):
allow_null=True,
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
),
"epg_data_ids": serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
help_text="Array of EPG data IDs. Can be used instead of channel_ids.",
),
},
),
responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Get IDs from request body
channel_uuids = request.data.get('channel_uuids', None)
epg_data_ids = request.data.get('epg_data_ids', None)
# Validate that at most one type of ID is provided
if channel_uuids is not None and epg_data_ids is not None:
return Response(
{"error": "Provide either channel_uuids or epg_data_ids, not both"},
status=status.HTTP_400_BAD_REQUEST
)
# Get current time
now = timezone.now()
# If epg_data_ids are provided, query directly by EPG data
if epg_data_ids is not None:
if not isinstance(epg_data_ids, list):
return Response(
{"error": "epg_data_ids must be an array of integers or null"},
status=status.HTTP_400_BAD_REQUEST
)
try:
epg_data_ids = [int(eid) for eid in epg_data_ids]
except (ValueError, TypeError):
return Response(
{"error": "epg_data_ids must contain valid integers"},
status=status.HTTP_400_BAD_REQUEST
)
# Limit to 50 IDs per request
epg_data_ids = epg_data_ids[:50]
# Defer the multi-MB programme_index the JOIN would pull once per row. The lookup reads it via a targeted refresh_from_db
epg_data_entries = EPGData.objects.select_related('epg_source').defer(
'epg_source__programme_index'
).filter(id__in=epg_data_ids)
# Batch-fetch current programs for all requested EPG entries in one query
db_programs = ProgramData.objects.filter(
epg__in=epg_data_entries, start_time__lte=now, end_time__gt=now
).select_related('epg')
# Map epg_data id -> first matching program
programs_by_epg = {}
for prog in db_programs:
if prog.epg_id not in programs_by_epg:
programs_by_epg[prog.epg_id] = prog
current_programs = []
for epg_data in epg_data_entries:
# Check batch-fetched DB results first
program = programs_by_epg.get(epg_data.id)
if program:
program_data = ProgramDataSerializer(program).data
program_data['epg_data_id'] = epg_data.id
current_programs.append(program_data)
continue
# Skip dummy sources
if epg_data.epg_source and epg_data.epg_source.source_type == 'dummy':
continue
# Fall back to byte-offset index lookup, pass the object to avoid re-fetch
result = find_current_program_for_tvg_id(epg_data)
if result == "timeout":
current_programs.append({
"epg_data_id": epg_data.id,
"parsing": True,
})
elif result is not None:
result['epg_data_id'] = epg_data.id
current_programs.append(result)
return Response(current_programs, status=status.HTTP_200_OK)
# Otherwise, use channel-based query
# Import Channel model
from apps.channels.models import Channel
# Build query for channels with EPG data
query = Channel.objects.filter(epg_data__isnull=False)
channel_uuids = request.data.get('channel_uuids', None)
if channel_uuids is not None:
if not isinstance(channel_uuids, list):
return Response(
@ -1092,9 +1176,6 @@ class CurrentProgramsAPIView(APIView):
# Get channels with EPG data
channels = query.select_related('epg_data')
# Get current time
now = timezone.now()
# Build list of current programs
current_programs = []
@ -1113,4 +1194,3 @@ class CurrentProgramsAPIView(APIView):
return Response(current_programs, status=status.HTTP_200_OK)

View file

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0022_alter_epgdata_name'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='programme_index',
field=models.JSONField(
blank=True,
default=None,
help_text='Byte-offset index mapping tvg_id to file positions, built after each EPG refresh',
null=True,
),
),
]

View file

@ -62,6 +62,12 @@ class EPGSource(models.Model):
blank=True,
help_text="Last status message, including success results or error information"
)
programme_index = models.JSONField(
null=True,
blank=True,
default=None,
help_text="Byte-offset index mapping tvg_id to file positions, built after each EPG refresh"
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text="Time when this source was created"

View file

@ -4,6 +4,7 @@ import logging
import gzip
import html.entities
import os
import re
import uuid
import requests
import time # Add import for tracking download progress
@ -56,6 +57,12 @@ def _build_html_entity_doctype() -> bytes:
_HTML_ENTITY_DOCTYPE = _build_html_entity_doctype()
def _parse_programme_element(element_bytes):
"""Parse a single <programme> element, prepending the HTML-entity DOCTYPE so references like &eacute; in the text resolve instead of failing."""
parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=True)
return etree.fromstring(_HTML_ENTITY_DOCTYPE + element_bytes, parser)
class _PrependStream:
"""Wraps an open binary file and prepends a bytes prefix to its content.
@ -358,6 +365,10 @@ def refresh_epg_data(source_id, force=False):
# Continue with the normal processing...
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
if source.source_type == 'xmltv':
# Invalidate the byte-offset index before downloading the new file
# so stale offsets are never used during the refresh window.
EPGSource.objects.filter(id=source.id).update(programme_index=None)
fetch_success = fetch_xmltv(source)
if not fetch_success:
logger.error(f"Failed to fetch XMLTV for source {source.name}")
@ -376,6 +387,9 @@ def refresh_epg_data(source_id, force=False):
gc.collect()
return
# Build byte-offset index for preview lookups in the background so refresh isn't blocked by it
build_programme_index_task.delay(source.id)
parse_programs_for_source(source)
elif source.source_type == 'schedules_direct':
@ -1317,7 +1331,8 @@ def parse_channels_only(source):
@shared_task(time_limit=3600, soft_time_limit=3500)
def parse_programs_for_tvg_id(epg_id):
def parse_programs_for_tvg_id(epg_id, force=False):
# Skip XMLTV file parsing for Schedules Direct sources. Program data is
# fetched and persisted directly by fetch_schedules_direct().
try:
@ -1369,7 +1384,7 @@ def parse_programs_for_tvg_id(epg_id):
release_task_lock('parse_epg_programs', epg_id)
return
if not Channel.objects.filter(epg_data=epg).exists():
if not force and not Channel.objects.filter(epg_data=epg).exists():
logger.info(f"No channels matched to EPG {epg.tvg_id}")
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
@ -3251,3 +3266,425 @@ def generate_dummy_epg(source):
logger.warning(f"generate_dummy_epg() called for {source.name} but this function is deprecated. "
f"Dummy EPG programs are now generated on-demand.")
return True
# EPG program byte-offset index for channel preview lookups
def _resolve_source_file(epg_source):
"""Resolve the XML file path for an EPG source."""
file_path = epg_source.extracted_file_path or epg_source.file_path
if not file_path:
file_path = epg_source.get_cache_file()
return file_path
_CHANNEL_ATTR_RE = re.compile(rb"""channel\s*=\s*(?:"([^"]+)"|'([^']+)')""")
_PROGRAMME_TAG = b'<programme'
_PROGRAMME_TAG_LEN = len(_PROGRAMME_TAG)
_TAG_FOLLOW = b' \t\n\r>/'
_MAX_START_TAG = 4096 # generous upper bound for a start tag with namespaces/extra attrs
_OFFSET_CAP = 10 # max block-starts recorded per channel; exceeding this flags the channel as interleaved
def _decode_channel_id(raw):
"""Match how EPGData.tvg_id is stored: resolve XML entities and strip, so byte-level index keys equal the lxml-parsed channel ids."""
s = raw.decode('utf-8', errors='replace')
if '&' in s:
s = html.unescape(s)
return s.strip()
def _find_programme_tag(buf, start):
"""
Find the next <programme element in *buf* starting from *start*.
Returns (tag_pos, tag_end) or (-1, -1) if not found.
"""
pos = start
while True:
idx = buf.find(_PROGRAMME_TAG, pos)
if idx == -1:
return -1, -1
# Validate next byte is whitespace or '>'
follow = idx + _PROGRAMME_TAG_LEN
if follow >= len(buf):
return idx, -1 # need more data
if buf[follow: follow + 1] not in _TAG_FOLLOW:
pos = follow # false match (e.g. <programmeXYZ), skip
continue
# Find the '>' that closes the opening tag (scan up to _MAX_START_TAG bytes)
tag_end = buf.find(b'>', follow, idx + _MAX_START_TAG)
if tag_end == -1:
if len(buf) >= idx + _MAX_START_TAG:
logger.warning(
f'[_find_programme_tag] <programme> start tag exceeds {_MAX_START_TAG} bytes at offset {idx}, skipping'
)
return -1, -1
return idx, -1 # need more data
return idx, tag_end
def _programme_to_dict(elem, start_time, end_time):
"""Convert a <programme> lxml element to a serializable dict."""
title_el = elem.find('title')
desc_el = elem.find('desc')
sub_el = elem.find('sub-title')
return {
'title': title_el.text if title_el is not None and title_el.text else '',
'description': desc_el.text if desc_el is not None and desc_el.text else '',
'sub_title': sub_el.text if sub_el is not None and sub_el.text else '',
'start_time': start_time.isoformat(),
'end_time': end_time.isoformat(),
}
def build_programme_index(source_id):
"""
Scan the XML file with raw binary I/O to build a {tvg_id: [byte_offset, ...]} map.
Persists the result to EPGSource.programme_index. Most XMLTV files group programmes
by channel, but some split a channel across multiple non-contiguous blocks, so we
record block starts up to _OFFSET_CAP and mark only channels that exceed the cap
as interleaved.
"""
try:
source = EPGSource.objects.get(id=source_id)
except EPGSource.DoesNotExist:
logger.error(f'[build_programme_index] EPGSource {source_id} not found')
return
file_path = _resolve_source_file(source)
if not file_path or not os.path.exists(file_path):
logger.warning(
f'[build_programme_index] File not found for source {source_id}: {file_path}'
)
return
logger.debug(
f'[build_programme_index] Building byte-offset index for source {source_id} from {file_path}'
)
start = time.monotonic()
index = {}
prev_channel = None
interleaved_channels = set()
CHUNK = 8 * 1024 * 1024 # 8MB
with open(file_path, 'rb') as f:
buf = bytearray()
buf_offset = 0 # absolute file offset of buf[0]
while True:
chunk = f.read(CHUNK)
if not chunk and not buf:
break
buf.extend(chunk)
search_from = 0
while True:
idx, tag_end = _find_programme_tag(buf, search_from)
if idx == -1:
break
if tag_end == -1 and chunk:
break # incomplete tag at buffer edge, need more data
abs_pos = buf_offset + idx
m = _CHANNEL_ATTR_RE.search(
buf, idx, tag_end + 1 if tag_end != -1 else idx + _MAX_START_TAG
)
if m:
channel_id = _decode_channel_id(m.group(1) or m.group(2))
if channel_id not in index:
index[channel_id] = [abs_pos]
elif channel_id != prev_channel:
if len(index[channel_id]) < _OFFSET_CAP:
index[channel_id].append(abs_pos)
else:
interleaved_channels.add(channel_id)
prev_channel = channel_id
search_from = (
(tag_end + 1) if tag_end != -1 else (idx + _PROGRAMME_TAG_LEN)
)
if not chunk:
break
# Keep unprocessed tail for next iteration
keep_from = (
max(search_from, len(buf) - _MAX_START_TAG) if chunk else len(buf)
)
del buf[:keep_from]
buf_offset += keep_from
elapsed = time.monotonic() - start
logger.info(
f'[build_programme_index] Indexed {len(index)} channels in {elapsed:.1f}s for source {source_id}'
+ (
f' ({len(interleaved_channels)} interleaved)'
if interleaved_channels
else ''
)
)
result = {
'channels': index,
'interleaved_channels': sorted(interleaved_channels),
}
EPGSource.objects.filter(id=source_id).update(programme_index=result)
@shared_task
def build_programme_index_task(source_id):
"""Celery wrapper. Locks so refresh and preview don't both build the same source. Releases on finish rather than waiting out the TTL."""
from core.utils import RedisClient
redis_client = RedisClient.get_client()
lock_key = f'building_programme_index_{source_id}'
if not redis_client.set(lock_key, '1', nx=True, ex=300):
return
try:
build_programme_index(source_id)
finally:
redis_client.delete(lock_key)
def find_current_program_for_tvg_id(epg_or_id):
"""
Look up the currently-airing program for an EPGData instance (or id) using
the byte-offset index. If no index exists yet, queue an async build and let
the caller retry rather than doing a blocking scan.
Returns dict, None, or "timeout".
"""
if isinstance(epg_or_id, EPGData):
epg = epg_or_id
else:
try:
epg = EPGData.objects.select_related('epg_source').get(id=epg_or_id)
except EPGData.DoesNotExist:
return None
source = epg.epg_source
if not source or source.source_type in ('dummy', 'schedules_direct'):
return None
tvg_id = epg.tvg_id
if not tvg_id:
return None
file_path = _resolve_source_file(source)
if not file_path or not os.path.exists(file_path):
return None
now = timezone.now()
# Force a fresh read of the DB-backed index to avoid using stale related-object
# state when an EPG refresh invalidates/rebuilds the index concurrently.
source.refresh_from_db(fields=['programme_index'])
index = source.programme_index
if index is not None:
channels = index.get('channels', {})
if tvg_id not in channels:
# Channel has no programmes in the file
return None
offsets = channels[tvg_id]
if tvg_id in (index.get('interleaved_channels') or ()):
# Check all stored offsets first (cheap: one seek + one element parse each)
result = _read_programs_at_offsets(file_path, tvg_id, offsets, now)
if result is not None:
return result
# Current programme is beyond the stored offsets; scan forward from the
# last known position to avoid re-reading the already-checked portion
result = _scan_from_offset_for_tvg_id(file_path, tvg_id, offsets[-1], now)
if result == 'timeout':
logger.warning(
f'[find_current_program_for_tvg_id] Interleaved scan timed out for '
f'tvg_id={tvg_id} source={source.id}; index has {len(offsets)} offsets'
)
return None
return result
return _read_programs_at_offsets(file_path, tvg_id, offsets, now)
# No index yet: dispatch a background build and let the frontend retry.
# A sync scan can block a worker for ~10s on SMB-hosted EPGs.
build_programme_index_task.delay(source.id)
return 'timeout'
def _read_programs_at_offsets(file_path, tvg_id, offsets, now):
"""
Seek to each offset, extract <programme> elements for *tvg_id*, return the
first one currently airing. Chunk-based so it works on minified XML.
"""
PROG_CLOSE = b'</programme>'
CLOSE_LEN = len(PROG_CLOSE)
READ_SIZE = 2 * 1024 * 1024 # 2MB per read
with open(file_path, 'rb') as f:
for offset in offsets:
f.seek(offset)
buf = bytearray()
done = False
while not done:
chunk = f.read(READ_SIZE)
if not chunk and not buf:
break
buf.extend(chunk)
search_from = 0
while True:
tag_start, tag_end = _find_programme_tag(buf, search_from)
if tag_start == -1:
break
if tag_end == -1 and chunk:
break # incomplete tag, need more data
# Check channel before searching for close tag
m = _CHANNEL_ATTR_RE.search(
buf,
tag_start,
tag_end + 1 if tag_end != -1 else tag_start + _MAX_START_TAG,
)
if not m:
search_from = (
(tag_end + 1)
if tag_end != -1
else (tag_start + _PROGRAMME_TAG_LEN)
)
continue
ch = _decode_channel_id(m.group(1) or m.group(2))
if ch != tvg_id:
done = True # different channel, end of block
break
# Find the closing </programme> tag
close_pos = buf.find(
PROG_CLOSE, tag_end + 1 if tag_end != -1 else m.end()
)
if close_pos == -1:
if not chunk:
done = True # EOF with no close tag
break # need more data
close_end = close_pos + CLOSE_LEN
element_bytes = bytes(buf[tag_start:close_end])
search_from = close_end
try:
prog = _parse_programme_element(element_bytes)
except etree.XMLSyntaxError:
continue
start_str = prog.get('start')
stop_str = prog.get('stop')
if not start_str or not stop_str:
continue
start_time = parse_xmltv_time(start_str)
end_time = parse_xmltv_time(stop_str)
if start_time is None or end_time is None:
continue
if start_time <= now < end_time:
return _programme_to_dict(prog, start_time, end_time)
# Trim processed bytes
if search_from > 0:
del buf[:search_from]
search_from = 0
if not chunk:
break
return None
def _scan_from_offset_for_tvg_id(file_path, tvg_id, start_offset, now, timeout_sec=10):
"""
Scan forward from start_offset for tvg_id, skipping other channels rather than
stopping at a channel boundary. Used for interleaved/time-sorted XMLTV files where
a channel exceeded the stored offset cap.
Returns dict, None, or 'timeout'.
"""
PROG_CLOSE = b'</programme>'
CLOSE_LEN = len(PROG_CLOSE)
READ_SIZE = 2 * 1024 * 1024
deadline = time.monotonic() + timeout_sec
with open(file_path, 'rb') as f:
f.seek(start_offset)
buf = bytearray()
while True:
if time.monotonic() > deadline:
return 'timeout'
chunk = f.read(READ_SIZE)
if not chunk and not buf:
break
buf.extend(chunk)
search_from = 0
trim_to = 0
while True:
tag_start, tag_end = _find_programme_tag(buf, search_from)
if tag_start == -1:
trim_to = search_from
break
if tag_end == -1 and chunk:
trim_to = tag_start # keep incomplete tag for next read
break
m = _CHANNEL_ATTR_RE.search(
buf,
tag_start,
tag_end + 1 if tag_end != -1 else tag_start + _MAX_START_TAG,
)
if not m:
search_from = (
tag_end + 1 if tag_end != -1 else tag_start + _PROGRAMME_TAG_LEN
)
continue
ch = _decode_channel_id(m.group(1) or m.group(2))
if ch != tvg_id:
search_from = (
tag_end + 1 if tag_end != -1 else tag_start + _PROGRAMME_TAG_LEN
)
continue
close_pos = buf.find(
PROG_CLOSE, tag_end + 1 if tag_end != -1 else m.end()
)
if close_pos == -1:
trim_to = tag_start # keep incomplete element for next read
break
close_end = close_pos + CLOSE_LEN
element_bytes = bytes(buf[tag_start:close_end])
search_from = close_end
try:
prog = _parse_programme_element(element_bytes)
except etree.XMLSyntaxError:
continue
start_str = prog.get('start')
stop_str = prog.get('stop')
if not start_str or not stop_str:
continue
start_time = parse_xmltv_time(start_str)
end_time = parse_xmltv_time(stop_str)
if start_time is None or end_time is None:
continue
if start_time <= now < end_time:
return _programme_to_dict(prog, start_time, end_time)
if trim_to > 0:
del buf[:trim_to]
if not chunk:
break
return None

29
apps/epg/tests/fixtures/test_epg.xml vendored Normal file
View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<tv generator-info-name="test">
<channel id="channel.current">
<display-name>Current Channel</display-name>
</channel>
<channel id="channel.past">
<display-name>Past Channel</display-name>
</channel>
<channel id="channel.empty">
<display-name>Empty Channel</display-name>
</channel>
<programme start="20200101000000 +0000" stop="20200101060000 +0000" channel="channel.past">
<title>Past Show</title>
<desc>A show that already ended</desc>
</programme>
<programme start="20200101060000 +0000" stop="20200101120000 +0000" channel="channel.past">
<title>Another Past Show</title>
<desc>Another show that ended</desc>
</programme>
<programme start="20000101000000 +0000" stop="20991231235959 +0000" channel="channel.current">
<title>Always On Show</title>
<sub-title>The eternal broadcast</sub-title>
<desc>This programme spans a very long time for testing</desc>
</programme>
<programme start="20200101000000 +0000" stop="20200101060000 +0000" channel="channel.current">
<title>Old Current Show</title>
<desc>An old show on current channel</desc>
</programme>
</tv>

View file

@ -0,0 +1,194 @@
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework import status
from apps.epg.models import EPGSource, EPGData, ProgramData
User = get_user_model()
CURRENT_PROGRAMS_URL = "/api/epg/current-programs/"
class CurrentProgramsAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="testuser", password="testpass123"
)
self.user.user_level = 10
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.now = timezone.now()
# Create an XMLTV source with programmes
self.source = EPGSource.objects.create(
name="Test XMLTV",
source_type="xmltv",
url="http://example.com/epg.xml",
)
self.epg_data = EPGData.objects.create(
tvg_id="test.channel",
name="Test Channel",
epg_source=self.source,
)
self.program = ProgramData.objects.create(
epg=self.epg_data,
start_time=self.now - timezone.timedelta(hours=1),
end_time=self.now + timezone.timedelta(hours=1),
title="Current Show",
description="A show currently airing",
tvg_id="test.channel",
)
# Dummy EPG source
self.dummy_source = EPGSource.objects.create(
name="Dummy EPG",
source_type="dummy",
)
self.dummy_epg = EPGData.objects.create(
tvg_id="dummy.channel",
name="Dummy Channel",
epg_source=self.dummy_source,
)
def test_returns_program_for_current_time_window(self):
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": [self.epg_data.id]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["title"], "Current Show")
self.assertEqual(response.data[0]["epg_data_id"], self.epg_data.id)
def test_program_payload_has_expected_fields(self):
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": [self.epg_data.id]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
payload = response.data[0]
expected_keys = {
"id",
"start_time",
"end_time",
"title",
"description",
"sub_title",
"tvg_id",
"epg_data_id",
}
self.assertTrue(expected_keys.issubset(set(payload.keys())))
self.assertEqual(payload["epg_data_id"], self.epg_data.id)
@patch("apps.epg.api_views.find_current_program_for_tvg_id", return_value=None)
def test_returns_empty_when_no_program_matches(self, mock_find):
# Create EPG data with no DB programme and fallback returns None
epg_no_prog = EPGData.objects.create(
tvg_id="no.programme",
name="No Programme Channel",
epg_source=self.source,
)
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": [epg_no_prog.id]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
@patch(
"apps.epg.api_views.find_current_program_for_tvg_id",
return_value="timeout",
)
def test_returns_parsing_sentinel_on_timeout(self, mock_find):
epg_no_prog = EPGData.objects.create(
tvg_id="timeout.channel",
name="Timeout Channel",
epg_source=self.source,
)
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": [epg_no_prog.id]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertTrue(response.data[0]["parsing"])
self.assertEqual(response.data[0]["epg_data_id"], epg_no_prog.id)
def test_400_when_both_channel_uuids_and_epg_data_ids(self):
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"channel_uuids": ["abc"], "epg_data_ids": [1]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("not both", response.data["error"])
def test_skips_dummy_epg_sources(self):
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": [self.dummy_epg.id]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
def test_enforces_50_id_limit(self):
# Create 55 EPG entries, each with a current programme so DB lookup
# handles them all (no fallback to find_current_program_for_tvg_id).
ids = []
for i in range(55):
epg = EPGData.objects.create(
tvg_id=f"limit.{i}",
name=f"Limit Channel {i}",
epg_source=self.source,
)
ProgramData.objects.create(
epg=epg,
start_time=self.now - timezone.timedelta(hours=1),
end_time=self.now + timezone.timedelta(hours=1),
title=f"Show {i}",
tvg_id=f"limit.{i}",
)
ids.append(epg.id)
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": ids},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# The view truncates to 50 IDs, so at most 50 results
self.assertLessEqual(len(response.data), 50)
def test_400_for_non_integer_epg_data_ids(self):
response = self.client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": ["abc", "def"]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("valid integers", response.data["error"])
def test_auth_required(self):
anon_client = APIClient()
response = anon_client.post(
CURRENT_PROGRAMS_URL,
{"epg_data_ids": [self.epg_data.id]},
format="json",
)
self.assertIn(
response.status_code,
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
)

View file

@ -0,0 +1,754 @@
import os
import gzip
import tempfile
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.epg.models import EPGSource, EPGData
from apps.epg.tasks import (
find_current_program_for_tvg_id,
build_programme_index,
build_programme_index_task,
)
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
FIXTURE_XML = os.path.join(FIXTURE_DIR, "test_epg.xml")
class FindCurrentProgramTests(TestCase):
def setUp(self):
self.now = timezone.now()
self.source = EPGSource.objects.create(
name="Test Source",
source_type="xmltv",
url="http://example.com/epg.xml",
file_path=FIXTURE_XML,
)
self.epg = EPGData.objects.create(
tvg_id="channel.current",
name="Current Channel",
epg_source=self.source,
)
def test_returns_none_for_dummy_source(self):
dummy = EPGSource.objects.create(name="Dummy", source_type="dummy")
epg = EPGData.objects.create(
tvg_id="x", name="X", epg_source=dummy
)
self.assertIsNone(find_current_program_for_tvg_id(epg))
def test_returns_none_for_schedules_direct_source(self):
sd = EPGSource.objects.create(
name="SD", source_type="schedules_direct"
)
epg = EPGData.objects.create(
tvg_id="x", name="X", epg_source=sd
)
self.assertIsNone(find_current_program_for_tvg_id(epg))
def test_returns_none_when_tvg_id_empty(self):
epg = EPGData.objects.create(
tvg_id="", name="Empty", epg_source=self.source
)
self.assertIsNone(find_current_program_for_tvg_id(epg))
def test_returns_none_when_tvg_id_none(self):
epg = EPGData.objects.create(
tvg_id=None, name="None", epg_source=self.source
)
self.assertIsNone(find_current_program_for_tvg_id(epg))
def test_byte_offset_index_hit(self):
# Build the index from the fixture
build_programme_index(self.source.id)
self.source.refresh_from_db()
self.assertIsNotNone(self.source.programme_index)
# "Always On Show" spans 2000-2099, so should always be current
result = find_current_program_for_tvg_id(self.epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Always On Show")
self.assertEqual(result["sub_title"], "The eternal broadcast")
self.assertEqual(
result["description"],
"This programme spans a very long time for testing",
)
self.assertIn("start_time", result)
self.assertIn("end_time", result)
def test_byte_offset_index_miss(self):
# Build index, then query for a tvg_id that exists in the index
# but has no programme airing now
build_programme_index(self.source.id)
self.source.refresh_from_db()
epg_past = EPGData.objects.create(
tvg_id="channel.past",
name="Past Channel",
epg_source=self.source,
)
result = find_current_program_for_tvg_id(epg_past)
self.assertIsNone(result)
def test_index_miss_tvg_id_not_in_index(self):
# tvg_id not in index at all
build_programme_index(self.source.id)
self.source.refresh_from_db()
epg_unknown = EPGData.objects.create(
tvg_id="channel.nonexistent",
name="Nonexistent",
epg_source=self.source,
)
result = find_current_program_for_tvg_id(epg_unknown)
self.assertIsNone(result)
def test_accepts_integer_id(self):
# find_current_program_for_tvg_id accepts an int (EPGData PK)
build_programme_index(self.source.id)
result = find_current_program_for_tvg_id(self.epg.id)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Always On Show")
def test_returns_none_for_nonexistent_id(self):
result = find_current_program_for_tvg_id(99999)
self.assertIsNone(result)
def test_multi_block_file(self):
# Create an XML where programmes for the same channel appear in
# multiple non-contiguous blocks (A, B, A, B pattern).
# The index records multiple offsets per channel so the lookup
# scans all blocks.
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="A"/>\n'
' <channel id="B"/>\n'
' <programme start="20000101000000 +0000" stop="20000101060000 +0000" channel="A">\n'
" <title>A Morning</title>\n"
" </programme>\n"
' <programme start="20000101000000 +0000" stop="20000101060000 +0000" channel="B">\n'
" <title>B Morning</title>\n"
" </programme>\n"
# Second block for A — current programme lives here
' <programme start="20000101060000 +0000" stop="20991231235959 +0000" channel="A">\n'
" <title>A Current</title>\n"
" </programme>\n"
' <programme start="20000101060000 +0000" stop="20991231235959 +0000" channel="B">\n'
" <title>B Current</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="MultiBlock",
source_type="xmltv",
file_path=tmp_path,
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIsNotNone(src.programme_index)
epg_a = EPGData.objects.create(
tvg_id="A", name="A", epg_source=src
)
result = find_current_program_for_tvg_id(epg_a)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "A Current")
finally:
os.unlink(tmp_path)
def test_channel_id_entities_and_whitespace_match_tvg_id(self):
# programme@channel carries an XML entity and surrounding whitespace;
# EPGData.tvg_id holds the lxml-decoded, stripped form. The index key
# and lookup must canonicalize to the same value.
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="A&amp;E.us"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel=" A&amp;E.us ">\n'
" <title>A and E Now</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Entities", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn("A&E.us", src.programme_index["channels"])
epg = EPGData.objects.create(
tvg_id="A&E.us", name="A&E", epg_source=src
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "A and E Now")
finally:
os.unlink(tmp_path)
def test_offset_lookup_resolves_named_html_entities_in_programme_text(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="entity.channel"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="entity.channel">\n'
" <title>Caf&eacute; Live</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Named Entities", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
epg = EPGData.objects.create(
tvg_id="entity.channel", name="Entity Channel", epg_source=src
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Caf\u00e9 Live")
finally:
os.unlink(tmp_path)
def test_epgshare_fr_style_programme_with_channel_first_and_apostrophe_entities(self):
# Based on epgshare01 FR feeds: channel is the first programme attr,
# ids/text include non-ASCII plus XML entities, and sub-title is common.
tvg_id = "France.3.-.C\u00f4te.d'Azur.fr"
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
'<tv generator-info-name="none" generator-info-url="none">\n'
' <channel id="France.3.-.C\u00f4te.d&apos;Azur.fr">\n'
' <display-name lang="fr">France 3 - C\u00f4te d&apos;Azur</display-name>\n'
" </channel>\n"
' <programme channel="France.3.-.C\u00f4te.d&apos;Azur.fr" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title lang=\"fr\">La p&apos;tite librairie</title>\n"
" <sub-title lang=\"fr\">Le Lys de Brooklyn, de Betty Smith</sub-title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="EPGShare FR", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn(tvg_id, src.programme_index["channels"])
epg = EPGData.objects.create(
tvg_id=tvg_id, name="France 3 Cote d'Azur", epg_source=src
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "La p'tite librairie")
self.assertEqual(
result["sub_title"], "Le Lys de Brooklyn, de Betty Smith"
)
finally:
os.unlink(tmp_path)
def test_epgshare_fr_style_description_decodes_predefined_entities(self):
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
"<tv>\n"
' <channel id="Chasse.et.P\u00eache.fr"/>\n'
' <programme channel="Chasse.et.P\u00eache.fr" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title lang=\"fr\">Chasse &amp; p\u00eache, le mag</title>\n"
" <desc lang=\"fr\">Au sommaire : &quot;La r\u00e9gion&quot; &lt;HD&gt; &amp; bonus.</desc>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="EPGShare FR Entities", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
epg = EPGData.objects.create(
tvg_id="Chasse.et.P\u00eache.fr",
name="Chasse et Peche",
epg_source=src,
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Chasse & p\u00eache, le mag")
self.assertEqual(
result["description"],
'Au sommaire : "La r\u00e9gion" <HD> & bonus.',
)
finally:
os.unlink(tmp_path)
def test_epgshare_all_sources_style_start_stop_before_channel(self):
# The all-sources EPG contains both programme attr orders:
# channel/start/stop and start/stop/channel.
tvg_id = "Atfal.&.Mawaheb.ae"
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
"<tv>\n"
' <channel id="Atfal.&amp;.Mawaheb.ae"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="Atfal.&amp;.Mawaheb.ae">\n'
" <title lang=\"en\">Kids &amp; Talent</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="EPGShare All Sources", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn(tvg_id, src.programme_index["channels"])
epg = EPGData.objects.create(
tvg_id=tvg_id, name="Atfal and Mawaheb", epg_source=src
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Kids & Talent")
finally:
os.unlink(tmp_path)
def test_jesmann_fullguide_style_numeric_channel_id(self):
# FullGuide.xml.gz uses numeric channel ids and consistently orders
# programme attrs as start/stop/channel.
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
"<tv>\n"
' <channel id="123958">\n'
" <display-name>Sample Numeric Channel</display-name>\n"
" </channel>\n"
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="123958">\n'
" <title>Breaking Basics</title>\n"
" <desc>Tobi visits the &quot;Flying Steps&quot; in Berlin.</desc>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Jesmann FullGuide", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn("123958", src.programme_index["channels"])
epg = EPGData.objects.create(
tvg_id="123958", name="Numeric Channel", epg_source=src
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Breaking Basics")
self.assertEqual(
result["description"],
'Tobi visits the "Flying Steps" in Berlin.',
)
finally:
os.unlink(tmp_path)
def test_offset_lookup_accepts_single_quoted_channel_attribute(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
" <channel id='single.quote.channel'/>\n"
" <programme start='20000101000000 +0000' "
"stop='20991231235959 +0000' channel='single.quote.channel'>\n"
" <title>Single Quote Current</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Single Quotes", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn("single.quote.channel", src.programme_index["channels"])
epg = EPGData.objects.create(
tvg_id="single.quote.channel",
name="Single Quote Channel",
epg_source=src,
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Single Quote Current")
finally:
os.unlink(tmp_path)
def test_offset_lookup_resolves_html_named_entity_not_predefined_by_xml(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="html.entity.channel"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="html.entity.channel">\n'
" <title>Caf&eacute;&nbsp;Society</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="HTML Entities", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
epg = EPGData.objects.create(
tvg_id="html.entity.channel",
name="HTML Entity Channel",
epg_source=src,
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Caf\u00e9\u00a0Society")
finally:
os.unlink(tmp_path)
def test_offset_lookup_handles_programme_element_larger_than_read_chunk(self):
long_desc = "x" * (2 * 1024 * 1024 + 1024)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="large.programme.channel"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="large.programme.channel">\n'
" <title>Large Programme Current</title>\n"
f" <desc>{long_desc}</desc>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Large Programme", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
epg = EPGData.objects.create(
tvg_id="large.programme.channel",
name="Large Programme Channel",
epg_source=src,
)
result = find_current_program_for_tvg_id(epg)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Large Programme Current")
self.assertEqual(len(result["description"]), len(long_desc))
finally:
os.unlink(tmp_path)
@patch("apps.epg.tasks.build_programme_index_task")
def test_no_index_dispatches_build_and_returns_timeout(self, mock_build_task):
# Source with no index and file on disk
src = EPGSource.objects.create(
name="No Index",
source_type="xmltv",
file_path=FIXTURE_XML,
programme_index=None,
)
epg = EPGData.objects.create(
tvg_id="channel.current",
name="Current",
epg_source=src,
)
result = find_current_program_for_tvg_id(epg)
self.assertEqual(result, "timeout")
mock_build_task.delay.assert_called_once_with(src.id)
class BuildProgrammeIndexTests(TestCase):
def test_builds_index_from_fixture(self):
source = EPGSource.objects.create(
name="Index Test",
source_type="xmltv",
file_path=FIXTURE_XML,
)
build_programme_index(source.id)
source.refresh_from_db()
index = source.programme_index
self.assertIsNotNone(index)
channels = index["channels"]
self.assertIn("channel.current", channels)
self.assertIn("channel.past", channels)
# channel.empty has no programmes
self.assertNotIn("channel.empty", channels)
# Small fixture has no interleaved channels
self.assertEqual(index["interleaved_channels"], [])
def test_builds_index_when_channel_attribute_has_valid_xml_spacing(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="spaced.channel"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel = "spaced.channel">\n'
" <title>Spaced Attribute Current</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Spaced Attribute", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn(
"spaced.channel", src.programme_index["channels"]
)
finally:
os.unlink(tmp_path)
def test_builds_index_from_extracted_file_path_for_gz_source(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="gz.channel"/>\n'
' <programme channel="gz.channel" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title>GZ Current</title>\n"
" </programme>\n"
"</tv>\n"
)
gz_path = None
xml_path = None
try:
with tempfile.NamedTemporaryFile(
mode="wb", suffix=".xml.gz", delete=False
) as gz_file:
gz_path = gz_file.name
with gzip.GzipFile(fileobj=gz_file, mode="wb") as compressed:
compressed.write(b"not the file the index should scan")
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as xml_file:
xml_file.write(xml)
xml_path = xml_file.name
src = EPGSource.objects.create(
name="Extracted GZ",
source_type="xmltv",
file_path=gz_path,
extracted_file_path=xml_path,
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn("gz.channel", src.programme_index["channels"])
finally:
if gz_path:
os.unlink(gz_path)
if xml_path:
os.unlink(xml_path)
def test_builds_index_ignores_elements_whose_name_only_starts_with_programme(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="real.channel"/>\n'
' <programme-extra channel="not.a.programme" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title>Not a Programme</title>\n"
" </programme-extra>\n"
' <programme channel="real.channel" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title>Real Programme</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", encoding="utf-8", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Programme Prefix", source_type="xmltv", file_path=tmp_path
)
build_programme_index(src.id)
src.refresh_from_db()
self.assertIn("real.channel", src.programme_index["channels"])
self.assertNotIn("not.a.programme", src.programme_index["channels"])
finally:
os.unlink(tmp_path)
def test_nonexistent_source_does_not_raise(self):
# Should log error but not raise
build_programme_index(99999)
@patch("apps.epg.tasks.build_programme_index")
def test_task_builds_and_releases_lock_when_free(self, mock_build):
mock_redis = MagicMock()
mock_redis.set.return_value = True # lock acquired
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
build_programme_index_task(42)
mock_build.assert_called_once_with(42)
mock_redis.set.assert_called_once()
self.assertEqual(
mock_redis.set.call_args.args[0], "building_programme_index_42"
)
mock_redis.delete.assert_called_once_with("building_programme_index_42")
@patch("apps.epg.tasks.build_programme_index")
def test_task_skips_when_lock_held(self, mock_build):
mock_redis = MagicMock()
mock_redis.set.return_value = False # another build in flight
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
build_programme_index_task(42)
mock_build.assert_not_called()
mock_redis.delete.assert_not_called()
@patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom"))
def test_task_releases_lock_on_failure(self, mock_build):
mock_redis = MagicMock()
mock_redis.set.return_value = True
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
with self.assertRaises(RuntimeError):
build_programme_index_task(42)
mock_redis.delete.assert_called_once_with("building_programme_index_42")
def test_per_channel_interleaved_marking(self):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="A"/>\n'
' <channel id="B"/>\n'
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="A">\n'
" <title>A Current</title>\n"
" </programme>\n"
' <programme start="20000101000000 +0000" '
'stop="20991231235959 +0000" channel="B">\n'
" <title>B Current</title>\n"
" </programme>\n"
' <programme start="19990101000000 +0000" '
'stop="19990102000000 +0000" channel="A">\n'
" <title>A Old</title>\n"
" </programme>\n"
"</tv>\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml)
tmp_path = f.name
try:
src = EPGSource.objects.create(
name="Interleaved", source_type="xmltv", file_path=tmp_path
)
with patch("apps.epg.tasks._OFFSET_CAP", 1):
build_programme_index(src.id)
src.refresh_from_db()
index = src.programme_index
self.assertEqual(index["interleaved_channels"], ["A"])
epg_b = EPGData.objects.create(
tvg_id="B", name="B", epg_source=src
)
with patch(
"apps.epg.tasks._scan_from_offset_for_tvg_id"
) as mock_scan:
result_b = find_current_program_for_tvg_id(epg_b)
self.assertIsNotNone(result_b)
self.assertEqual(result_b["title"], "B Current")
mock_scan.assert_not_called()
epg_a = EPGData.objects.create(
tvg_id="A", name="A", epg_source=src
)
result_a = find_current_program_for_tvg_id(epg_a)
self.assertIsNotNone(result_a)
self.assertEqual(result_a["title"], "A Current")
finally:
os.unlink(tmp_path)

View file

@ -2955,7 +2955,7 @@ def refresh_account_profiles(account_id):
existing_props = profile.custom_properties or {}
existing_props.update(profile_account_info)
profile.custom_properties = existing_props
profile.save(update_fields=['custom_properties'])
profile.save(update_fields=['custom_properties', 'exp_date'])
profiles_updated += 1
logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})")

View file

@ -2049,3 +2049,149 @@ class Migration0037DemoteOrphansTests(TestCase):
"auto_created=True or deleted",
)
self.assertIsNone(ch.auto_created_by)
class CompactNumberingWithGroupOverrideTests(TestCase):
"""
Compact numbering must keep working when a Channel Group Override is
configured on the source ChannelGroupM3UAccount. With an override,
sync stores auto-created channels under the OVERRIDE TARGET group's id
rather than the source group's id recorded on the relation. The
compact paths resolve the relation from the channel's group id, so
without the override-aware fallback they all miss and slot accounting
silently breaks (hidden channels keep their numbers, unhides get none,
repack sees zero channels).
Fix location: apps/channels/compact_numbering.py
(get_group_relation_for_channel fallback, _repack_inner group_ids,
assign_compact_numbers_for_channels bulk fallback).
"""
def _override_setup(self, start=100, end=110):
account = _make_account()
source_group = _make_group(name="SourcePPV")
target_group = _make_group(name="TargetAll")
rel = _attach_group_to_account(
account,
source_group,
custom_properties={
"compact_numbering": True,
"group_override": target_group.id,
},
)
rel.auto_sync_channel_start = start
rel.auto_sync_channel_end = end
rel.save()
return account, source_group, target_group, rel
def _auto_channel(self, account, group, number=None, hidden=False, name="PPV"):
return Channel.objects.create(
name=name,
channel_number=number,
channel_group=group,
auto_created=True,
auto_created_by=account,
hidden_from_output=hidden,
)
def test_hide_releases_slot_under_group_override(self):
# Fail signature: channel_number stays populated after hide =
# release_compact_number_on_hide bailed because
# get_group_relation_for_channel returned None for the override
# target group.
account, source, target, rel = self._override_setup()
ch = self._auto_channel(account, target, number=100)
ch.hidden_from_output = True
ch.save()
ch.refresh_from_db()
self.assertIsNone(
ch.channel_number,
"Hiding an auto channel under a Channel Group Override must "
"release its compact slot (channel_number=None)",
)
def test_unhide_assigns_slot_under_group_override(self):
# Fail signature: channel_number stays None after unhide =
# assign_compact_number_on_unhide bailed on the override target.
account, source, target, rel = self._override_setup()
ch = self._auto_channel(account, target, number=None, hidden=True)
ch.hidden_from_output = False
ch.save()
ch.refresh_from_db()
self.assertEqual(
ch.channel_number,
100,
"Unhiding an auto channel under a Channel Group Override must "
"assign a number from the compact range",
)
def test_repack_sees_channels_under_override_target(self):
# Fail signature: assigned=0 = _repack_inner filtered on the source
# group id and found none of the channels stored under the target.
from apps.channels.compact_numbering import repack_group
account, source, target, rel = self._override_setup()
channels = [
self._auto_channel(account, target, number=900 + i, name=f"C{i}")
for i in range(3)
]
result = repack_group(rel)
self.assertEqual(result["assigned"], 3)
self.assertEqual(result["failed"], 0)
nums = sorted(
Channel.objects.filter(
id__in=[c.id for c in channels]
).values_list("channel_number", flat=True)
)
self.assertEqual(nums, [100, 101, 102])
def test_no_override_fast_path_still_resolves(self):
# Regression guard: the common no-override case must still resolve
# the relation via the direct lookup (channel.channel_group_id ==
# source group id), unaffected by the override fallback.
from apps.channels.compact_numbering import (
get_group_relation_for_channel,
)
account = _make_account()
group = _make_group(name="PlainSports")
rel = _attach_group_to_account(
account, group, custom_properties={"compact_numbering": True}
)
ch = self._auto_channel(account, group, number=100)
resolved = get_group_relation_for_channel(ch)
self.assertIsNotNone(resolved)
self.assertEqual(resolved.id, rel.id)
def test_repack_under_override_query_count_does_not_scale(self):
# Perf guard: the override-aware repack widens the channel lookup's
# IN clause; it must not add a query per channel. Query count must
# be identical for N and 3*N channels.
from django.db import connection
from django.test.utils import CaptureQueriesContext
from apps.channels.compact_numbering import repack_group
account, source, target, rel = self._override_setup(start=100, end=300)
def measure(n):
Channel.objects.filter(auto_created_by=account).delete()
for i in range(n):
self._auto_channel(account, target, number=900 + i, name=f"C{i}")
with CaptureQueriesContext(connection) as ctx:
repack_group(rel)
return len(ctx.captured_queries)
small = measure(5)
large = measure(15)
self.assertEqual(
small,
large,
f"repack query count scaled with channel count: {small} -> {large}",
)

View file

@ -1,6 +1,5 @@
from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse
import json
from rest_framework.response import Response
from django.urls import reverse
from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream
from apps.channels.utils import format_channel_number
@ -16,7 +15,7 @@ from datetime import datetime, timedelta
import html
import time
from tzlocal import get_localzone
from urllib.parse import urlparse
from urllib.parse import urlencode
import base64
import logging
from django.db.models.functions import Lower
@ -24,7 +23,7 @@ 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
from core.utils import log_system_event, build_absolute_uri_with_port
import hashlib
logger = logging.getLogger(__name__)
@ -127,7 +126,7 @@ def generate_m3u(request, profile_name=None, user=None):
# Check if this is a POST request with data (which we don't want to allow)
if request.method == "POST" and request.body:
if request.body.decode() != '{}':
return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode()))
return HttpResponseForbidden("POST requests with body are not allowed.")
if user is not None:
if user.user_level < 10:
@ -211,7 +210,21 @@ def generate_m3u(request, profile_name=None, user=None):
# This is an XC API request - use XC-style EPG URL
base_url = build_absolute_uri_with_port(request, '')
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
# Build the query-string suffix for stream URLs once - it's the same for every channel
xc_qs = {}
if output_profile_id:
xc_qs['output_profile'] = output_profile_id
if output_format_param:
xc_qs['output_format'] = output_format_param
xc_qs_suffix = f"?{urlencode(xc_qs)}" if xc_qs else ""
else:
# Pre-compute proxy query-string suffix (same for every channel in this request)
proxy_qs = {}
if output_profile_id:
proxy_qs['output_profile'] = output_profile_id
if output_format_param:
proxy_qs['output_format'] = output_format_param
proxy_qs_suffix = f"?{urlencode(proxy_qs)}" if proxy_qs else ""
# Regular request - use standard EPG endpoint
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
@ -219,7 +232,6 @@ def generate_m3u(request, profile_name=None, user=None):
preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days']
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
if query_params:
from urllib.parse import urlencode
epg_url = f"{epg_base_url}?{urlencode(query_params)}"
else:
epg_url = epg_base_url
@ -281,9 +293,7 @@ def generate_m3u(request, profile_name=None, user=None):
# Determine the stream URL based on request type
if is_xc_request:
# XC API request - use XC-style stream URL format
base_url = build_absolute_uri_with_port(request, '')
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}"
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}{xc_qs_suffix}"
elif use_direct_urls:
# Try to get the first stream's direct URL
all_streams = channel.streams.all()
@ -297,16 +307,7 @@ def generate_m3u(request, profile_name=None, user=None):
else:
# Standard behavior - use proxy URL
base_stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
qs_parts = {}
if output_profile_id:
qs_parts['output_profile'] = output_profile_id
if output_format_param:
qs_parts['output_format'] = output_format_param
if qs_parts:
from urllib.parse import urlencode
stream_url = f"{base_stream_url}?{urlencode(qs_parts)}"
else:
stream_url = base_stream_url
stream_url = f"{base_stream_url}{proxy_qs_suffix}"
m3u_content += extinf_line + stream_url + "\n"
@ -2548,60 +2549,79 @@ def xc_get_vod_categories(user):
def xc_get_vod_streams(request, user, category_id=None):
"""Get VOD streams (movies) for XtreamCodes API"""
from apps.vod.models import Movie, M3UMovieRelation
from django.db.models import Prefetch
from apps.vod.models import M3UMovieRelation
from django.db import connection
rel_filters = {"m3u_account__is_active": True}
if category_id:
rel_filters["category_id"] = category_id
base_qs = (
M3UMovieRelation.objects
.filter(**rel_filters)
.select_related('movie', 'movie__logo', 'category')
)
if connection.vendor == 'postgresql':
# DISTINCT ON returns one row per movie (highest-priority active relation)
# in a single query. ORDER BY must lead with the DISTINCT field.
relations = list(
base_qs.order_by('movie_id', '-m3u_account__priority', 'id')
.distinct('movie_id')
)
else:
# SQLite fallback: fetch all matching relations, deduplicate in Python.
seen: dict = {}
for rel in base_qs.order_by('-m3u_account__priority', 'id'):
if rel.movie_id not in seen:
seen[rel.movie_id] = rel
relations = list(seen.values())
# Precompute logo URL prefix/suffix once (mirrors _xc_live_streams_setup)
# so each row only needs a string concat instead of reverse() + URI build.
_base_url = build_absolute_uri_with_port(request, "")
_sample_logo_path = reverse("api:vod:vodlogo-cache", args=[0])
_logo_prefix_raw, _, _logo_suffix_raw = _sample_logo_path.partition("/0/")
_logo_url_prefix = _base_url + _logo_prefix_raw + "/"
_logo_url_suffix = "/" + _logo_suffix_raw
# Sort by name (DISTINCT ON forces ORDER BY movie_id; SQLite path is unsorted).
relations.sort(key=lambda r: (r.movie.name or "").lower())
streams = []
append = streams.append
for num, relation in enumerate(relations, 1):
movie = relation.movie
custom_props = movie.custom_properties or {}
category = relation.category
category_id_str = str(category.id) if category else "0"
category_id_list = [category.id] if category else []
rating = movie.rating
logo = movie.logo
# All authenticated users get access to VOD from all active M3U accounts
filters = {"m3u_relations__m3u_account__is_active": True}
if category_id:
filters["m3u_relations__category_id"] = category_id
# Optimize with prefetch_related to eliminate N+1 queries
# This loads all relations in a single query instead of one per movie
movies = Movie.objects.filter(**filters).select_related('logo').prefetch_related(
Prefetch(
'm3u_relations',
queryset=M3UMovieRelation.objects.filter(
m3u_account__is_active=True
).select_related('m3u_account', 'category').order_by('-m3u_account__priority', 'id'),
to_attr='active_relations'
)
).distinct()
for movie in movies:
# Get the first (highest priority) relation from prefetched data
# This avoids the N+1 query problem entirely
if hasattr(movie, 'active_relations') and movie.active_relations:
relation = movie.active_relations[0]
else:
# Fallback - should rarely be needed with proper prefetching
continue
streams.append({
"num": movie.id,
append({
"num": num,
"name": movie.name,
"stream_type": "movie",
"stream_id": movie.id,
"stream_icon": (
None if not movie.logo
else build_absolute_uri_with_port(
request,
reverse("api:vod:vodlogo-cache", args=[movie.logo.id])
)
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
),
#'stream_icon': movie.logo.url if movie.logo else '',
"rating": movie.rating or "0",
"rating_5based": round(float(movie.rating or 0) / 2, 2) if movie.rating else 0,
"rating": rating or "0",
"rating_5based": round(float(rating or 0) / 2, 2) if rating else 0,
"added": str(int(movie.created_at.timestamp())),
"is_adult": 0,
"tmdb_id": movie.tmdb_id or "",
"imdb_id": movie.imdb_id or "",
"trailer": (movie.custom_properties or {}).get('trailer') or "",
"category_id": str(relation.category.id) if relation.category else "0",
"category_ids": [int(relation.category.id)] if relation.category else [],
"trailer": custom_props.get('youtube_trailer') or "",
"plot": movie.description or "",
"genre": movie.genre or "",
"year": movie.year or "",
"director": custom_props.get('director', ''),
"cast": custom_props.get('actors', ''),
"release_date": custom_props.get('release_date', ''),
"category_id": category_id_str,
"category_ids": category_id_list,
"container_extension": relation.container_extension or "mp4",
"custom_sid": None,
"direct_source": "",
@ -2635,47 +2655,70 @@ def xc_get_series_categories(user):
def xc_get_series(request, user, category_id=None):
"""Get series list for XtreamCodes API"""
from apps.vod.models import M3USeriesRelation
from django.db import connection
series_list = []
# All authenticated users get access to series from all active M3U accounts
filters = {"m3u_account__is_active": True}
rel_filters = {"m3u_account__is_active": True}
if category_id:
filters["category_id"] = category_id
rel_filters["category_id"] = category_id
# Get series relations instead of series directly
series_relations = M3USeriesRelation.objects.filter(**filters).select_related(
'series', 'series__logo', 'category', 'm3u_account'
base_qs = (
M3USeriesRelation.objects
.filter(**rel_filters)
.select_related('series', 'series__logo', 'category')
)
for relation in series_relations:
if connection.vendor == 'postgresql':
relations = list(
base_qs.order_by('series_id', '-m3u_account__priority', 'id')
.distinct('series_id')
)
else:
seen: dict = {}
for rel in base_qs.order_by('-m3u_account__priority', 'id'):
if rel.series_id not in seen:
seen[rel.series_id] = rel
relations = list(seen.values())
_base_url = build_absolute_uri_with_port(request, "")
_sample_logo_path = reverse("api:vod:vodlogo-cache", args=[0])
_logo_prefix_raw, _, _logo_suffix_raw = _sample_logo_path.partition("/0/")
_logo_url_prefix = _base_url + _logo_prefix_raw + "/"
_logo_url_suffix = "/" + _logo_suffix_raw
relations.sort(key=lambda r: (r.series.name or "").lower())
series_list = []
append = series_list.append
for num, relation in enumerate(relations, 1):
series = relation.series
series_list.append({
"num": relation.id, # Use relation ID
custom_props = series.custom_properties or {}
category = relation.category
rating = series.rating
logo = series.logo
year_str = str(series.year) if series.year else ""
release_date = custom_props.get('release_date', year_str)
append({
"num": num,
"name": series.name,
"series_id": relation.id, # Use relation ID
"series_id": relation.id,
"cover": (
None if not series.logo
else build_absolute_uri_with_port(
request,
reverse("api:vod:vodlogo-cache", args=[series.logo.id])
)
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
),
"plot": series.description or "",
"cast": series.custom_properties.get('cast', '') if series.custom_properties else "",
"director": series.custom_properties.get('director', '') if series.custom_properties else "",
"cast": custom_props.get('cast', ''),
"director": custom_props.get('director', ''),
"genre": series.genre or "",
"release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""),
"releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""),
"release_date": release_date,
"releaseDate": release_date,
"last_modified": str(int(relation.updated_at.timestamp())),
"rating": str(series.rating or "0"),
"rating_5based": str(round(float(series.rating or 0) / 2, 2)) if series.rating else "0",
"backdrop_path": series.custom_properties.get('backdrop_path', []) if series.custom_properties else [],
"youtube_trailer": series.custom_properties.get('youtube_trailer', '') if series.custom_properties else "",
"episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "",
"category_id": str(relation.category.id) if relation.category else "0",
"category_ids": [int(relation.category.id)] if relation.category else [],
"rating": str(rating or "0"),
"rating_5based": str(round(float(rating or 0) / 2, 2)) if rating else "0",
"backdrop_path": custom_props.get('backdrop_path', []),
"youtube_trailer": custom_props.get('youtube_trailer', ''),
"episode_run_time": custom_props.get('episode_run_time', ''),
"category_id": str(category.id) if category else "0",
"category_ids": [category.id] if category else [],
"tmdb_id": series.tmdb_id or "",
"imdb_id": series.imdb_id or "",
})
@ -3108,87 +3151,6 @@ def xc_series_stream(request, username, password, stream_id, extension):
return HttpResponseRedirect(vod_url)
def get_host_and_port(request):
"""
Returns (host, port) for building absolute URIs.
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
- Falls back to Host header.
- Returns None for port if using standard ports (80/443) to omit from URLs.
- In dev, uses 5656 as a guess if port cannot be determined.
"""
# Determine the scheme first - needed for standard port detection
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
standard_port = "443" if scheme == "https" else "80"
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
if xfh:
if ":" in xfh:
host, port = xfh.split(":", 1)
# Omit standard ports from URLs
if port == standard_port:
return host, None
# Non-standard port in X-Forwarded-Host - return it
# This handles reverse proxies on non-standard ports (e.g., https://example.com:8443)
return host, port
else:
host = xfh
# Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host)
port = request.META.get("HTTP_X_FORWARDED_PORT")
if port:
# Omit standard ports from URLs
return host, None if port == standard_port else port
# If X-Forwarded-Proto is set but no valid port, assume standard
if request.META.get("HTTP_X_FORWARDED_PROTO"):
return host, None
# 2. Try Host header
raw_host = request.get_host()
if ":" in raw_host:
host, port = raw_host.split(":", 1)
# Omit standard ports from URLs
return host, None if port == standard_port else port
else:
host = raw_host
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
port = request.META.get("HTTP_X_FORWARDED_PORT")
if port:
# Omit standard ports from URLs
return host, None if port == standard_port else port
# 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present)
# If so, assume standard port for the scheme (don't trust SERVER_PORT in this case)
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
return host, None
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
port = request.META.get("SERVER_PORT")
if port:
# Omit standard ports from URLs
return host, None if port == standard_port else port
# 6. Dev fallback: guess port 5656
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
return host, "5656"
# 7. Final fallback: assume standard port for scheme (omit from URL)
return host, None
def build_absolute_uri_with_port(request, path):
"""
Build an absolute URI with optional port.
Port is omitted from URL if None (standard port for scheme).
"""
host, port = get_host_and_port(request)
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
if port:
return f"{scheme}://{host}:{port}{path}"
else:
return f"{scheme}://{host}{path}"
def format_duration_hms(seconds):
"""
Format a duration in seconds as HH:MM:SS zero-padded string.

View file

@ -1,7 +1,6 @@
import hashlib
import ipaddress
import logging
import io
import json
import re
import socket
@ -9,6 +8,7 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, serializers
from drf_spectacular.utils import extend_schema, inline_serializer
from django.core.cache import cache
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.http import FileResponse
@ -335,6 +335,21 @@ def _save_fetched_manifest_to_repo(repo, data, verified):
return None
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/")
keys = []
for p in manifest.get("plugins", []):
url = p.get("manifest_url", "")
if not url:
continue
if root_url and not url.startswith(("http://", "https://")):
url = f"{root_url}/{url}"
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
if keys:
cache.delete_many(keys)
def _unmanage_dropped_slugs(repo, new_manifest_data):
"""After a manifest refresh, clear source_repo on any installed plugins
whose slug is no longer listed in the repo's manifest. Also syncs the
@ -571,6 +586,7 @@ class PluginDeleteAPIView(PluginAuthMixin, APIView):
# ---------------------------------------------------------------------------
MANIFEST_FETCH_TIMEOUT = 15
PLUGIN_DETAIL_CACHE_TTL = 300 # seconds
OFFICIAL_KEY_PATH = os.path.join(
os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub"
@ -589,6 +605,101 @@ def _normalize_pgp_key(text):
return text
def _gpg_run(cmd, input_data=None, timeout=30):
"""
Run a GPG command using os.posix_spawn.
os.posix_spawn skips pthread_atfork handlers, avoiding the indefinite hang
that fork()-based approaches suffer under gevent+uWSGI. select.select()
and time.sleep() are gevent-patched so reads and the waitpid poll yield to
the hub cooperatively.
Returns (returncode, stdout_bytes, stderr_bytes).
"""
import select as _select
import signal as _signal
import time as _time
stdin_r, stdin_w = os.pipe()
stdout_r, stdout_w = os.pipe()
stderr_r, stderr_w = os.pipe()
try:
executable = shutil.which(cmd[0]) or cmd[0]
pid = os.posix_spawn(
executable, cmd, os.environ,
file_actions=[
(os.POSIX_SPAWN_DUP2, stdin_r, 0),
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
(os.POSIX_SPAWN_DUP2, stderr_w, 2),
(os.POSIX_SPAWN_CLOSE, stdin_r),
(os.POSIX_SPAWN_CLOSE, stdin_w),
(os.POSIX_SPAWN_CLOSE, stdout_w),
(os.POSIX_SPAWN_CLOSE, stderr_w),
(os.POSIX_SPAWN_CLOSE, stdout_r),
(os.POSIX_SPAWN_CLOSE, stderr_r),
],
)
except Exception:
for fd in (stdin_r, stdin_w, stdout_r, stdout_w, stderr_r, stderr_w):
try:
os.close(fd)
except OSError:
pass
raise
for fd in (stdin_r, stdout_w, stderr_w):
os.close(fd)
try:
if input_data:
os.write(stdin_w, input_data)
finally:
os.close(stdin_w)
out, err = [], []
done = set()
deadline = _time.monotonic() + timeout
try:
while len(done) < 2:
remaining = deadline - _time.monotonic()
if remaining <= 0:
try:
os.kill(pid, _signal.SIGKILL)
except ProcessLookupError:
pass
break
fds = [fd for fd in (stdout_r, stderr_r) if fd not in done]
readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5))
for fd in readable:
data = os.read(fd, 8192)
if data:
(out if fd == stdout_r else err).append(data)
else:
done.add(fd)
finally:
for fd in (stdout_r, stderr_r):
try:
os.close(fd)
except OSError:
pass
deadline = _time.monotonic() + 5.0
while _time.monotonic() < deadline:
try:
wpid, st = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
return -1, b"".join(out), b"".join(err)
if wpid == pid:
if os.WIFEXITED(st):
return os.WEXITSTATUS(st), b"".join(out), b"".join(err)
if os.WIFSIGNALED(st):
return -os.WTERMSIG(st), b"".join(out), b"".join(err)
return -1, b"".join(out), b"".join(err)
_time.sleep(0.01)
return -1, b"".join(out), b"".join(err)
def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None):
"""Verify a detached GPG signature over the canonical manifest JSON.
@ -597,7 +708,7 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
repos). When *None* the bundled official key is used instead.
Returns True if valid, False if invalid/error, None if verification
could not be attempted (no signature, no key, gnupg missing, etc.).
could not be attempted (no signature, no key, gpg binary missing, etc.).
"""
if not signature_armored:
return None
@ -614,18 +725,19 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
logger.debug("No GPG public key available; skipping verification")
return None
try:
import gnupg
except ImportError:
logger.debug("python-gnupg not installed; skipping signature verification")
return None
tmp_home = tempfile.mkdtemp(prefix="gpg_verify_")
try:
gpg = gnupg.GPG(gnupghome=tmp_home)
import_result = gpg.import_keys(key_text)
if not import_result.fingerprints:
logger.warning("Failed to import GPG public key")
key_bytes = key_text.encode("utf-8") if isinstance(key_text, str) else key_text
rc, _, import_stderr = _gpg_run(
["gpg", "--batch", "--no-tty", "--status-fd", "2",
"--homedir", tmp_home, "--import"],
input_data=key_bytes,
)
if logger.isEnabledFor(logging.DEBUG):
for line in import_stderr.decode("utf-8", errors="replace").splitlines():
logger.debug("gpg import: %s", line)
if rc != 0:
logger.warning("GPG key import failed (rc=%d)", rc)
return None
# Must match what the signing script produces: jq -c '.manifest'
@ -639,8 +751,18 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
with open(sig_path, "w") as sf:
sf.write(signature_armored)
verified = gpg.verify_data(sig_path, manifest_bytes)
return bool(verified)
rc, _, verify_stderr = _gpg_run(
["gpg", "--batch", "--no-tty", "--status-fd", "2",
"--homedir", tmp_home, "--verify", sig_path, "-"],
input_data=manifest_bytes,
)
if logger.isEnabledFor(logging.DEBUG):
for line in verify_stderr.decode("utf-8", errors="replace").splitlines():
logger.debug("gpg verify: %s", line)
return rc == 0 and b"VALIDSIG" in verify_stderr
except FileNotFoundError:
logger.debug("gpg binary not found; skipping signature verification")
return None
except Exception:
logger.exception("GPG signature verification error")
return False
@ -881,6 +1003,7 @@ class PluginRepoRefreshAPIView(PluginAuthMixin, APIView):
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)
_unmanage_dropped_slugs(repo, data)
_invalidate_plugin_detail_cache(repo.id, data)
return Response(PluginRepoSerializer(repo).data)
@ -1017,6 +1140,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
_validate_fetch_url(manifest_url)
except ValueError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
cache_key = f"plugin_detail:{repo_id}:{hashlib.md5(manifest_url.encode()).hexdigest()}"
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
try:
resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT)
resp.raise_for_status()
@ -1045,10 +1174,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
if url_val and not url_val.startswith(("http://", "https://")):
manifest_obj["latest"][url_field] = f"{root_url}/{url_val}"
return Response({
result = {
"manifest": manifest_obj,
"signature_verified": verified,
})
}
cache.set(cache_key, result, PLUGIN_DETAIL_CACHE_TTL)
return Response(result)
except Exception as e:
logger.exception("Failed to fetch plugin manifest from %s", manifest_url)
return Response(

View file

@ -399,7 +399,8 @@ class ChannelStatus:
'owner': metadata.get(ChannelMetadataField.OWNER),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
'client_count': client_count,
'uptime': uptime
'uptime': uptime,
'started_at': created_at if created_at > 0 else None,
}
channel_name = metadata.get(ChannelMetadataField.CHANNEL_NAME)
@ -418,7 +419,8 @@ class ChannelStatus:
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)
# TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip
total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES)
if total_bytes_bytes:
total_bytes = int(total_bytes_bytes)
info['total_bytes'] = total_bytes
@ -459,29 +461,31 @@ class ChannelStatus:
client_key = RedisKeys.client_metadata(channel_id, client_id)
# Fetch only the fields we need in one round-trip (hmget returns a list
# in the same order as the requested keys; values are None if absent)
ua, ip, connected_at, user_id, output_format, raw_profile_id = (
proxy_server.redis_client.hmget(
client_key,
'user_agent', 'ip_address', 'connected_at', 'user_id',
'output_format', 'output_profile_id',
)
)
client_info = {
'client_id': client_id,
'user_agent': ua,
'output_format': output_format or 'mpegts',
}
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
client_info['user_agent'] = user_agent_bytes
if ip:
client_info['ip_address'] = ip
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
if ip_address_bytes:
client_info['ip_address'] = ip_address_bytes
if connected_at:
client_info['connected_at'] = float(connected_at)
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
if connected_at_bytes:
client_info['connected_at'] = float(connected_at_bytes)
if user_id:
client_info['user_id'] = user_id
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
if user_id_bytes:
client_info['user_id'] = user_id_bytes
output_format = proxy_server.redis_client.hget(client_key, 'output_format')
client_info['output_format'] = output_format or 'mpegts'
raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id')
if raw_profile_id and raw_profile_id not in ('None', '0', ''):
client_info['output_profile_id'] = int(raw_profile_id)
else:

View file

@ -1440,23 +1440,20 @@ class StreamManager:
except Exception as e:
logger.debug(f"Error stopping HTTP reader for channel {self.channel_id}: {e}")
# Otherwise handle socket and transcode resources
if self.socket:
try:
self.socket.close()
except Exception as e:
logger.debug(f"Error closing socket for channel {self.channel_id}: {e}")
pass
# Enhanced transcode process cleanup with immediate termination
if self.transcode_process:
# Kill proc before closing self.socket. Closing relay_read while the stream
# OS thread is blocked in select() on it does not reliably wake that select()
# on Linux. Killing ffmpeg closes its relay_write, sending EOF to the stream
# thread naturally. We close self.socket afterward as cleanup only.
proc = self.transcode_process
self.transcode_process = None # claim early so concurrent greenlets skip this block
if proc:
try:
logger.debug(f"Killing transcode process for channel {self.channel_id}")
self.transcode_process.kill()
proc.kill()
# Give it a very short time to die
try:
self.transcode_process.wait(timeout=0.5)
proc.wait(timeout=0.5)
except subprocess.TimeoutExpired:
logger.error(f"Failed to kill transcode process even with force for channel {self.channel_id}")
except Exception as e:
@ -1464,18 +1461,26 @@ class StreamManager:
# Final attempt: try to kill directly
try:
self.transcode_process.kill()
proc.kill()
except Exception as e:
logger.error(f"Final kill attempt failed for channel {self.channel_id}: {e}")
# Close relay socket after proc death; stream thread has already unblocked via EOF.
if self.socket:
try:
self.socket.close()
except Exception as e:
logger.debug(f"Error closing socket for channel {self.channel_id}: {e}")
if proc:
# Explicitly close all subprocess pipes to prevent file descriptor leaks
try:
if self.transcode_process.stdin:
self.transcode_process.stdin.close()
if self.transcode_process.stdout:
self.transcode_process.stdout.close()
if self.transcode_process.stderr:
self.transcode_process.stderr.close()
if proc.stdin:
proc.stdin.close()
if proc.stdout:
proc.stdout.close()
if proc.stderr:
proc.stderr.close()
logger.debug(f"Closed all subprocess pipes for channel {self.channel_id}")
except Exception as e:
logger.debug(f"Error closing subprocess pipes for channel {self.channel_id}: {e}")
@ -1492,8 +1497,7 @@ class StreamManager:
finally:
self.stderr_reader_thread = None
self.transcode_process = None
self.transcode_process_active = False # Reset the flag
self.transcode_process_active = False
# Clear transcode active key in Redis if available
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
@ -1558,6 +1562,10 @@ class StreamManager:
try:
chunk = _os.read(fd, Config.CHUNK_SIZE)
except OSError as e:
import errno as _errno
if e.errno == _errno.EAGAIN and (self.stop_requested or not self.running):
self.connected = False
return False
logger.warning(f"Read error for channel {self.channel_id}: {e}")
self.connected = False
return False

View file

@ -140,11 +140,8 @@ class StreamGenerator:
self._cleanup()
def _wait_for_initialization(self):
"""Wait for channel initialization to complete, sending keepalive packets."""
initialization_start = time.time()
max_init_wait = ConfigHelper.client_wait_timeout()
keepalive_interval = 0.5
last_keepalive = 0
proxy_server = ProxyServer.get_instance()
while time.time() - initialization_start < max_init_wait:
@ -169,16 +166,7 @@ class StreamGenerator:
yield create_ts_packet('error', "Error: Channel is stopping")
return False
# Send PAT+PMT+null so clients recognise a valid TS program and keep
# buffering instead of timing out from missing program info.
if time.time() - last_keepalive >= keepalive_interval:
logger.debug(f"[{self.client_id}] Sending keepalive during initialization")
keepalive_data = create_ts_packet('null')
yield keepalive_data
self.bytes_sent += len(keepalive_data)
last_keepalive = time.time()
else:
gevent.sleep(0.1)
gevent.sleep(0.1)
logger.warning(f"[{self.client_id}] Timed out waiting for initialization")
yield create_ts_packet('error', "Error: Initialization timeout")
@ -597,43 +585,6 @@ class StreamGenerator:
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
# Schedule channel shutdown if no clients left
self._schedule_channel_shutdown_if_needed(local_clients)
def _schedule_channel_shutdown_if_needed(self, local_clients):
"""
Schedule channel shutdown if there are no clients left and we're the owner.
"""
proxy_server = ProxyServer.get_instance()
# If no clients left and we're the owner, schedule shutdown using the config value
if local_clients == 0 and proxy_server.am_i_owner(self.channel_id):
# When output/profile managers are present, remove_client already spawned
# handle_client_disconnect which will stop them and then stop the channel.
# Spawning a second shutdown greenlet here causes a redundant concurrent
# stop_channel call that can race against the first.
if (proxy_server.output_managers.get(self.channel_id) or
proxy_server.profile_managers.get(self.channel_id)):
return
logger.info(f"No local clients left for channel {self.channel_id}, scheduling shutdown")
def delayed_shutdown():
# Use the config setting instead of hardcoded value
shutdown_delay = ConfigHelper.channel_shutdown_delay() # Use ConfigHelper
logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped")
gevent.sleep(shutdown_delay) # Replace time.sleep
# After delay, check global client count
if self.channel_id in proxy_server.client_managers:
total = proxy_server.client_managers[self.channel_id].get_total_client_count()
if total == 0:
logger.info(f"Shutting down channel {self.channel_id} as no clients connected")
proxy_server.stop_channel(self.channel_id)
else:
logger.info(f"Not shutting down channel {self.channel_id}, {total} clients still connected")
gevent.spawn(delayed_shutdown)
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None, buffer=None):
"""

View file

@ -70,6 +70,7 @@ class ProxyServer:
self.profile_managers = {} # {channel_id: {profile_id: OutputProfileManager}}
self.profile_buffers = {} # {channel_id: {profile_id: StreamBuffer}}
self._channel_names = {}
self._stopping_channels = set() # channels with an active stop_channel call in progress
# Generate a unique worker ID
pid = os.getpid()
@ -1267,6 +1268,10 @@ class ProxyServer:
def stop_channel(self, channel_id):
"""Stop a channel with proper ownership handling"""
if channel_id in self._stopping_channels:
logger.debug(f"stop_channel already in progress for {channel_id}, ignoring duplicate call")
return
self._stopping_channels.add(channel_id)
try:
logger.info(f"Stopping channel {channel_id}")
@ -1316,7 +1321,6 @@ class ProxyServer:
# Release ownership
self.release_ownership(channel_id)
logger.info(f"Released ownership of channel {channel_id}")
# Log channel stop event (after cleanup, before releasing ownership section ends)
try:
@ -1404,6 +1408,8 @@ class ProxyServer:
except Exception as e:
logger.error(f"Error stopping channel {channel_id}: {e}")
return False
finally:
self._stopping_channels.discard(channel_id)
def check_inactive_channels(self):
"""Check for inactive channels (no clients) and stop them"""
@ -1615,6 +1621,10 @@ class ProxyServer:
# Safety: if we have a stream_manager, we ARE the real owner
# but the Redis key may have expired. Try to re-acquire.
if channel_id in self.stream_managers:
# Ownership was explicitly released by an active stop_channel call -
# don't fight the shutdown by trying to re-acquire.
if channel_id in self._stopping_channels:
continue
logger.warning(
f"Ownership gap for {channel_id}: this worker has stream_manager "
f"but am_i_owner returned False. Attempting re-acquisition."
@ -1980,7 +1990,9 @@ class ProxyServer:
logger.info(f"Non-owner cleanup: Removed stream buffer for channel {channel_id}")
if channel_id in self.client_managers:
del self.client_managers[channel_id]
client_manager = self.client_managers.pop(channel_id)
if hasattr(client_manager, 'stop'):
client_manager.stop()
logger.info(f"Non-owner cleanup: Removed client manager for channel {channel_id}")
# Stop profile managers owned by this worker, but only for profiles

View file

@ -1,6 +1,5 @@
import logging
import re
import struct
from urllib.parse import urlparse
import inspect
@ -58,53 +57,6 @@ def get_client_ip(request):
ip = request.META.get('REMOTE_ADDR')
return ip
def _mpeg_crc32(data):
crc = 0xFFFFFFFF
for b in data:
crc ^= b << 24
for _ in range(8):
if crc & 0x80000000:
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF
else:
crc = (crc << 1) & 0xFFFFFFFF
return crc
def create_ts_pat_pmt_packets():
"""
Return two valid TS packets: PAT (PID 0x0000) and PMT (PID 0x0100).
Declares program 1 with an H.264 video track at PID 0x0101.
TS clients like VLC need PAT/PMT to recognise a stream as valid; without
them they time out waiting for program info even while receiving null packets.
Returns exactly 376 bytes (2 x 188-byte TS packets).
"""
# PAT section: program 1 mapped to PMT at PID 0x0100
pat_body = bytes([
0x00, 0xB0, 0x0D, # table_id=PAT, section_length=13
0x00, 0x01, # transport_stream_id=1
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
0x00, 0x01, # program_number=1
0xE1, 0x00, # PMT PID=0x0100 (reserved 0b111 | PID)
])
pat_body += struct.pack('>I', _mpeg_crc32(pat_body))
pat_packet = bytes([0x47, 0x40, 0x00, 0x10, 0x00]) + pat_body + bytes([0xFF] * (183 - len(pat_body)))
# PMT section: program 1, H.264 video at PID 0x0101
pmt_body = bytes([
0x02, 0xB0, 0x12, # table_id=PMT, section_length=18
0x00, 0x01, # program_number=1
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
0xE1, 0x01, # PCR_PID=0x0101
0xF0, 0x00, # program_info_length=0
0x1B, 0xE1, 0x01, 0xF0, 0x00, # stream_type=H.264, PID=0x0101
])
pmt_body += struct.pack('>I', _mpeg_crc32(pmt_body))
pmt_packet = bytes([0x47, 0x41, 0x00, 0x10, 0x00]) + pmt_body + bytes([0xFF] * (183 - len(pmt_body)))
return pat_packet + pmt_packet
def create_ts_packet(packet_type='null', message=None):
"""
Create a Transport Stream (TS) packet for various purposes.

View file

@ -468,14 +468,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
url_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.URL
)
ua_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.USER_AGENT
)
profile_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STREAM_PROFILE
url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget(
metadata_key,
ChannelMetadataField.URL,
ChannelMetadataField.USER_AGENT,
ChannelMetadataField.STREAM_PROFILE,
)
if url_bytes:
@ -630,7 +627,12 @@ def stream_xc(request, username, password, channel_id):
else:
channel = get_object_or_404(Channel, id=channel_id)
force_format = 'fmp4' if extension.lower() == '.mp4' else 'mpegts'
if extension.lower() == '.mp4':
force_format = 'fmp4'
elif extension.lower() == '.ts':
force_format = 'mpegts'
else:
force_format = None
return stream_ts(request._request, str(channel.uuid), user, force_output_format=force_format)

View file

@ -98,8 +98,7 @@ def get_user_active_connections(user_id):
channel_id = parts[2]
client_id = parts[4]
client_user_id = redis_client.hget(key, 'user_id')
connected_at = redis_client.hget(key, 'connected_at')
client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at')
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] channel_id = {channel_id}")
@ -124,9 +123,9 @@ def get_user_active_connections(user_id):
if len(parts) >= 2:
client_id = parts[1]
client_user_id = redis_client.hget(key, 'user_id')
connected_at = redis_client.hget(key, 'created_at')
content_uuid = redis_client.hget(key, 'content_uuid')
client_user_id, connected_at, content_uuid = redis_client.hmget(
key, 'user_id', 'created_at', 'content_uuid'
)
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] client_id = {client_id}")

View file

@ -0,0 +1,225 @@
"""
Tests for `_get_content_and_relation`'s graceful stream_id fallback when the
VOD content UUID has been orphaned by an import-time refresh.
Context (see #961 / closed #973): `process_movie_batch` and `process_series_batch`
can create duplicate `vod_movie` / `vod_episode` records during a refresh and
repoint existing `M3U*Relation` rows at the new records. The old UUIDs that
external players (Emby / Jellyfin / ChannelsDVR) cached in `.strm` URLs are
left orphaned, and the proxy then 404s even though the same request carries
a stable `stream_id` that uniquely identifies a live relation.
These tests cover the read-side fallback that resolves content via that
stream_id when the UUID lookup misses, leaving the existing UUID-first path
unchanged for the happy case. Both branches (movie + episode) exercise:
* UUID hit (no fallback fires) happy path unchanged
* UUID miss + stream_id present resolved via stream_id, [STREAMID-FALLBACK]
logged at WARNING
* UUID miss + stream_id present + preferred_m3u_account_id strictest-first
account match preferred
* UUID miss + stream_id present but no relation matches Http404 with both
identifiers in the message
* UUID miss + no stream_id Http404 (no fallback attempt)
"""
import logging
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from django.http import Http404
# ---------- Movie branch --------------------------------------------------
class TestStreamIdFallbackMovie(SimpleTestCase):
"""Movie UUID dead -> fall back to M3UMovieRelation.stream_id."""
def _call(self, **kwargs):
# Imported inside each test so the module-level Movie / Episode /
# M3U*Relation references can be patched per test without leaking.
from apps.proxy.vod_proxy.views import _get_content_and_relation
return _get_content_and_relation(
kwargs.pop('content_type', 'movie'),
kwargs.pop('content_id', 'dead-uuid'),
preferred_m3u_account_id=kwargs.pop('preferred_m3u_account_id', None),
preferred_stream_id=kwargs.pop('preferred_stream_id', None),
)
def test_uuid_hit_no_fallback_attempted(self):
"""When the UUID resolves, the M3UMovieRelation table is never queried
for fallback purposes the existing happy-path behaviour is preserved
and only the stream_id-specific relation selection runs."""
live_movie = MagicMock(name='Movie', uuid='live-uuid', id=42)
live_movie.name = 'Live Movie'
# The existing relation-selection logic walks
# content_obj.m3u_relations; give it a relation matching the requested
# stream_id so we exit cleanly.
live_relation = MagicMock(stream_id='S1')
live_relation.m3u_account.name = 'AcmeProvider'
live_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = live_relation
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
MovieMock.objects.filter.return_value.first.return_value = live_movie
content, relation = self._call(
content_type='movie', content_id='live-uuid', preferred_stream_id='S1',
)
self.assertIs(content, live_movie)
self.assertIs(relation, live_relation)
# Fallback path must not have queried the relation table directly
# — happy path is unchanged.
RelMock.objects.filter.assert_not_called()
def test_uuid_miss_resolves_via_stream_id(self):
"""UUID lookup returns None; stream_id finds an active relation; the
recovered movie is returned and the fallback line is logged."""
recovered_movie = MagicMock(name='Movie', uuid='new-uuid', id=99)
recovered_movie.name = 'Recovered Movie'
fallback_rel = MagicMock(movie=recovered_movie)
fallback_rel.m3u_account.name = 'AcmeProvider'
# The fallback only sets content_obj; the existing relation-selection
# logic then re-discovers the same relation via the reverse FK.
recovered_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = fallback_rel
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock, \
self.assertLogs('apps.proxy.vod_proxy.views', level='WARNING') as logs:
MovieMock.objects.filter.return_value.first.return_value = None
# The non-account-scoped fallback chain returns our rel.
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = fallback_rel
content, _ = self._call(
content_type='movie', content_id='dead-uuid', preferred_stream_id='S1',
)
self.assertIs(content, recovered_movie)
self.assertTrue(
any('[STREAMID-FALLBACK]' in m for m in logs.output),
f"expected [STREAMID-FALLBACK] in warnings, got: {logs.output}",
)
def test_uuid_miss_prefers_requested_account_first(self):
"""When preferred_m3u_account_id is set AND a matching relation exists
on that account, it must be chosen ahead of the unrestricted ordered
fallback. This is the strictest-match-first contract."""
preferred_movie = MagicMock(name='PreferredMovie', uuid='preferred-uuid', id=1)
preferred_movie.name = 'Preferred'
preferred_rel = MagicMock(movie=preferred_movie)
preferred_rel.m3u_account.name = 'Preferred'
preferred_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = preferred_rel
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
MovieMock.objects.filter.return_value.first.return_value = None
# Two distinct fallback chains share the same RelMock — we
# distinguish them by which `.filter(...)` call they emerged from.
# The account-scoped query is the FIRST .filter() call (with the
# m3u_account_id kw); the unrestricted ordered query is the SECOND.
unrestricted_movie = MagicMock(uuid='other-uuid', id=2)
unrestricted_movie.name = 'OtherMovie'
unrestricted_rel = MagicMock(movie=unrestricted_movie)
def filter_router(**kwargs):
# First chain: scoped to m3u_account_id -> returns preferred_rel
if 'm3u_account_id' in kwargs:
chain = MagicMock()
chain.select_related.return_value.first.return_value = preferred_rel
return chain
# Second chain: no account scope -> returns unrestricted_rel
chain = MagicMock()
chain.select_related.return_value.order_by.return_value.first.return_value = unrestricted_rel
return chain
RelMock.objects.filter.side_effect = filter_router
content, _ = self._call(
content_type='movie',
content_id='dead-uuid',
preferred_stream_id='S1',
preferred_m3u_account_id=7,
)
# The account-scoped relation wins; the unrestricted-ordered one
# is never consulted because the strict match succeeded.
self.assertIs(content, preferred_movie)
def test_uuid_miss_with_no_stream_id_raises_404(self):
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
MovieMock.objects.filter.return_value.first.return_value = None
content, relation = self._call(
content_type='movie', content_id='dead-uuid', preferred_stream_id=None,
)
# _get_content_and_relation swallows exceptions and returns
# (None, None) for any error including Http404 — caller checks for
# that. Verify the fallback was NEVER attempted.
self.assertIsNone(content)
self.assertIsNone(relation)
RelMock.objects.filter.assert_not_called()
def test_uuid_miss_with_no_matching_relation_raises_404(self):
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
MovieMock.objects.filter.return_value.first.return_value = None
# Both the account-scoped and unrestricted chains return None.
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = None
RelMock.objects.filter.return_value.select_related.return_value.first.return_value = None
content, relation = self._call(
content_type='movie',
content_id='dead-uuid',
preferred_stream_id='ghost-stream',
)
self.assertIsNone(content)
self.assertIsNone(relation)
# ---------- Episode branch ------------------------------------------------
class TestStreamIdFallbackEpisode(SimpleTestCase):
"""Episode UUID dead -> fall back to M3UEpisodeRelation.stream_id.
Same contract as the movie branch; less duplication of edge cases since
the code paths are intentionally symmetric.
"""
def _call(self, **kwargs):
from apps.proxy.vod_proxy.views import _get_content_and_relation
return _get_content_and_relation(
'episode',
kwargs.pop('content_id', 'dead-uuid'),
preferred_m3u_account_id=kwargs.pop('preferred_m3u_account_id', None),
preferred_stream_id=kwargs.pop('preferred_stream_id', None),
)
def test_uuid_miss_resolves_via_stream_id(self):
recovered_episode = MagicMock(uuid='new-uuid', id=77)
recovered_episode.name = 'Recovered S01E01'
recovered_episode.series.name = 'Recovered Show'
fallback_rel = MagicMock(episode=recovered_episode)
fallback_rel.m3u_account.name = 'AcmeProvider'
recovered_episode.m3u_relations.filter.return_value.filter.return_value.first.return_value = fallback_rel
with patch('apps.proxy.vod_proxy.views.Episode') as EpisodeMock, \
patch('apps.proxy.vod_proxy.views.M3UEpisodeRelation') as RelMock, \
self.assertLogs('apps.proxy.vod_proxy.views', level='WARNING') as logs:
EpisodeMock.objects.filter.return_value.first.return_value = None
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = fallback_rel
content, _ = self._call(content_id='dead-uuid', preferred_stream_id='S99')
self.assertIs(content, recovered_episode)
self.assertTrue(
any('[STREAMID-FALLBACK]' in m and 'Episode' in m for m in logs.output),
f"expected episode-flavoured [STREAMID-FALLBACK] warning, got: {logs.output}",
)
def test_uuid_hit_no_fallback_attempted(self):
live_episode = MagicMock(uuid='live-uuid', id=42)
live_episode.name = 'Live S01E02'
live_episode.series.name = 'Live Show'
live_relation = MagicMock(stream_id='S2')
live_relation.m3u_account.name = 'AcmeProvider'
live_episode.m3u_relations.filter.return_value.filter.return_value.first.return_value = live_relation
with patch('apps.proxy.vod_proxy.views.Episode') as EpisodeMock, \
patch('apps.proxy.vod_proxy.views.M3UEpisodeRelation') as RelMock:
EpisodeMock.objects.filter.return_value.first.return_value = live_episode
content, relation = self._call(content_id='live-uuid', preferred_stream_id='S2')
self.assertIs(content, live_episode)
self.assertIs(relation, live_relation)
RelMock.objects.filter.assert_not_called()

View file

@ -7,17 +7,20 @@ import time
import random
import logging
import requests
from urllib.parse import urlencode
from django.http import JsonResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from apps.vod.models import Movie, Series, Episode
from apps.vod.models import Movie, Series, Episode, M3UMovieRelation, M3UEpisodeRelation
from apps.m3u.models import M3UAccountProfile
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key
from .utils import get_client_info
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.permissions import AllowAny
from apps.accounts.models import User
from apps.accounts.permissions import IsAdmin
from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
from apps.proxy.utils import check_user_stream_limits
from dispatcharr.utils import network_access_allowed
@ -36,7 +39,49 @@ def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id
logger.info(f"[CONTENT-LOOKUP] Preferred stream ID: {preferred_stream_id}")
if content_type == 'movie':
content_obj = get_object_or_404(Movie, uuid=content_id)
content_obj = Movie.objects.filter(uuid=content_id).first()
if content_obj is None and preferred_stream_id:
# UUIDs are regenerated when process_movie_batch
# (apps/vod/tasks.py) creates duplicate vod_movie records
# during refresh — see #961 / #973. stream_id is stable
# (unique per (m3u_account, stream_id)) so it's a safe
# fallback for previously-cached external player URLs.
# Strictest-match first: prefer the requested account, then
# any active account by priority (matches the existing
# relation-selection ordering below).
rel = None
if preferred_m3u_account_id:
rel = (
M3UMovieRelation.objects
.filter(stream_id=preferred_stream_id,
m3u_account_id=preferred_m3u_account_id,
m3u_account__is_active=True)
.select_related('movie', 'm3u_account')
.first()
)
if rel is None:
rel = (
M3UMovieRelation.objects
.filter(stream_id=preferred_stream_id,
m3u_account__is_active=True)
.select_related('movie', 'm3u_account')
.order_by('-m3u_account__priority', 'id')
.first()
)
if rel is not None:
content_obj = rel.movie
logger.warning(
f"[STREAMID-FALLBACK] Movie UUID {content_id} not "
f"found; resolved via stream_id "
f"{preferred_stream_id} -> movie uuid "
f"{content_obj.uuid} (provider: "
f"{rel.m3u_account.name})"
)
if content_obj is None:
raise Http404(
f"Movie not found by uuid {content_id} "
f"or stream_id {preferred_stream_id}"
)
logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})")
# Filter by preferred stream ID first (most specific)
@ -67,7 +112,44 @@ def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id
return content_obj, relation
elif content_type == 'episode':
content_obj = get_object_or_404(Episode, uuid=content_id)
content_obj = Episode.objects.filter(uuid=content_id).first()
if content_obj is None and preferred_stream_id:
# Same rationale as the movie branch above — episode UUIDs
# are regenerated when process_series_batch creates
# duplicate vod_episode records during refresh.
rel = None
if preferred_m3u_account_id:
rel = (
M3UEpisodeRelation.objects
.filter(stream_id=preferred_stream_id,
m3u_account_id=preferred_m3u_account_id,
m3u_account__is_active=True)
.select_related('episode', 'm3u_account')
.first()
)
if rel is None:
rel = (
M3UEpisodeRelation.objects
.filter(stream_id=preferred_stream_id,
m3u_account__is_active=True)
.select_related('episode', 'm3u_account')
.order_by('-m3u_account__priority', 'id')
.first()
)
if rel is not None:
content_obj = rel.episode
logger.warning(
f"[STREAMID-FALLBACK] Episode UUID {content_id} not "
f"found; resolved via stream_id "
f"{preferred_stream_id} -> episode uuid "
f"{content_obj.uuid} (provider: "
f"{rel.m3u_account.name})"
)
if content_obj is None:
raise Http404(
f"Episode not found by uuid {content_id} "
f"or stream_id {preferred_stream_id}"
)
logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})")
# Filter by preferred stream ID first (most specific)
@ -293,6 +375,7 @@ def _transform_url(original_url, m3u_profile):
return original_url
@api_view(["GET"])
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
@permission_classes([AllowAny])
def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None):
"""
@ -306,7 +389,8 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
if user is None and hasattr(request, "user") and request.user.is_authenticated:
user = request.user
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}")
logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}")
logger.info(f"[VOD-REQUEST] Request method: {request.method}")
@ -384,39 +468,66 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}")
# Preserve any query parameters (except session_id)
# Preserve any query parameters (except session_id and token)
query_params = dict(request.GET)
query_params.pop('session_id', None) # Remove if present
query_params.pop('session_id', None)
query_params.pop('token', None) # Token not needed after session is established
if user:
redirect_url = f"{request.path}?session_id={new_session_id}"
if query_params:
query_string = urlencode(query_params, doseq=True)
redirect_url = f"{redirect_url}&{query_string}"
else:
# Build redirect URL with session ID in path, preserve query parameters
# The VOD proxy URL patterns accept session_id in the path, so we redirect
# to a path-based URL. XC endpoints (/movie/<user>/<pass>/<id>.<ext>) have
# a fixed shape and instead read session_id from a query parameter.
is_vod_proxy_path = request.path.startswith('/proxy/vod/')
if is_vod_proxy_path:
path_parts = request.path.rstrip('/').split('/')
# Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/
if profile_id:
new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/"
else:
new_path = f"{'/'.join(path_parts)}/{new_session_id}"
if query_params:
from urllib.parse import urlencode
query_string = urlencode(query_params, doseq=True)
redirect_url = f"{new_path}?{query_string}"
else:
redirect_url = new_path
else:
# XC path: keep the original path, put session_id in the query string
query_params['session_id'] = new_session_id
query_string = urlencode(query_params, doseq=True)
redirect_url = f"{request.path}?{query_string}"
logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}")
# Persist the authenticated user to Redis so the streaming request
# (which arrives without the token after the redirect) can resolve it.
if user:
try:
from core.utils import RedisClient
_r = RedisClient.get_client()
if _r:
_r.set(f"vod_session_user:{new_session_id}", user.id, ex=300)
except Exception:
pass
return HttpResponse(
status=301,
headers={'Location': redirect_url}
)
# Resolve user from Redis session mapping when the streaming request
# arrives without auth credentials (token was stripped from redirect URL).
# Only needed on the first streaming request - skip if connection already exists.
if user is None:
try:
from core.utils import RedisClient
_r = RedisClient.get_client()
if _r and not _r.exists(f"vod_persistent_connection:{session_id}"):
stored_uid = _r.get(f"vod_session_user:{session_id}")
if stored_uid:
user = User.objects.filter(id=int(stored_uid)).first()
except Exception:
pass
if user:
if not check_user_stream_limits(user, session_id, media_id=content_id):
return JsonResponse(
@ -506,6 +617,7 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
return HttpResponse(f"Streaming error: {str(e)}", status=500)
@api_view(["HEAD"])
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
@permission_classes([AllowAny])
def head_vod(request, content_type, content_id, session_id=None, profile_id=None):
"""

View file

@ -120,11 +120,30 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
"""Get detailed movie information from the original provider, throttled to 24h."""
movie = self.get_object()
# Get the highest priority active relation
relation = M3UMovieRelation.objects.filter(
relation_id = request.query_params.get('relation_id')
if relation_id is not None:
try:
relation_id = int(relation_id)
except (TypeError, ValueError):
return Response(
{'error': 'Invalid relation_id'},
status=status.HTTP_400_BAD_REQUEST
)
qs = M3UMovieRelation.objects.filter(
movie=movie,
m3u_account__is_active=True
).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first()
).select_related('m3u_account')
if relation_id is not None:
relation = qs.filter(id=relation_id).first()
if not relation:
return Response(
{'error': 'Relation not found or not active'},
status=status.HTTP_404_NOT_FOUND
)
else:
relation = qs.order_by('-m3u_account__priority', 'id').first()
if not relation:
return Response(
@ -326,11 +345,30 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
series = self.get_object()
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
# Get the highest priority active relation
relation = M3USeriesRelation.objects.filter(
relation_id = request.query_params.get('relation_id')
if relation_id is not None:
try:
relation_id = int(relation_id)
except (TypeError, ValueError):
return Response(
{'error': 'Invalid relation_id'},
status=status.HTTP_400_BAD_REQUEST
)
qs = M3USeriesRelation.objects.filter(
series=series,
m3u_account__is_active=True
).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first()
).select_related('m3u_account')
if relation_id is not None:
relation = qs.filter(id=relation_id).first()
if not relation:
return Response(
{'error': 'Relation not found or not active'},
status=status.HTTP_404_NOT_FOUND
)
else:
relation = qs.order_by('-m3u_account__priority', 'id').first()
if not relation:
return Response(

View file

@ -439,6 +439,26 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
trailer = extract_string_from_array_or_string(trailer_raw) if trailer_raw else None
logo_url = movie_data.get('stream_icon') or ''
director = extract_string_from_array_or_string(
movie_data.get('director') or ''
)
actors_raw = movie_data.get('actors') or movie_data.get('cast') or ''
if isinstance(actors_raw, list):
actors = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
else:
actors = actors_raw.strip() if actors_raw else None
release_date = movie_data.get('release_date') or movie_data.get('releasedate') or ''
custom_props = {}
if trailer:
custom_props['youtube_trailer'] = trailer
if director:
custom_props['director'] = director
if actors:
custom_props['actors'] = actors
if release_date:
custom_props['release_date'] = release_date
movie_props = {
'name': name,
'year': year,
@ -448,7 +468,7 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
'rating': rating,
'genre': genre,
'duration_secs': duration_secs,
'custom_properties': {'trailer': trailer} if trailer else None,
'custom_properties': custom_props or None,
}
movie_keys[movie_key] = {
@ -548,8 +568,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
for field, value in movie_props.items():
if field == 'custom_properties':
if value != movie.custom_properties:
movie.custom_properties = value
# Merge: preserve advanced-refresh keys; don't overwrite director/actors/release_date if already set.
existing_cp = movie.custom_properties or {}
incoming_cp = value or {}
merged = dict(existing_cp)
for k, v in incoming_cp.items():
if k in ('director', 'actors', 'release_date'):
if not existing_cp.get(k):
merged[k] = v
else:
merged[k] = v
if merged != existing_cp:
movie.custom_properties = merged
updated = True
elif getattr(movie, field) != value:
setattr(movie, field, value)
@ -564,8 +594,6 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
movie._logo_to_update = new_logo
logo_updated = True
elif movie.logo_id:
# Logo URL exists but logo creation failed or logo not found
# Clear the orphaned logo reference
logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference")
movie._logo_to_update = None
logo_updated = True
@ -784,7 +812,14 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
value = series_data.get(key)
if value:
# For string-like fields that might be arrays, extract clean strings
if key in ['poster_path', 'youtube_trailer', 'cast', 'director']:
if key == 'cast':
if isinstance(value, list):
clean_value = ', '.join(s.strip() for s in value if s and str(s).strip()) or None
else:
clean_value = extract_string_from_array_or_string(value)
if clean_value:
additional_metadata[key] = clean_value
elif key in ['poster_path', 'youtube_trailer', 'director']:
clean_value = extract_string_from_array_or_string(value)
if clean_value:
additional_metadata[key] = clean_value
@ -2156,26 +2191,25 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
movie.imdb_id = imdb_id_to_set
updated = True
logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {movie.id}")
# Only update trailer if we have a non-empty value and either no existing value or existing value is empty
if should_update_field(custom_props.get('youtube_trailer'), info.get('trailer')):
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('trailer'))
updated = True
if should_update_field(custom_props.get('youtube_trailer'), info.get('youtube_trailer')):
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('youtube_trailer'))
updated = True
# Only update backdrop_path if we have a non-empty value and either no existing value or existing value is empty
if should_update_field(custom_props.get('backdrop_path'), info.get('backdrop_path')):
backdrop_url = extract_string_from_array_or_string(info.get('backdrop_path'))
custom_props['backdrop_path'] = [backdrop_url] if backdrop_url else None
updated = True
# Only update actors if we have a non-empty value and either no existing value or existing value is empty
if should_update_field(custom_props.get('actors'), info.get('actors')):
custom_props['actors'] = extract_string_from_array_or_string(info.get('actors'))
updated = True
if should_update_field(custom_props.get('actors'), info.get('cast')):
custom_props['actors'] = extract_string_from_array_or_string(info.get('cast'))
updated = True
# Only update director if we have a non-empty value and either no existing value or existing value is empty
for actors_key in ('actors', 'cast'):
actors_raw = info.get(actors_key)
if should_update_field(custom_props.get('actors'), actors_raw):
if isinstance(actors_raw, list):
custom_props['actors'] = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
else:
custom_props['actors'] = extract_string_from_array_or_string(actors_raw)
updated = True
break
if should_update_field(custom_props.get('director'), info.get('director')):
custom_props['director'] = extract_string_from_array_or_string(info.get('director'))
updated = True

View file

@ -1,6 +1,5 @@
# core/api_views.py
import json
import ipaddress
import logging
from django.conf import settings as django_settings
@ -8,11 +7,9 @@ from django.db import models
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators import api_view, permission_classes, action
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from .models import (
UserAgent,
StreamProfile,
@ -32,8 +29,10 @@ from .serializers import (
)
import socket
import threading
import requests
import os
from django.core.cache import cache
from core.tasks import rehash_streams
from apps.accounts.permissions import (
Authenticated,
@ -287,6 +286,73 @@ class ProxySettingsViewSet(viewsets.ViewSet):
_IP_CACHE_KEY = "dispatcharr:ip_lookup_result"
_IP_CACHE_TTL = 3600 # 1 hour
_IP_LOCK_KEY = "dispatcharr:ip_lookup_lock"
def _perform_ip_lookup():
"""Run IP and geolocation lookups in a background thread and cache the result."""
public_ip = None
local_ip = None
country_code = None
country_name = None
city = None
try:
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
r.raise_for_status()
public_ip = r.json().get("ip")
except requests.RequestException:
pass
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("203.0.113.1", 80))
local_ip = s.getsockname()[0]
finally:
s.close()
except Exception:
pass
try:
ipaddress.ip_address(public_ip)
except (ValueError, TypeError):
public_ip = None
if public_ip:
try:
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("country_code")
country_name = geo.get("country_name")
city = geo.get("city")
else:
r = requests.get("http://ip-api.com/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("countryCode")
country_name = geo.get("country")
city = geo.get("city")
except Exception as e:
logger.error(f"Error during geo lookup: {e}")
result = {
"public_ip": public_ip,
"local_ip": local_ip,
"country_code": country_code,
"country_name": country_name,
"city": city,
}
cache.set(_IP_CACHE_KEY, result, _IP_CACHE_TTL)
cache.delete(_IP_LOCK_KEY)
from core.utils import send_websocket_update
send_websocket_update("updates", "update", {"type": "ip_lookup_complete", **result})
@extend_schema(
description="Endpoint for environment details",
)
@ -297,55 +363,27 @@ def environment(request):
local_ip = None
country_code = None
country_name = None
city = None
ip_lookup_pending = False
# 1) Get the public IP from ipify.org API
try:
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
r.raise_for_status()
public_ip = r.json().get("ip")
except requests.RequestException as e:
public_ip = f"Error: {e}"
ip_lookup_env_disabled = not getattr(django_settings, "ENABLE_IP_LOOKUP", True)
ip_lookup_db_enabled = CoreSettings.get_system_settings().get("enable_ip_lookup", True)
ip_lookup_enabled = not ip_lookup_env_disabled and ip_lookup_db_enabled
# 2) Get the local IP by connecting to a public DNS server
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# connect to a "public" address so the OS can determine our local interface
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
except Exception as e:
local_ip = f"Error: {e}"
if ip_lookup_enabled:
cached = cache.get(_IP_CACHE_KEY)
if cached is not None:
public_ip = cached.get("public_ip")
local_ip = cached.get("local_ip")
country_code = cached.get("country_code")
country_name = cached.get("country_name")
city = cached.get("city")
else:
if cache.add(_IP_LOCK_KEY, True, 30):
threading.Thread(target=_perform_ip_lookup, daemon=True).start()
ip_lookup_pending = True
# 3) Get geolocation data from ipapi.co or ip-api.com
if public_ip and "Error" not in public_ip:
try:
# Attempt to get geo information from ipapi.co first
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("country_code") # e.g. "US"
country_name = geo.get("country_name") # e.g. "United States"
else:
# If ipapi.co fails, fallback to ip-api.com
# only supports http requests for free tier
r = requests.get("http://ip-api.com/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("countryCode") # e.g. "US"
country_name = geo.get("country") # e.g. "United States"
else:
raise Exception("Geo lookup failed with both services")
except Exception as e:
logger.error(f"Error during geo lookup: {e}")
country_code = None
country_name = None
# 4) Get environment mode and TLS status from settings
# Get environment mode and TLS status from settings
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
return Response(
@ -355,6 +393,10 @@ def environment(request):
"local_ip": local_ip,
"country_code": country_code,
"country_name": country_name,
"city": city,
"ip_lookup_enabled": ip_lookup_enabled,
"ip_lookup_env_disabled": ip_lookup_env_disabled,
"ip_lookup_pending": ip_lookup_pending,
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
"redis_tls": {
"enabled": getattr(django_settings, "REDIS_SSL", False),

View file

@ -1,6 +1,6 @@
import sys
import psycopg2
from psycopg2 import sql
import psycopg
from psycopg import sql
from django.core.management.base import BaseCommand
from django.conf import settings
from django.db import connection
@ -38,8 +38,7 @@ class Command(BaseCommand):
maintenance_db = 'postgres'
try:
self.stdout.write("Connecting to maintenance database...")
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, **ssl_kwargs)
conn.autocommit = True
conn = psycopg.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, autocommit=True, **ssl_kwargs)
cur = conn.cursor()
self.stdout.write(f"Dropping database '{db_name}'...")
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))

View file

@ -11,12 +11,15 @@ def create_default_output_profiles(apps, schema_editor):
'command': 'ffmpeg',
'parameters': (
'-fflags +discardcorrupt+genpts+nobuffer '
'-probesize 512K '
'-analyzeduration 0 '
'-i pipe:0 '
'-map 0 '
'-c:v copy '
'-c:a ac3 '
'-b:a 384k '
'-max_muxing_queue_size 4096 '
'-flush_packets 1 '
'-mpegts_flags +pat_pmt_at_frames+resend_headers+initial_discontinuity '
'-f mpegts pipe:1'
),
@ -30,6 +33,8 @@ def create_default_output_profiles(apps, schema_editor):
'command': 'ffmpeg',
'parameters': (
'-fflags +discardcorrupt+genpts+nobuffer '
'-probesize 512K '
'-analyzeduration 0 '
'-i pipe:0 '
'-map 0 '
'-c:v copy '
@ -37,6 +42,7 @@ def create_default_output_profiles(apps, schema_editor):
'-b:a 192k '
'-ac 2 '
'-max_muxing_queue_size 4096 '
'-flush_packets 1 '
'-mpegts_flags +pat_pmt_at_frames+resend_headers+initial_discontinuity '
'-f mpegts pipe:1'
),

View file

@ -313,6 +313,8 @@ class CoreSettings(models.Model):
"movie_fallback_template": "Movies/{start}.mkv",
"comskip_enabled": False,
"comskip_custom_path": "",
"comskip_mode": "cut",
"comskip_hw_accel": "none",
"pre_offset_minutes": 0,
"post_offset_minutes": 0,
"series_rules": [],
@ -342,6 +344,16 @@ class CoreSettings(models.Model):
def get_dvr_comskip_enabled(cls):
return bool(cls.get_dvr_settings().get("comskip_enabled", False))
@classmethod
def get_dvr_comskip_mode(cls):
mode = cls.get_dvr_settings().get("comskip_mode", "cut")
return mode if mode in ("cut", "mark") else "cut"
@classmethod
def get_dvr_comskip_hw_accel(cls):
hw = cls.get_dvr_settings().get("comskip_hw_accel", "none")
return hw if hw in ("none", "cuvid", "qsv") else "none"
@classmethod
def get_dvr_comskip_custom_path(cls):
return cls.get_dvr_settings().get("comskip_custom_path", "")
@ -395,6 +407,7 @@ class CoreSettings(models.Model):
"max_system_events": 100,
"preferred_region": None,
"auto_import_mapped_files": True,
"enable_ip_lookup": True,
})
@classmethod

View file

@ -388,9 +388,35 @@ def scan_and_process_files():
}
)
# Rebuild EPG programme indices on first run if missing (e.g. after migration or container restart)
if not _first_scan_completed:
_rebuild_programme_indices()
# Mark that the first scan is complete
_first_scan_completed = True
def _rebuild_programme_indices():
"""Queue index builds for active EPG sources that are missing their DB index."""
try:
from apps.epg.tasks import build_programme_index_task
sources = EPGSource.objects.filter(
is_active=True,
programme_index__isnull=True,
).exclude(source_type__in=('dummy', 'schedules_direct'))
count = 0
for source in sources:
# The task acquires its own build lock; taking it here would make the task no-op.
build_programme_index_task.delay(source.id)
count += 1
if count:
logger.info(f"Queued programme index rebuild for {count} EPG source(s)")
except Exception as e:
logger.warning(f"Failed to queue programme index rebuilds: {e}")
def fetch_channel_stats():
redis_client = RedisClient.get_client()

View file

@ -2,9 +2,58 @@ from unittest.mock import patch, MagicMock
from django.test import TestCase
from apps.epg.models import EPGSource
from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY
class ProgrammeIndexRebuildTests(TestCase):
def test_startup_rebuild_does_not_lock_out_queued_build_task(self):
EPGSource.objects.update(
programme_index={"channels": {}, "interleaved_channels": []}
)
source = EPGSource.objects.create(
name="Missing Index",
source_type="xmltv",
is_active=True,
programme_index=None,
)
class FakeRedis:
def __init__(self):
self.keys = set()
def set(self, key, value, nx=False, ex=None):
if nx and key in self.keys:
return False
self.keys.add(key)
return True
def delete(self, key):
self.keys.discard(key)
fake_redis = FakeRedis()
from apps.epg.tasks import build_programme_index_task
from core.tasks import _rebuild_programme_indices
def run_task_immediately(source_id):
build_programme_index_task(source_id)
with patch(
"core.tasks.RedisClient.get_client", return_value=fake_redis
), patch(
"core.utils.RedisClient.get_client", return_value=fake_redis
), patch(
"apps.epg.tasks.build_programme_index"
) as mock_build, patch(
"apps.epg.tasks.build_programme_index_task.delay",
side_effect=run_task_immediately,
):
_rebuild_programme_indices()
mock_build.assert_called_once_with(source.id)
class GetDvrSeriesRulesTest(TestCase):
"""Verify get_dvr_series_rules handles corrupted stored data."""
@ -153,7 +202,7 @@ class EpgIgnoreListsTest(TestCase):
class DropDBCommandTlsTest(TestCase):
"""Verify dropdb management command passes TLS parameters to psycopg2."""
"""Verify dropdb management command passes TLS parameters to psycopg."""
databases = []
_DB_WITH_TLS = {
@ -184,7 +233,7 @@ class DropDBCommandTlsTest(TestCase):
}
}
@patch('core.management.commands.dropdb.psycopg2.connect')
@patch('core.management.commands.dropdb.psycopg.connect')
@patch('core.management.commands.dropdb.connection')
@patch('builtins.input', return_value='yes')
def test_dropdb_passes_ssl_kwargs_when_tls_enabled(self, _inp, _conn, mock_connect):
@ -199,13 +248,14 @@ class DropDBCommandTlsTest(TestCase):
mock_connect.assert_called_once_with(
dbname='postgres', user='testuser', password='testpass',
host='localhost', port=5432,
autocommit=True,
sslmode='verify-full',
sslrootcert='/certs/ca.crt',
sslcert='/certs/client.crt',
sslkey='/certs/client.key',
)
@patch('core.management.commands.dropdb.psycopg2.connect')
@patch('core.management.commands.dropdb.psycopg.connect')
@patch('core.management.commands.dropdb.connection')
@patch('builtins.input', return_value='yes')
def test_dropdb_no_ssl_kwargs_when_tls_disabled(self, _inp, _conn, mock_connect):
@ -220,4 +270,5 @@ class DropDBCommandTlsTest(TestCase):
mock_connect.assert_called_once_with(
dbname='postgres', user='testuser', password='testpass',
host='localhost', port=5432,
autocommit=True,
)

View file

@ -752,6 +752,76 @@ def send_websocket_notification(notification):
logger.error(f"Failed to send WebSocket notification: {e}")
def get_host_and_port(request):
"""
Returns (host, port) for building absolute URIs.
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
- Falls back to Host header.
- Returns None for port if using standard ports (80/443) to omit from URLs.
- In dev, uses 5656 as a guess if port cannot be determined.
"""
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
standard_port = "443" if scheme == "https" else "80"
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
if xfh:
if ":" in xfh:
host, port = xfh.split(":", 1)
if port == standard_port:
return host, None
return host, port
else:
host = xfh
port = request.META.get("HTTP_X_FORWARDED_PORT")
if port:
return host, None if port == standard_port else port
if request.META.get("HTTP_X_FORWARDED_PROTO"):
return host, None
# 2. Try Host header
raw_host = request.get_host()
if ":" in raw_host:
host, port = raw_host.split(":", 1)
return host, None if port == standard_port else port
else:
host = raw_host
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
port = request.META.get("HTTP_X_FORWARDED_PORT")
if port:
return host, None if port == standard_port else port
# 4. Behind a reverse proxy with no port info - assume standard port
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
return host, None
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
port = request.META.get("SERVER_PORT")
if port:
return host, None if port == standard_port else port
# 6. Dev fallback
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
return host, "5656"
# 7. Final fallback: assume standard port for scheme
return host, None
def build_absolute_uri_with_port(request, path):
"""
Build an absolute URI with optional port.
Port is omitted from URL if None (standard port for scheme).
"""
host, port = get_host_and_port(request)
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
if port:
return f"{scheme}://{host}:{port}{path}"
return f"{scheme}://{host}{path}"
def send_notification_dismissed(notification_key):
"""
Notify all connected clients that a notification was dismissed.

View file

@ -64,11 +64,42 @@ configure_variables() {
POSTGRES_PASSWORD="secret"
NGINX_HTTP_PORT="9191"
WEBSOCKET_PORT="8001"
GUNICORN_RUNTIME_DIR="dispatcharr"
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
PYTHON_BIN=$(command -v python3)
UWSGI_RUNTIME_DIR="dispatcharr"
UWSGI_SOCKET="/run/${UWSGI_RUNTIME_DIR}/dispatcharr.sock"
SYSTEMD_DIR="/etc/systemd/system"
NGINX_SITE="/etc/nginx/sites-available/dispatcharr"
}
# Helper: pick the first candidate package that exists in apt repos
pick_candidate() {
local cand info candidate
for cand in "$@"; do
info=$(apt-cache policy "$cand" 2>/dev/null || true)
candidate=$(printf '%s' "$info" | awk '/Candidate:/ {print $2; exit}')
if [ -n "$candidate" ] && [ "$candidate" != "(none)" ]; then
printf '%s' "$cand"
return 0
fi
done
return 1
}
# Resolve a list of package candidate entries into installable package names.
# Each entry may contain alternatives separated by '|', e.g. 'libpcre3-dev|libpcre2-dev'
resolve_packages() {
local entry
local alts
local alt
local resolved=()
for entry in "$@"; do
IFS='|' read -r -a alts <<<"$entry"
alt=$(pick_candidate "${alts[@]}" 2>/dev/null || true)
if [ -n "$alt" ]; then
resolved+=("$alt")
else
echo "[WARN] No available candidate for package group: $entry" >&2
fi
done
printf '%s\n' "${resolved[@]}"
}
##############################################################################
@ -77,13 +108,39 @@ configure_variables() {
install_packages() {
echo ">>> Installing system packages..."
# Refresh package lists before probing availability
apt-get update
declare -a packages=(
git curl wget build-essential gcc libpq-dev
python3-dev python3-venv python3-pip nginx redis-server
postgresql postgresql-contrib ffmpeg procps streamlink
sudo
# Candidate package groups (use '|' to separate alternatives)
package_candidates=(
'git'
'curl'
'wget'
'build-essential'
'gcc'
'libpq-dev'
'libpcre3-dev|libpcre2-dev|pcre3-dev'
'python3-dev|python3.13-dev'
'libssl-dev'
'pkg-config'
'nginx'
'redis-server'
'postgresql'
'postgresql-contrib'
'ffmpeg'
'procps'
'streamlink'
'sudo'
)
# Resolve candidates to actual installable package names
mapfile -t packages < <(resolve_packages "${package_candidates[@]}")
if [ "${#packages[@]}" -eq 0 ]; then
echo "[ERROR] No installable packages found. Aborting." >&2
exit 1
fi
apt-get install -y --no-install-recommends "${packages[@]}"
if ! command -v node >/dev/null 2>&1; then
@ -113,6 +170,11 @@ create_dispatcharr_user() {
##############################################################################
setup_postgresql() {
echo ">>> Waiting for PostgreSQL to accept connections..."
until pg_isready -h /var/run/postgresql >/dev/null 2>&1; do
sleep 1
done
echo ">>> Checking PostgreSQL database and user..."
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
@ -143,7 +205,7 @@ setup_postgresql() {
clone_dispatcharr_repo() {
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
if [ ! -d "$APP_DIR" ]; then
mkdir -p "$APP_DIR"
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
@ -171,14 +233,8 @@ EOSU
# 6) Setup Python Environment
##############################################################################
install_uv() {
echo ">>> Installing UV package manager..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
}
setup_python_env() {
echo ">>> Setting up Python virtual environment with UV..."
echo ">>> Setting up Python virtual environment with UV (Python 3.13)..."
su - "$DISPATCH_USER" <<EOSU
set -euo pipefail
@ -188,13 +244,12 @@ setup_python_env() {
command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
rm -rf env
$PYTHON_BIN -m venv env
env/bin/python -m ensurepip --upgrade
# uv creates the venv with a managed Python 3.13 (auto-downloads if missing),
# avoiding system Python version mismatches on Debian 12 / Ubuntu 24.04.
uv venv --python 3.13 env
export UV_PROJECT_ENVIRONMENT="$APP_DIR/env"
uv sync --no-dev
env/bin/python -m pip install -q gunicorn
EOSU
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
@ -291,17 +346,40 @@ EOSU
configure_services() {
echo ">>> Creating systemd service files..."
# Gunicorn
# uWSGI config
cat <<EOF >${APP_DIR}/uwsgi-debian.ini
[uwsgi]
chdir = ${APP_DIR}
module = dispatcharr.wsgi:application
virtualenv = ${APP_DIR}/env
master = true
workers = 4
socket = ${UWSGI_SOCKET}
chmod-socket = 666
vacuum = true
die-on-term = true
gevent = 100
gevent-early-monkey-patch = true
import = dispatcharr.gevent_patch
lazy-apps = true
buffer-size = 65536
socket-timeout = 600
thunder-lock = true
EOF
chown ${DISPATCH_USER}:${DISPATCH_GROUP} ${APP_DIR}/uwsgi-debian.ini
# uWSGI
cat <<EOF >${SYSTEMD_DIR}/dispatcharr.service
[Unit]
Description=Gunicorn for Dispatcharr
Description=uWSGI for Dispatcharr
After=network.target postgresql.service redis-server.service
[Service]
User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
RuntimeDirectory=${UWSGI_RUNTIME_DIR}
RuntimeDirectoryMode=0775
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
@ -310,12 +388,7 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
Environment="POSTGRES_HOST=localhost"
ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done'
ExecStart=${APP_DIR}/env/bin/gunicorn \\
--workers=4 \\
--worker-class=gevent \\
--timeout=300 \\
--bind unix:${GUNICORN_SOCKET} \\
dispatcharr.wsgi:application
ExecStart=${APP_DIR}/env/bin/uwsgi --ini ${APP_DIR}/uwsgi-debian.ini
Restart=always
KillMode=mixed
SyslogIdentifier=dispatcharr
@ -412,9 +485,14 @@ EOF
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
server {
listen ${NGINX_HTTP_PORT};
client_max_body_size 0;
location / {
include proxy_params;
proxy_pass http://unix:${GUNICORN_SOCKET};
include uwsgi_params;
uwsgi_param HTTP_X_REAL_IP \$remote_addr;
uwsgi_read_timeout 600;
uwsgi_send_timeout 600;
uwsgi_pass unix:${UWSGI_SOCKET};
}
location /static/ {
alias ${APP_DIR}/static/;
@ -465,7 +543,7 @@ show_summary() {
=================================================
Dispatcharr installation (or update) complete!
Nginx is listening on port ${NGINX_HTTP_PORT}.
Gunicorn socket: ${GUNICORN_SOCKET}.
uWSGI socket: ${UWSGI_SOCKET}.
WebSockets on port ${WEBSOCKET_PORT} (path /ws/).
You can check logs via:

View file

@ -1,5 +1,6 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async
import regex, logging
logger = logging.getLogger(__name__)
@ -16,7 +17,6 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
try:
await self.accept()
await self.channel_layer.group_add(self.room_name, self.channel_name)
# Send a connection confirmation to the client with consistent format
await self.send(text_data=json.dumps({
'type': 'connection_established',
'data': {
@ -24,13 +24,22 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
'message': 'WebSocket connection established successfully'
}
}))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error in WebSocket connect: {str(e)}")
# If an error occurs during connection, attempt to close
# If the IP lookup already completed before the client connected,
# push the cached result immediately so the Skeleton resolves.
try:
await self.close(code=1011) # Internal server error
from django.core.cache import cache
from core.api_views import _IP_CACHE_KEY
cached_ip = await sync_to_async(cache.get)(_IP_CACHE_KEY)
if cached_ip:
await self.send(text_data=json.dumps({
'data': {'type': 'ip_lookup_complete', **cached_ip}
}))
except Exception as e:
logger.warning(f"Could not push cached IP result on connect: {e}")
except Exception as e:
logger.error(f"Error in WebSocket connect: {str(e)}")
try:
await self.close(code=1011)
except:
pass

View file

@ -1,24 +1,20 @@
"""
Loaded via uWSGI's `import = dispatcharr.gevent_patch` directive.
Two things happen here:
gevent stdlib monkey-patching - replaces blocking socket/threading/os
primitives with gevent-cooperative versions. `gevent-early-monkey-patch = true`
in the ini should already have done this; calling it again is a safe no-op if
it did, and a necessary fallback if it didn't (e.g. older uWSGI build).
1. gevent stdlib monkey-patching - replaces blocking socket/threading/os
primitives with gevent-cooperative versions. `gevent-early-monkey-patch = true`
in the ini should already have done this; calling it again is a safe no-op if
it did, and a necessary fallback if it didn't (e.g. older uWSGI build).
2. psycogreen - installs a wait callback on psycopg2 so libpq yields to the
gevent hub during I/O instead of blocking the OS thread.
Without (1), `async_to_sync(channel_layer.group_send)` in send_websocket_update
Without this, `async_to_sync(channel_layer.group_send)` in send_websocket_update
calls epoll_wait() directly, which blocks the OS thread and freezes all greenlets
on the worker until the call returns. With (1), select.epoll is replaced by
monkey-patching, which breaks asyncio event loop creation in threadpool threads.
on the worker until the call returns. With monkey-patching, select.epoll is
replaced, which breaks asyncio event loop creation in threadpool threads.
send_websocket_update therefore uses a synchronous Redis path in gevent workers
instead of asyncio - see _gevent_ws_send() in core/utils.py.
Without (2), psycopg2 network calls pin the worker during slow/stalled queries.
psycopg3 uses Python's socket layer for I/O, so monkey.patch_all() provides
gevent compatibility without any additional driver patching.
Celery and Daphne run in separate daemon processes and do not load this module.
@ -40,15 +36,4 @@ if not monkey.is_module_patched("socket"):
else:
print("[gevent_patch] gevent stdlib monkey-patching already active.", flush=True)
try:
from psycogreen.gevent import patch_psycopg
patch_psycopg()
print("[gevent_patch] psycogreen: psycopg2 patched for gevent.", flush=True)
except ImportError:
print(
"[gevent_patch] WARNING: psycogreen not installed - "
"psycopg2 will block the gevent hub during DB I/O. "
"Run: uv pip install psycogreen",
file=sys.stderr,
flush=True,
)

View file

@ -59,6 +59,8 @@ if REDIS_SSL:
else:
print("Redis TLS: disabled")
ENABLE_IP_LOOKUP = os.environ.get("DISPATCHARR_ENABLE_IP_LOOKUP", "true").lower() == "true"
# Set DEBUG to True for development, False for production
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
DEBUG = True
@ -111,24 +113,11 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5
# Database optimization settings
DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries
DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet connections
DATABASE_CONN_MAX_AGE = 0 # geventpool intercepts close(); pool handles reuse
# Disable atomic requests for performance-sensitive views
ATOMIC_REQUESTS = False
# Cache settings - add caching for EPG operations
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "dispatcharr-epg-cache",
"TIMEOUT": 3600, # 1 hour cache timeout
"OPTIONS": {
"MAX_ENTRIES": 10000,
"CULL_FREQUENCY": 3, # Purge 1/3 of entries when max is reached
},
}
}
# Timeouts for external connections
REQUESTS_TIMEOUT = 30 # Seconds for external API requests
@ -198,6 +187,25 @@ CHANNEL_LAYERS = {
},
}
_django_redis_opts = {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
if REDIS_SSL:
# rediss:// in the URL already enables SSL; pass cert paths and verify
# settings separately via CONNECTION_POOL_KWARGS.
_django_redis_opts["CONNECTION_POOL_KWARGS"] = {
k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"
}
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": _channels_redis_url,
"TIMEOUT": 3600,
"OPTIONS": _django_redis_opts,
}
}
# PostgreSQL TLS configuration (defined before DATABASES for module-level access)
POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").lower() == "true"
POSTGRES_SSL_MODE = os.environ.get("POSTGRES_SSL_MODE", "verify-full")
@ -215,13 +223,18 @@ if os.getenv("DB_ENGINE", None) == "sqlite":
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"ENGINE": "django_db_geventpool.backends.postgresql_psycopg3",
"NAME": os.environ.get("POSTGRES_DB", "dispatcharr"),
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
"CONN_MAX_AGE": DATABASE_CONN_MAX_AGE,
"OPTIONS": {
"MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100
"REUSE_CONNS": 3, # Connections to keep warm between requests
"pool": False, # Disable Django's native psycopg3 pool; geventpool manages connections
},
}
}
@ -232,9 +245,9 @@ else:
("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY),
], "PostgreSQL")
DATABASES["default"]["OPTIONS"] = {
DATABASES["default"]["OPTIONS"].update({
"sslmode": POSTGRES_SSL_MODE,
}
})
if POSTGRES_SSL_CA_CERT:
DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT
if POSTGRES_SSL_CERT:

View file

@ -1,4 +1,10 @@
FROM lscr.io/linuxserver/ffmpeg:latest
# ==============================================================================
# Stage 1: builder
# Installs the Python toolchain + compilers, creates the virtual environment and
# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end
# up in the final image — only the artifacts below are copied forward.
# ==============================================================================
FROM lscr.io/linuxserver/ffmpeg:latest AS builder
ENV DEBIAN_FRONTEND=noninteractive
ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
&& apt-get update \
&& apt-get install --no-install-recommends -y \
python3.13 python3.13-dev python3.13-venv libpython3.13 \
libpcre3 libpcre3-dev libpq-dev procps pciutils \
nginx comskip \
vlc-bin vlc-plugin-base \
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build
libpcre3 libpcre3-dev \
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \
&& rm -rf /var/lib/apt/lists/*
# --- Install UV ---
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
@ -28,11 +33,12 @@ WORKDIR /tmp/build
COPY pyproject.toml /tmp/build/
COPY version.py /tmp/build/
COPY README.md /tmp/build/
RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \
rm -rf /tmp/build
RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev
WORKDIR /
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
# build/pip are installed into the venv only long enough to produce the wheel,
# then uninstalled so they are not carried into the final image's venv.
RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \
cd /tmp && \
$UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \
@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build
cd numpy-*/ && \
$UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
mv dist/*.whl /opt/ && \
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \
uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip
# --- Clean up build dependencies to reduce image size ---
RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \
apt-get autoremove -y --purge && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /root/.cache /tmp/*
# ==============================================================================
# Stage 2: final runtime image
# Same ffmpeg base, but installs only the runtime system libraries (no compilers)
# and copies the prebuilt virtual environment + NumPy wheel from the builder.
# ==============================================================================
FROM lscr.io/linuxserver/ffmpeg:latest AS final
ENV DEBIAN_FRONTEND=noninteractive
ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
ENV VIRTUAL_ENV=/dispatcharrpy
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# --- Install runtime system dependencies (no build toolchain) ---
# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI;
# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget
# and software-properties-common are kept for TLS and the AIO runtime apt step.
RUN apt-get update && apt-get install --no-install-recommends -y \
ca-certificates software-properties-common gnupg2 curl wget \
&& add-apt-repository ppa:deadsnakes/ppa \
&& apt-get update \
&& apt-get install --no-install-recommends -y \
python3.13 python3.13-venv libpython3.13 \
libpcre3 libopenblas0 procps pciutils \
nginx comskip \
vlc-bin vlc-plugin-base \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# --- Install UV (used at runtime to swap in the legacy NumPy wheel) ---
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder ---
COPY --from=builder /dispatcharrpy /dispatcharrpy
COPY --from=builder /opt/numpy-*.whl /opt/
# --- Set up Redis 7.x ---
RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \

View file

@ -1,6 +1,101 @@
; Minimal default comskip config
edl_out=1
output_edl=1
; comskip.ini - tuned for recorded live TV
; Source reference: https://github.com/erikkaashoek/Comskip/blob/master/comskip.c
[Main Settings]
; Detection method bitmask - add the values together for the methods you want:
; 1 = black/uniform frame 2 = logo detection
; 4 = scene change 8 = resolution change
; 16 = closed captions 32 = aspect ratio change
; 64 = silence 255 = all methods
; Comskip default (no ini): 107 (1+2+8+32+64, no scene change or CC)
; Recommended for recorded TV: 127 (all seven methods)
detect_method=127
; Verbosity level. 0=silent, 5=moderate, 10=maximum. Use 10 temporarily to diagnose misses.
verbose=0
; Number of CPU threads to use. 0 = use all available cores.
thread_count=0
[Output]
; Write an EDL cut-list file. Dispatcharr reads this to drive FFmpeg, which
; permanently cuts the commercials out of the recording file and replaces it in place.
output_edl=1
; Action code written in the third column of each EDL line.
; The format originates from MPlayer (values 0-1); Kodi adopted it and extended it (values 2-3).
; 0 = Cut - player removes the section entirely; total duration shrinks (MPlayer + Kodi)
; 1 = Mute - audio muted, video keeps playing (MPlayer + Kodi)
; 2 = Scene Marker - marks a point of interest; used for chapter-style navigation (Kodi only)
; 3 = Commercial Break - auto-skipped once on first playthrough; user can rewind into it (Kodi only)
; Dispatcharr's cut mode ignores this value (only timestamps matter for FFmpeg cutting).
; For mark mode the file is kept and handed to the player, so 3 is correct for Kodi.
; Default in comskip is 0; without this override Kodi would treat ranges as hard cuts.
edl_skip_field=3
; Suppress the default .txt stats file comskip writes alongside the EDL.
output_default=0
[Commercial Break Timing]
; Maximum total length in seconds for one commercial break (source default: 600)
max_commercialbreak=600
; Minimum total length in seconds for a break to be marked (source default: 20)
; 25s is a safe floor for US broadcast TV - avoids marking short interstitials
min_commercialbreak=25
; Maximum length in seconds for a single spot within a break (source default: 120)
max_commercial_size=120
; Minimum length in seconds for a single spot (source default: 4)
min_commercial_size=5
; Minimum show segment in seconds; blocks shorter than this are suspect (source default: 120)
min_show_segment_length=120
[Black Frame Detection]
; A frame is NOT black if any sampled pixel exceeds this brightness (0-255, source default: 60)
max_brightness=60
; Secondary check: if any pixel exceeds this value, comskip also checks the average (source default: 40)
test_brightness=40
; Maximum average brightness for a frame to be classified as black/dim (0-255, source default: 19)
max_avg_brightness=19
; Uniform-color (non-black slate) detection sensitivity. Lower = stricter. (source default: 500)
; Solid-color slates at break boundaries are also used as cut points.
non_uniformity=500
[Logo Detection]
; Minimum fraction of frames in the show that must carry the logo to enable logo detection (source default: 0.40)
; Lower this if the station watermark disappears often during the program itself.
logo_fraction=0.40
; Upper bound: if more than this fraction of frames has a logo, detection is disabled (source default: 0.92)
logo_percentile=0.92
; Edge-match similarity threshold for confirming the logo is present (0.0-1.0, source default: 0.80)
; Lower slightly (e.g. 0.75) if comskip fails to track the logo through compression artifacts.
logo_threshold=0.75
; Minimum fraction of a block that must contain the logo for it to score as show content (source default: 0.25)
logo_percentage_threshold=0.25
; Maximum fraction of screen area the logo may occupy - larger regions are rejected (source default: 0.12)
logo_max_percentage_of_screen=0.12
; Temporal smoothing filter for logo presence. 0 = off. (source default: 0)
; Set to 4 if brief compression artifacts cause the logo to flicker in/out.
logo_filter=0
[Silence Detection]
; Frames with audio volume below this value are treated as silence (source default: 100)
; Raise this if quiet dialogue or music beds are triggering false cut points.
max_silence=100
[Live TV]
; Set to 1 only if comskip is processing the file WHILE it is still being recorded.
; For post-recording analysis (the normal Dispatcharr use case) keep this at 0.
live_tv=0

View file

@ -177,7 +177,7 @@ export POSTGRES_DIR=/data/db
variables=(
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY

View file

@ -42,8 +42,8 @@ gevent = 100
# Patch the stdlib (socket, threading, time, ...) before any app code
# loads so blocking calls yield to the gevent hub. Without this, a single
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
# worker. The companion module greens psycopg2 specifically.
# blocking requests / DNS call freezes every greenlet on the
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
gevent-early-monkey-patch = true
import = dispatcharr.gevent_patch

View file

@ -45,8 +45,8 @@ gevent = 100
# Patch the stdlib (socket, threading, time, ...) before any app code
# loads so blocking calls yield to the gevent hub. Without this, a single
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
# worker. The companion module greens psycopg2 specifically.
# blocking requests / DNS call freezes every greenlet on the
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
gevent-early-monkey-patch = true
import = dispatcharr.gevent_patch

View file

@ -48,8 +48,8 @@ gevent = 400 # Each unused greenlet costs ~2-4KB of memory
# Patch the stdlib (socket, threading, time, ...) before any app code
# loads so blocking calls yield to the gevent hub. Without this, a single
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
# worker. The companion module greens psycopg2 specifically.
# blocking requests / DNS call freezes every greenlet on the
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
gevent-early-monkey-patch = true
import = dispatcharr.gevent_patch

View file

@ -44,8 +44,8 @@ gevent = 400 # Each unused greenlet costs ~2-4KB of memory
# Patch the stdlib (socket, threading, time, ...) before any app code
# loads so blocking calls yield to the gevent hub. Without this, a single
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
# worker. The companion module greens psycopg2 specifically.
# blocking requests / DNS call freezes every greenlet on the
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
gevent-early-monkey-patch = true
import = dispatcharr.gevent_patch

View file

@ -965,6 +965,12 @@ export const WebsocketProvider = ({ children }) => {
break;
}
case 'ip_lookup_complete': {
const { type: _t, ...ipData } = parsedEvent.data;
useSettingsStore.getState().setEnvironmentFields(ipData);
break;
}
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`

View file

@ -1529,6 +1529,24 @@ export default class API {
}
}
static async getCurrentProgramForEpg(epgId) {
const response = await request(
`${host}/api/epg/current-programs/`,
{
method: 'POST',
body: { epg_data_ids: [epgId] },
}
);
if (response && response.length > 0) {
if (response[0].parsing) {
return { parsing: true };
}
return response[0];
}
return null;
}
// Notice there's a duplicated "refreshPlaylist" method above;
// you might want to rename or remove one if it's not needed.
@ -3526,10 +3544,11 @@ export default class API {
}
}
static async getMovieProviderInfo(movieId) {
static async getMovieProviderInfo(movieId, relationId = null) {
try {
const params = relationId ? `?relation_id=${relationId}` : '';
const response = await request(
`${host}/api/vod/movies/${movieId}/provider-info/`
`${host}/api/vod/movies/${movieId}/provider-info/${params}`
);
return response;
} catch (e) {
@ -3568,11 +3587,12 @@ export default class API {
}
}
static async getSeriesInfo(seriesId) {
static async getSeriesInfo(seriesId, relationId = null) {
try {
// Call the provider-info endpoint that includes episodes
const params = new URLSearchParams({ include_episodes: 'true' });
if (relationId) params.set('relation_id', relationId);
const response = await request(
`${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true`
`${host}/api/vod/series/${seriesId}/provider-info/?${params}`
);
return response;
} catch (e) {

View file

@ -0,0 +1,144 @@
import React from 'react';
import {
Box,
Button,
Divider,
Group,
Modal,
SimpleGrid,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { BookOpen, Github, Heart, Users } from 'lucide-react';
import { DiscordIcon } from './icons.jsx';
import logo from '../images/logo.png';
import useSettingsStore from '../store/settings';
const AboutModal = ({ isOpen, onClose }) => {
const appVersion = useSettingsStore((s) => s.version);
const versionString = `v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`;
return (
<Modal
opened={isOpen}
onClose={onClose}
title="About Dispatcharr"
centered
size="md"
>
<Stack gap="lg">
<Group justify="center" gap="md">
<img src={logo} alt="Dispatcharr" width={56} />
<Stack gap={2}>
<Text fw={700} size="xl">
Dispatcharr
</Text>
<Text size="sm" c="dimmed">
{versionString}
</Text>
</Stack>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<Tooltip label="Visit the Dispatcharr documentation" position="top">
<Button
component="a"
href="https://dispatcharr.github.io/Dispatcharr-Docs/"
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<BookOpen size={15} />}
fullWidth
>
Documentation
</Button>
</Tooltip>
<Tooltip label="Join our Discord community" position="top">
<Button
component="a"
href="https://discord.gg/Sp45V5BcxU"
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<DiscordIcon size={15} />}
fullWidth
>
Discord
</Button>
</Tooltip>
<Tooltip label="View source on GitHub" position="top">
<Button
component="a"
href="https://github.com/Dispatcharr/Dispatcharr"
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<Github size={15} />}
fullWidth
>
GitHub
</Button>
</Tooltip>
<Tooltip
label="Support Dispatcharr on Open Collective"
position="top"
>
<Button
component="a"
href="https://opencollective.com/dispatcharr/contribute"
target="_blank"
rel="noopener noreferrer"
variant="default"
color="pink"
leftSection={<Heart size={15} />}
fullWidth
>
Donate
</Button>
</Tooltip>
</SimpleGrid>
<Divider />
<Stack gap="xs">
<Group gap="xs">
<Users size={16} />
<Text size="sm" fw={500}>
Contributors
</Text>
</Group>
<Text size="sm" c="dimmed">
Dispatcharr is built by the community, for the community. Thank you
to every contributor, tester, and supporter who has helped make this
project what it is.
</Text>
</Stack>
<Tooltip label="Remembering Jesse Mann" position="top" withArrow>
<Box
style={{
background: 'var(--mantine-color-dark-6)',
borderRadius: 'var(--mantine-radius-sm)',
borderLeft: '3px solid var(--mantine-color-pink-5)',
padding: '10px 14px',
cursor: 'default',
}}
>
<Text size="sm" c="dimmed">
In memory of{' '}
<Text span fw={600} c="gray.3">
Jesse Mann
</Text>
.
</Text>
</Box>
</Tooltip>
</Stack>
</Modal>
);
};
export default AboutModal;

View file

@ -23,18 +23,7 @@ import {
} 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">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
export const DiscordIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
);
import { DiscordIcon, GitHubIcon } from './icons.jsx';
/**
* Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals.

View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import {
ActionIcon,
Box,
Group,
Progress,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { ChevronDown, ChevronRight, Radio } from 'lucide-react';
const formatProgramTime = (seconds) => {
const absSeconds = Math.abs(seconds);
const hours = Math.floor(absSeconds / 3600);
const minutes = Math.floor((absSeconds % 3600) / 60);
const secs = Math.floor(absSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const ProgramPreview = ({ program, loading, fetched, label = 'Now Playing:' }) => {
const [isExpanded, setIsExpanded] = useState(false);
if (loading) {
return (
<Group gap={5}>
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" c="dimmed">Loading EPG data...</Text>
</Group>
);
}
if (fetched && !program) {
return (
<Group gap={5}>
<Radio size="14" style={{ color: '#6b7280', flexShrink: 0 }} />
<Text size="xs" c="dimmed">No current program (EPG may need refresh)</Text>
</Group>
);
}
if (!program) {
return null;
}
const now = new Date();
const startTime = program.start_time ? new Date(program.start_time) : null;
const endTime = program.end_time ? new Date(program.end_time) : null;
let elapsed = 0, remaining = 0, percentage = 0;
let hasValidTime = false;
if (startTime && endTime) {
const totalDuration = (endTime - startTime) / 1000;
if (totalDuration > 0) {
hasValidTime = true;
elapsed = (now - startTime) / 1000;
remaining = (endTime - now) / 1000;
percentage = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
}
}
return (
<>
<Group gap={5} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
{label}
</Text>
<Tooltip label={program.title}>
<Text size="xs" c="dimmed" truncate>
{program.title}
</Text>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsExpanded(!isExpanded)}
style={{ flexShrink: 0 }}
>
{isExpanded ? <ChevronDown size="14" /> : <ChevronRight size="14" />}
</ActionIcon>
</Group>
{isExpanded && program.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{program.description}
</Text>
</Box>
)}
{isExpanded && hasValidTime && (
<Stack gap="xs" mt={4} ml={24}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
)}
</>
);
};
export default ProgramPreview;

View file

@ -470,6 +470,13 @@ const SeriesModal = ({ series, opened, onClose }) => {
const onChangeSelectedProvider = (value) => {
const provider = providers.find((p) => p.id.toString() === value);
setSelectedProvider(provider);
if (provider) {
setLoadingDetails(true);
fetchSeriesInfo(series.id, provider.id)
.then((details) => setDetailedSeries(details))
.catch(() => {})
.finally(() => setLoadingDetails(false));
}
};
if (!series) return null;

View file

@ -1,7 +1,15 @@
import React, { useRef, useState, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import { Copy, LogOut, ChevronDown, ChevronRight, Heart } from 'lucide-react';
import {
Copy,
LogOut,
ChevronDown,
ChevronRight,
Heart,
HelpCircle,
} from 'lucide-react';
import AboutModal from './AboutModal';
import { getOrderedNavItems } from '../config/navigation';
import {
Avatar,
@ -10,10 +18,10 @@ import {
Box,
Text,
UnstyledButton,
TextInput,
ActionIcon,
AppShellNavbar,
ScrollArea,
Skeleton,
Tooltip,
} from '@mantine/core';
import logo from '../images/logo.png';
@ -153,9 +161,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const getNavOrder = useAuthStore((s) => s.getNavOrder);
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
const publicIPRef = useRef(null);
const [userFormOpen, setUserFormOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [ipRevealed, setIpRevealed] = useState(false);
const closeUserForm = () => setUserFormOpen(false);
@ -280,35 +288,106 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}}
>
{isAuthenticated && (
<Stack gap="sm">
{!collapsed && (
<TextInput
label="Public IP"
ref={publicIPRef}
value={environment.public_ip}
readOnly={true}
leftSection={
environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={environment.country_name || environment.country_code}
title={
environment.country_name || environment.country_code
}
/>
)
}
rightSection={
<ActionIcon
variant="transparent"
color="gray.9"
onClick={copyPublicIP}
<Stack gap="sm" style={{ width: '100%' }}>
{!collapsed &&
environment.ip_lookup_enabled !== false &&
environment.ip_lookup_pending && (
<Box>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Skeleton height={36} radius="sm" />
</Box>
)}
{!collapsed &&
environment.ip_lookup_enabled !== false &&
!environment.ip_lookup_pending &&
environment.public_ip &&
!environment.public_ip.startsWith('Error') && (
<Box
onClick={() => setIpRevealed((v) => !v)}
style={{ cursor: 'pointer' }}
>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Box
style={{
display: 'flex',
alignItems: 'center',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'var(--mantine-color-dark-6)',
height: '36px',
paddingLeft: '10px',
gap: '8px',
}}
>
<Copy />
</ActionIcon>
}
/>
)}
{environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={
environment.country_name || environment.country_code
}
title={[
environment.country_name || environment.country_code,
environment.city,
]
.filter(Boolean)
.join(', ')}
style={{ flexShrink: 0 }}
/>
)}
<Box style={{ flex: 1, overflow: 'hidden' }}>
<span
style={{
display: 'block',
whiteSpace: 'nowrap',
fontSize: 'var(--mantine-font-size-sm)',
color: 'var(--mantine-color-text)',
}}
>
{(() => {
const ip = environment.public_ip;
const isIPv6 = ip.includes(':');
const sep = isIPv6 ? ':' : '.';
const parts = ip.split(sep);
const splitAt = isIPv6 ? 4 : 2;
const visible =
parts.slice(0, splitAt).join(sep) + sep;
const hidden = parts.slice(splitAt).join(sep);
return (
<>
{visible}
<span
style={{
filter: ipRevealed ? 'none' : 'blur(5px)',
transition: 'filter 0.15s',
userSelect: ipRevealed ? 'text' : 'none',
}}
>
{hidden}
</span>
</>
);
})()}
</span>
</Box>
<ActionIcon
variant="transparent"
color="gray.9"
onClick={(e) => {
e.stopPropagation();
copyPublicIP();
}}
style={{ flexShrink: 0 }}
>
<Copy />
</ActionIcon>
</Box>
</Box>
)}
{!collapsed && authUser && (
<Group
@ -327,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
</Group>
)}
{collapsed && (
<Group gap="xs">
<Group justify="center" style={{ width: '100%' }}>
<Avatar src="" radius="xl" />
</Group>
)}
@ -372,6 +451,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
</Text>
</Tooltip>
<Group gap="xs" wrap="nowrap">
<Tooltip label="About" position="top">
<ActionIcon
variant="transparent"
color="gray"
onClick={() => setAboutOpen(true)}
>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
<DonateButton />
{isAuthenticated && <NotificationCenter />}
</Group>
@ -389,10 +477,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
>
{isAuthenticated && <NotificationCenter />}
<DonateButton tooltipPosition="right" />
<Tooltip label="About" position="right">
<ActionIcon
variant="transparent"
color="gray"
onClick={() => setAboutOpen(true)}
>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
</Box>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
<AboutModal isOpen={aboutOpen} onClose={() => setAboutOpen(false)} />
</AppShellNavbar>
);
};

View file

@ -23,7 +23,7 @@ import {
formatStreamLabel,
getYouTubeEmbedUrl,
imdbUrl,
tmdbUrl
tmdbUrl,
} from '../utils/components/SeriesModalUtils.js';
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
import {
@ -38,7 +38,7 @@ const Movie = ({
hasMultipleProviders,
selectedProvider,
detailedVOD,
vod
vod,
}) => {
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
@ -71,28 +71,19 @@ const Movie = ({
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text size="sm" c="dimmed" fs="italic">
Original: {displayVOD.o_name}
</Text>
)}
{displayVOD.o_name && displayVOD.o_name !== displayVOD.name && (
<Text size="sm" c="dimmed" fs="italic">
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.year && <Badge color="blue">{displayVOD.year}</Badge>}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
<Badge color="gray">{formatDuration(displayVOD.duration_secs)}</Badge>
)}
{displayVOD.rating && <Badge color="yellow">{displayVOD.rating}</Badge>}
{displayVOD.age && <Badge color="orange">{displayVOD.age}</Badge>}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
@ -203,12 +194,15 @@ const Movie = ({
const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
const hasDetails =
techDetails.bitrate || techDetails.video || techDetails.audio;
if (!hasDetails) return null;
const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
const hasVideo =
techDetails.video && Object.keys(techDetails.video).length > 0;
const hasAudio =
techDetails.audio && Object.keys(techDetails.audio).length > 0;
return (
<Stack spacing={4} mt="xs">
@ -217,7 +211,9 @@ const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
{selectedProvider && (
<Text size="xs" c="dimmed" weight="normal" span ml={8}>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
{selectedProvider.stream_id &&
` - Stream ${selectedProvider.stream_id}`}
)
</Text>
)}
</Text>
@ -315,16 +311,21 @@ const VODModal = ({ vod, opened, onClose }) => {
}, [opened]);
const onClickYouTubeTrailer = () => {
setTrailerUrl(
getYouTubeEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerUrl(getYouTubeEmbedUrl(displayVOD.youtube_trailer));
setTrailerModalOpened(true);
}
};
const onChangeSelectedProvider = (value) => {
const provider = providers.find((p) => p.id.toString() === value);
setSelectedProvider(provider);
}
if (provider) {
setLoadingDetails(true);
fetchMovieDetailsFromProvider(vod.id, provider.id)
.then((details) => setDetailedVOD(details))
.catch(() => {})
.finally(() => setLoadingDetails(false));
}
};
if (!vod) return null;

View file

@ -0,0 +1,231 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ProgramPreview from '../ProgramPreview';
// Mock Mantine components as lightweight stubs
vi.mock('@mantine/core', () => {
return {
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Progress: ({ value }) => (
<div data-testid="progress" data-value={value} />
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Tooltip: ({ children }) => <>{children}</>,
};
});
vi.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="chevron-down" />,
ChevronRight: () => <span data-testid="chevron-right" />,
Radio: () => <span data-testid="radio-icon" />,
}));
afterEach(() => {
vi.useRealTimers();
});
describe('ProgramPreview', () => {
it('renders nothing when loading=false, fetched=false, program=null', () => {
const { container } = render(
<ProgramPreview loading={false} fetched={false} program={null} />
);
expect(container.innerHTML).toBe('');
});
it('shows loading text when loading=true', () => {
render(<ProgramPreview loading={true} fetched={false} program={null} />);
expect(screen.getByText('Loading EPG data...')).toBeTruthy();
});
it('shows no current program message when fetched=true and program=null', () => {
render(<ProgramPreview loading={false} fetched={true} program={null} />);
expect(
screen.getByText('No current program (EPG may need refresh)')
).toBeTruthy();
});
it('shows program title when program is provided', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Test Show',
start_time: new Date(now - 1800000).toISOString(),
end_time: new Date(now + 1800000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
expect(screen.getByText('Test Show')).toBeTruthy();
});
it('shows custom label when label prop is overridden', () => {
const program = { title: 'Show', start_time: null, end_time: null };
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
label="Currently Airing:"
/>
);
expect(screen.getByText('Currently Airing:')).toBeTruthy();
});
it('shows default label "Now Playing:"', () => {
const program = { title: 'Show', start_time: null, end_time: null };
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
expect(screen.getByText('Now Playing:')).toBeTruthy();
});
it('expand/collapse reveals description and time details', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Expandable Show',
description: 'A detailed description',
start_time: new Date(now - 3600000).toISOString(),
end_time: new Date(now + 3600000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
// Description not visible initially
expect(screen.queryByText('A detailed description')).toBeNull();
// Click chevron to expand
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Description should now be visible
expect(screen.getByText('A detailed description')).toBeTruthy();
// Time info visible
expect(screen.getByText(/elapsed/)).toBeTruthy();
expect(screen.getByText(/remaining/)).toBeTruthy();
// Click again to collapse
const collapseButton = screen.getByTestId('chevron-down').closest('button');
fireEvent.click(collapseButton);
// Description and time should be hidden again
expect(screen.queryByText('A detailed description')).toBeNull();
expect(screen.queryByText(/elapsed/)).toBeNull();
});
it('does not render description block when program has no description', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'No Desc Show',
description: null,
start_time: new Date(now - 3600000).toISOString(),
end_time: new Date(now + 3600000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
// Expand
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Time info should be visible, but no italic description block
expect(screen.getByText(/elapsed/)).toBeTruthy();
expect(screen.queryByText('null')).toBeNull();
});
});
describe('formatProgramTime', () => {
// We need to test the exported helper; import it via the module
// Since it's not exported, we test it indirectly through rendered output
it('formats time with hours correctly', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Long Show',
description: 'desc',
start_time: new Date(now - 2 * 3600000 - 30 * 60000).toISOString(), // 2h30m ago
end_time: new Date(now + 30 * 60000).toISOString(), // 30m from now
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
// Expand to see time
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Elapsed should show ~2:30:xx format (with hours)
const elapsedEl = screen.getByText(/elapsed/);
expect(elapsedEl.textContent).toMatch(/2:30:\d{2} elapsed/);
});
it('formats time without hours correctly', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Short Show',
description: 'desc',
start_time: new Date(now - 5 * 60000).toISOString(), // 5m ago
end_time: new Date(now + 25 * 60000).toISOString(), // 25m from now
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Elapsed should show ~5:xx format (no hours)
const elapsedEl = screen.getByText(/elapsed/);
expect(elapsedEl.textContent).toMatch(/5:\d{2} elapsed/);
// With pinned time, remaining should be exactly 25:00
const remainingEl = screen.getByText(/remaining/);
expect(remainingEl.textContent).toBe('25:00 remaining');
});
});

View file

@ -7,6 +7,12 @@ import useSettingsStore from '../../store/settings';
import { copyToClipboard } from '../../utils';
// Mock stores
vi.mock('../../store/auth', () => ({
default: {
getState: () => ({ accessToken: null }),
},
}));
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));

View file

@ -22,6 +22,10 @@ vi.mock('../NotificationCenter', () => ({
),
}));
vi.mock('../AboutModal', () => ({
default: () => null,
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => (
@ -59,6 +63,7 @@ vi.mock('lucide-react', () => ({
Heart: () => <div data-testid="heart-icon" />,
Package: () => <div data-testid="package-icon" />,
Download: () => <div data-testid="download-icon" />,
HelpCircle: () => <div data-testid="help-circle-icon" />,
}));
// Mock UserForm component
@ -91,14 +96,6 @@ vi.mock('@mantine/core', async () => {
</Component>
);
},
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
<div>
{label && <label>{label}</label>}
{leftSection}
<input value={value} onChange={onChange} />
{rightSection}
</div>
),
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
@ -117,6 +114,7 @@ vi.mock('@mantine/core', async () => {
</nav>
),
ScrollArea: ({ children }) => <div>{children}</div>,
Skeleton: ({ height }) => <div data-testid="skeleton" style={{ height }} />,
Tooltip: ({ children }) => <>{children}</>,
};
});
@ -285,8 +283,7 @@ describe('Sidebar', () => {
it('should render public IP with country flag', () => {
renderSidebar();
const ipInput = screen.getByDisplayValue('192.168.1.1');
expect(ipInput).toBeInTheDocument();
expect(screen.getByText('Public IP')).toBeInTheDocument();
const flag = screen.getByAltText('United States');
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
@ -487,7 +484,7 @@ describe('Sidebar', () => {
});
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.getByText('Public IP')).toBeInTheDocument();
expect(
screen.queryByRole('img', { name: /flag/i })
).not.toBeInTheDocument();

View file

@ -439,12 +439,12 @@ export default function BackupManager() {
try {
await API.restoreBackup(selectedBackup.name);
notifications.show({
title: 'Success',
title: 'Restore Complete',
message:
'Backup restored successfully. You may need to refresh the page.',
'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.',
color: 'green',
});
setTimeout(() => window.location.reload(), 2000);
setTimeout(() => window.location.reload(), 4000);
} catch (error) {
notifications.show({
title: 'Error',

View file

@ -11,7 +11,6 @@ import {
Card,
Center,
Group,
Progress,
Select,
Stack,
Text,
@ -19,18 +18,16 @@ import {
useMantineTheme,
} from '@mantine/core';
import {
ChevronDown,
ChevronRight,
CirclePlay,
Gauge,
HardDriveDownload,
HardDriveUpload,
Radio,
SquareX,
Timer,
Users,
Video,
} from 'lucide-react';
import ProgramPreview from '../ProgramPreview';
import {
toFriendlyDuration,
formatExactDuration,
@ -57,50 +54,6 @@ import {
import useVideoStore from '../../store/useVideoStore';
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
const formatProgramTime = (seconds) => {
const absSeconds = Math.abs(seconds);
const hours = Math.floor(absSeconds / 3600);
const minutes = Math.floor((absSeconds % 3600) / 60);
const secs = Math.floor(absSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const ProgramProgress = ({ currentProgram }) => {
const now = new Date();
const startTime = new Date(currentProgram.start_time);
const endTime = new Date(currentProgram.end_time);
const totalDuration = (endTime - startTime) / 1000; // in seconds
const elapsed = (now - startTime) / 1000; // in seconds
const remaining = (endTime - now) / 1000; // in seconds
const percentage = Math.min(
100,
Math.max(0, (elapsed / totalDuration) * 100)
);
return (
<Stack gap="xs" mt={4}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
);
};
// Create a separate component for each channel card to properly handle the hook
const StreamConnectionCard = ({
@ -120,7 +73,7 @@ const StreamConnectionCard = ({
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
const [data, setData] = useState([]);
const [previewedStream, setPreviewedStream] = useState(null);
const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false);
const theme = useMantineTheme();
@ -615,7 +568,7 @@ const StreamConnectionCard = ({
<Group mt={10}>
<Box>
<Tooltip label={getStartDate(uptime)}>
<Tooltip label={getStartDate(channel.started_at)}>
<Center>
<Timer pr={5} />
{toFriendlyDuration(uptime, 'seconds')}
@ -663,48 +616,11 @@ const StreamConnectionCard = ({
{/* Display current program on its own line */}
{currentProgram && (
<Group gap={5} mt={-9} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
Now Playing:
</Text>
<Text size="xs" c="dimmed" truncate>
{currentProgram.title}
</Text>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
style={{ flexShrink: 0 }}
>
{isProgramDescExpanded ? (
<ChevronDown size="14" />
) : (
<ChevronRight size="14" />
)}
</ActionIcon>
</Group>
<Box mt={-9}>
<ProgramPreview program={currentProgram} />
</Box>
)}
{/* Expandable program description */}
{currentProgram &&
isProgramDescExpanded &&
currentProgram.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{currentProgram.description}
</Text>
</Box>
)}
{/* Program progress bar */}
{currentProgram &&
isProgramDescExpanded &&
currentProgram.start_time &&
currentProgram.end_time && (
<ProgramProgress currentProgram={currentProgram} />
)}
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (
<Box mt={-10}>

View file

@ -3,10 +3,12 @@ import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import ChannelGroupForm from './ChannelGroup';
import logo from '../../images/logo.png';
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
import { useEpgPreview } from '../../hooks/useEpgPreview';
import useLogosStore from '../../store/logos';
import LazyLogo from '../LazyLogo';
import LogoForm from './Logo';
@ -34,6 +36,7 @@ import {
useMantineTheme,
} from '@mantine/core';
import { ListOrdered, SquarePlus, Undo2, X, Zap } from 'lucide-react';
import ProgramPreview from '../ProgramPreview';
import useEPGsStore from '../../store/epgs';
import { FixedSizeList as List } from 'react-window';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
@ -455,6 +458,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
}
}, [defaultValues, channel, reset, epgs, tvgsById]);
const epgDataId = watch('epg_data_id');
const { currentProgram, isLoadingProgram, hasFetchedProgram } =
useEpgPreview(epgDataId);
// Memoize logo options to prevent infinite re-renders during background loading
const logoOptions = useMemo(() => {
const options = [{ id: '0', name: 'Default' }].concat(
@ -521,7 +528,8 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
</Text>
)}
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
{/* Col 1: Identity - Channel Name, Number, Group, Logo */}
<Stack gap="5" style={{ flex: 1, minWidth: 0 }}>
<TextInput
id="name"
name="name"
@ -561,6 +569,33 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
style={{ flex: 1 }}
/>
<NumberInput
id="channel_number"
name="channel_number"
label="Channel # (blank to auto-assign)"
description={
<ProviderHintRow
channel={channel}
field="channel_number"
formValue={watch('channel_number')}
hintText={getProviderHint(channel, 'channel_number')}
onReset={() =>
setValue(
'channel_number',
getProviderFormValue(channel, 'channel_number'),
{ shouldDirty: true }
)
}
/>
}
value={watch('channel_number')}
onChange={(value) => setValue('channel_number', value)}
error={errors.channel_number?.message}
size="xs"
step={0.1}
precision={1}
/>
<Flex gap="sm">
<Popover
opened={groupPopoverOpened}
@ -674,64 +709,6 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
</Flex>
</Flex>
<Select
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
description={
<ProviderHintRow
channel={channel}
field="stream_profile_id"
formValue={watch('stream_profile_id')}
hintText={getFkProviderHint(
channel,
'stream_profile_id',
streamProfiles.reduce((acc, p) => {
acc[p.id] = p;
return acc;
}, {})
)}
onReset={() =>
setValue(
'stream_profile_id',
getProviderFormValue(channel, 'stream_profile_id')
)
}
/>
}
value={watch('stream_profile_id')}
onChange={(value) => {
setValue('stream_profile_id', value);
}}
error={errors.stream_profile_id?.message}
data={[{ value: '0', label: '(use default)' }].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))
)}
size="xs"
/>
<Select
label="User Level Access"
data={Object.entries(USER_LEVELS).map(([, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
value={watch('user_level')}
onChange={(value) => {
setValue('user_level', value);
}}
error={errors.user_level?.message}
/>
</Stack>
<Divider size="sm" orientation="vertical" />
<Stack justify="flex-start" style={{ flex: 1 }}>
<Group justify="space-between">
<Popover
opened={logoPopoverOpened}
@ -908,85 +885,16 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
>
Upload or Create Logo
</Button>
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
<Box>
<Switch
label="Mature Content"
checked={watch('is_adult')}
onChange={(event) =>
setValue('is_adult', event.currentTarget.checked)
}
size="md"
/>
</Box>
</Tooltip>
<Tooltip
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
withArrow
multiline
w={320}
>
<Box>
<Switch
label="Hidden"
checked={watch('hidden_from_output')}
onChange={(event) =>
setValue(
'hidden_from_output',
event.currentTarget.checked
)
}
size="md"
/>
</Box>
</Tooltip>
{channel?.auto_created && hasAnyOverride && (
<Tooltip
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
withArrow
>
<Button
variant="light"
color="orange"
size="xs"
onClick={clearOverrides}
>
Clear All Overrides ({overriddenFieldLabels.length})
</Button>
</Tooltip>
)}
</Stack>
<Divider size="sm" orientation="vertical" />
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
<NumberInput
id="channel_number"
name="channel_number"
label="Channel # (blank to auto-assign)"
description={
<ProviderHintRow
channel={channel}
field="channel_number"
formValue={watch('channel_number')}
hintText={getProviderHint(channel, 'channel_number')}
onReset={() =>
setValue(
'channel_number',
getProviderFormValue(channel, 'channel_number'),
{ shouldDirty: true }
)
}
/>
}
value={watch('channel_number')}
onChange={(value) => setValue('channel_number', value)}
error={errors.channel_number?.message}
size="xs"
step={0.1} // Add step prop to allow decimal inputs
precision={1} // Specify decimal precision
/>
{/* Col 2: Guide Data - TVG-ID, Gracenote StationId, EPG, Program Preview */}
<Stack
gap="5"
style={{ flex: 1, minWidth: 0 }}
justify="flex-start"
>
<TextInput
id="tvg_id"
name="tvg_id"
@ -1218,6 +1126,124 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
</ScrollArea>
</PopoverDropdown>
</Popover>
{(isLoadingProgram || hasFetchedProgram || currentProgram) && (
<Box mt="xs" p="xs">
<ProgramPreview
program={currentProgram}
loading={isLoadingProgram}
fetched={hasFetchedProgram}
label="Current Program:"
/>
</Box>
)}
</Stack>
<Divider size="sm" orientation="vertical" />
{/* Col 3: Behavior/Access - Stream Profile, User Level, Mature Content, Hidden */}
<Stack justify="flex-start" style={{ flex: 1, minWidth: 0 }}>
<Select
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
description={
<ProviderHintRow
channel={channel}
field="stream_profile_id"
formValue={watch('stream_profile_id')}
hintText={getFkProviderHint(
channel,
'stream_profile_id',
streamProfiles.reduce((acc, p) => {
acc[p.id] = p;
return acc;
}, {})
)}
onReset={() =>
setValue(
'stream_profile_id',
getProviderFormValue(channel, 'stream_profile_id')
)
}
/>
}
value={watch('stream_profile_id')}
onChange={(value) => {
setValue('stream_profile_id', value);
}}
error={errors.stream_profile_id?.message}
data={[{ value: '0', label: '(use default)' }].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))
)}
size="xs"
/>
<Select
label="User Level Access"
data={Object.entries(USER_LEVELS).map(([, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
value={watch('user_level')}
onChange={(value) => {
setValue('user_level', value);
}}
error={errors.user_level?.message}
/>
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
<Box>
<Switch
label="Mature Content"
checked={watch('is_adult')}
onChange={(event) =>
setValue('is_adult', event.currentTarget.checked)
}
size="md"
/>
</Box>
</Tooltip>
<Tooltip
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
withArrow
multiline
w={320}
>
<Box>
<Switch
label="Hidden"
checked={watch('hidden_from_output')}
onChange={(event) =>
setValue(
'hidden_from_output',
event.currentTarget.checked
)
}
size="md"
/>
</Box>
</Tooltip>
{channel?.auto_created && hasAnyOverride && (
<Tooltip
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
withArrow
>
<Button
variant="light"
color="orange"
size="xs"
onClick={clearOverrides}
>
Clear All Overrides ({overriddenFieldLabels.length})
</Button>
</Tooltip>
)}
</Stack>
</Group>

View file

@ -1,86 +1,40 @@
import React, { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import API from '../../api';
import {
Alert,
Button,
Group,
Loader,
Modal,
MultiSelect,
SegmentedControl,
Select,
Stack,
SegmentedControl,
MultiSelect,
Group,
TextInput,
Loader,
} from '@mantine/core';
import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates';
import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates';
import { CircleAlert } from 'lucide-react';
import { isNotEmpty, useForm } from '@mantine/form';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
const DAY_OPTIONS = [
{ value: '6', label: 'Sun' },
{ value: '0', label: 'Mon' },
{ value: '1', label: 'Tue' },
{ value: '2', label: 'Wed' },
{ value: '3', label: 'Thu' },
{ value: '4', label: 'Fri' },
{ value: '5', label: 'Sat' },
];
const asDate = (value) => {
if (!value) return null;
if (value instanceof Date) return value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const toIsoIfDate = (value) => {
const dt = asDate(value);
return dt ? dt.toISOString() : value;
};
// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm"
const toTimeString = (value) => {
if (!value) return '00:00';
if (typeof value === 'string') {
const parsed = dayjs(
value,
['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
true
);
if (parsed.isValid()) return parsed.format('HH:mm');
return value;
}
const dt = asDate(value);
if (!dt) return '00:00';
return dayjs(dt).format('HH:mm');
};
const toDateString = (value) => {
const dt = asDate(value);
if (!dt) return null;
const year = dt.getFullYear();
const month = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const createRoundedDate = (minutesAhead = 0) => {
const dt = new Date();
dt.setSeconds(0);
dt.setMilliseconds(0);
dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30);
if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead);
return dt;
};
// robust onChange for TimeInput (string or event)
const timeChange = (setter) => (valOrEvent) => {
if (typeof valOrEvent === 'string') setter(valOrEvent);
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
};
import {
RECURRING_DAY_OPTIONS,
toTimeString,
} from '../../utils/dateTimeUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import {
buildRecurringPayload,
buildSinglePayload,
createRecording,
createRecurringRule,
getChannelsSummary,
getRecurringFormDefaults,
getSingleFormDefaults,
numberedChannelLabel,
recurringFormValidators,
singleFormValidators,
sortedChannelOptions,
timeChange,
updateRecording,
} from '../../utils/forms/RecordingUtils.js';
const RecordingModal = ({
recording = null,
@ -98,117 +52,29 @@ const RecordingModal = ({
const [mode, setMode] = useState('single');
const [submitting, setSubmitting] = useState(false);
const defaultStart = createRoundedDate();
const defaultEnd = createRoundedDate(60);
const defaultDate = new Date();
// One-time form
const singleForm = useForm({
mode: 'controlled',
initialValues: {
channel_id: recording
? `${recording.channel}`
: channel
? `${channel.id}`
: '',
start_time: recording
? asDate(recording.start_time) || defaultStart
: defaultStart,
end_time: recording
? asDate(recording.end_time) || defaultEnd
: defaultEnd,
},
validate: {
channel_id: isNotEmpty('Select a channel'),
start_time: isNotEmpty('Select a start time'),
end_time: (value, values) => {
const start = asDate(values.start_time);
const end = asDate(value);
if (!end) return 'Select an end time';
if (start && end <= start) return 'End time must be after start time';
return null;
},
},
initialValues: getSingleFormDefaults(recording, channel),
validate: singleFormValidators,
});
// Recurring form stores times as "HH:mm" strings for stable editing
const recurringForm = useForm({
mode: 'controlled',
validateInputOnChange: false,
validateInputOnBlur: true,
initialValues: {
channel_id: channel ? `${channel.id}` : '',
days_of_week: [],
start_time: dayjs(defaultStart).format('HH:mm'),
end_time: dayjs(defaultEnd).format('HH:mm'),
rule_name: '',
start_date: defaultDate,
end_date: defaultDate,
},
validate: {
channel_id: isNotEmpty('Select a channel'),
days_of_week: (value) =>
value && value.length ? null : 'Pick at least one day',
start_time: (value) => (value ? null : 'Select a start time'),
end_time: (value, values) => {
if (!value) return 'Select an end time';
const start = dayjs(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
true
);
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
if (
start.isValid() &&
end.isValid() &&
end.diff(start, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
},
end_date: (value, values) => {
const end = asDate(value);
const start = asDate(values.start_date);
if (!end) return 'Select an end date';
if (start && end < start) return 'End date cannot be before start date';
return null;
},
},
initialValues: getRecurringFormDefaults(channel),
validate: recurringFormValidators,
});
useEffect(() => {
if (!isOpen) return;
const freshStart = createRoundedDate();
const freshEnd = createRoundedDate(60);
const freshDate = new Date();
if (recording && recording.id) {
if (recording?.id) {
setMode('single');
singleForm.setValues({
channel_id: `${recording.channel}`,
start_time: asDate(recording.start_time) || defaultStart,
end_time: asDate(recording.end_time) || defaultEnd,
});
singleForm.setValues(getSingleFormDefaults(recording, channel));
} else {
// Reset forms for fresh open
singleForm.setValues({
channel_id: channel ? `${channel.id}` : '',
start_time: freshStart,
end_time: freshEnd,
});
const startStr = dayjs(freshStart).format('HH:mm');
recurringForm.setValues({
channel_id: channel ? `${channel.id}` : '',
days_of_week: [],
start_time: startStr,
end_time: dayjs(freshEnd).format('HH:mm'),
rule_name: channel?.name || '',
start_date: freshDate,
end_date: freshDate,
});
singleForm.setValues(getSingleFormDefaults(null, channel));
recurringForm.setValues(getRecurringFormDefaults(channel));
setMode('single');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -221,7 +87,7 @@ const RecordingModal = ({
if (!isOpen) return;
try {
setIsChannelsLoading(true);
const chans = await API.getChannelsSummary();
const chans = await getChannelsSummary();
if (cancelled) return;
setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
@ -238,19 +104,7 @@ const RecordingModal = ({
}, [isOpen]);
const channelOptions = useMemo(() => {
const list = Array.isArray(allChannels) ? [...allChannels] : [];
list.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
return aNum - bNum;
});
return list.map((item) => ({
value: `${item.id}`,
label: item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`,
}));
return sortedChannelOptions(allChannels, numberedChannelLabel);
}, [allChannels]);
const resetForms = () => {
@ -267,25 +121,18 @@ const RecordingModal = ({
const handleSingleSubmit = async (values) => {
try {
setSubmitting(true);
const payload = buildSinglePayload(values);
if (recording && recording.id) {
await API.updateRecording(recording.id, {
channel: values.channel_id,
start_time: toIsoIfDate(values.start_time),
end_time: toIsoIfDate(values.end_time),
});
notifications.show({
await updateRecording(recording.id, payload);
showNotification({
title: 'Recording updated',
message: 'Recording schedule updated successfully',
color: 'green',
autoClose: 2500,
});
} else {
await API.createRecording({
channel: values.channel_id,
start_time: toIsoIfDate(values.start_time),
end_time: toIsoIfDate(values.end_time),
});
notifications.show({
await createRecording(payload);
showNotification({
title: 'Recording scheduled',
message: 'One-time recording added to DVR queue',
color: 'green',
@ -304,18 +151,10 @@ const RecordingModal = ({
const handleRecurringSubmit = async (values) => {
try {
setSubmitting(true);
await API.createRecurringRule({
channel: values.channel_id,
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
start_time: toTimeString(values.start_time),
end_time: toTimeString(values.end_time),
start_date: toDateString(values.start_date),
end_date: toDateString(values.end_date),
name: values.rule_name?.trim() || '',
});
await createRecurringRule(buildRecurringPayload(values));
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
notifications.show({
showNotification({
title: 'Recurring rule saved',
message: 'Future slots will be scheduled automatically',
color: 'green',
@ -427,7 +266,10 @@ const RecordingModal = ({
key={recurringForm.key('days_of_week')}
label="Every"
placeholder="Select days"
data={DAY_OPTIONS}
data={RECURRING_DAY_OPTIONS.map((opt) => ({
value: String(opt.value),
label: opt.label,
}))}
searchable
clearable
nothingFoundMessage="No match"

View file

@ -1,11 +1,13 @@
import useChannelsStore from '../../store/channels.jsx';
import API from '../../api';
import {
format,
isAfter,
isBefore,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React from 'react';
import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check, Pencil, RefreshCcw, X } from 'lucide-react';
import {
ActionIcon,
Badge,
@ -21,7 +23,6 @@ import {
TextInput,
} from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
import { notifications } from '@mantine/notifications';
import defaultLogo from '../../images/logo.png';
import {
deleteRecordingById,
@ -33,10 +34,98 @@ import {
runComSkip,
} from '../../utils/cards/RecordingCardUtils.js';
import {
getChannel,
getRating,
getStatRows,
getUpcomingEpisodes,
refreshArtwork,
updateRecordingMetadata,
} from '../../utils/forms/RecordingDetailsModalUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
const EpisodeRow = ({
rec,
recording,
channel,
channelsById,
livePosterUrl,
toUserTime,
dateformat,
timeformat,
onOpenChild,
}) => {
const cp = rec.custom_properties || {};
const pr = cp.program || {};
const start = toUserTime(rec.start_time);
const end = toUserTime(rec.end_time);
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
const epChannel =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
const onRemove = async (e) => {
e?.stopPropagation?.();
try {
await deleteRecordingById(rec.id);
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
};
return (
<Card
withBorder
radius="md"
padding="sm"
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
onClick={() => onOpenChild(rec)}
>
<Flex gap="sm" align="center">
<Image
src={purl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={pr.title}
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
/>
<Stack gap={4} flex={1}>
<Group justify="space-between">
<Text
fw={600}
size="sm"
lineClamp={1}
title={pr.sub_title || pr.title}
>
{pr.sub_title || pr.title}
</Text>
{se && (
<Badge color="gray" variant="light">
{se}
</Badge>
)}
</Group>
<Text size="xs">
{format(start, `${dateformat}, YYYY ${timeformat}`)} {' '}
{format(end, timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button size="xs" color="red" variant="light" onClick={onRemove}>
Remove
</Button>
</Group>
</Flex>
</Card>
);
};
const RecordingDetailsModal = ({
opened,
@ -51,21 +140,21 @@ const RecordingDetailsModal = ({
}) => {
const allRecordings = useChannelsStore((s) => s.recordings);
// Local channel cache to avoid the global channels map
const [channelsById, setChannelsById] = React.useState({});
const [channelsById, setChannelsById] = useState({});
const { toUserTime, userNow } = useTimeHelpers();
const [childOpen, setChildOpen] = React.useState(false);
const [childRec, setChildRec] = React.useState(null);
const [childOpen, setChildOpen] = useState(false);
const [childRec, setChildRec] = useState(null);
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const [editing, setEditing] = React.useState(false);
const [editing, setEditing] = useState(false);
// Prefer the store version of the recording for live updates
// (e.g., after artwork refresh or metadata edit via WebSocket).
// Preserve _group_count from the categorized prop the store version
// doesn't carry this client-side field, so without merging it back
// isSeriesGroup would always be false and the episode list hidden.
const safeRecording = React.useMemo(() => {
const safeRecording = useMemo(() => {
if (recording?.id && Array.isArray(allRecordings)) {
const found = allRecordings.find((r) => r.id === recording.id);
if (found) {
@ -81,7 +170,7 @@ const RecordingDetailsModal = ({
const program = customProps.program || {};
// Derive poster URL from live store data instead of the stale prop snapshot.
const livePosterUrl = React.useMemo(
const livePosterUrl = useMemo(
() =>
getPosterUrl(
customProps.poster_logo_id,
@ -93,17 +182,17 @@ const RecordingDetailsModal = ({
// Optimistic overrides show saved values immediately without waiting
// for the WebSocket round-trip to refresh the store.
const [savedTitle, setSavedTitle] = React.useState(null);
const [savedDescription, setSavedDescription] = React.useState(null);
const [savedTitle, setSavedTitle] = useState(null);
const [savedDescription, setSavedDescription] = useState(null);
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
const description =
savedDescription ?? (program.description || customProps.description || '');
const [editTitle, setEditTitle] = React.useState('');
const [editDescription, setEditDescription] = React.useState('');
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
// Reset optimistic state when the recording changes
React.useEffect(() => {
useEffect(() => {
setSavedTitle(null);
setSavedDescription(null);
setEditing(false);
@ -129,7 +218,7 @@ const RecordingDetailsModal = ({
const isSeriesGroup = Boolean(
safeRecording._group_count && safeRecording._group_count > 1
);
const upcomingEpisodes = React.useMemo(() => {
const upcomingEpisodes = useMemo(() => {
return getUpcomingEpisodes(
isSeriesGroup,
allRecordings,
@ -147,7 +236,7 @@ const RecordingDetailsModal = ({
]);
// Ensure channel is available for a given id
const loadChannel = React.useCallback(
const loadChannel = useCallback(
async (id) => {
if (!id) {
return null;
@ -159,7 +248,7 @@ const RecordingDetailsModal = ({
}
try {
const ch = await API.getChannel(id);
const ch = await getChannel(id);
if (ch && ch.id === id) {
setChannelsById((prev) => ({ ...prev, [id]: ch }));
return ch;
@ -177,7 +266,7 @@ const RecordingDetailsModal = ({
);
// When opening a child episode, fetch that episode's channel
React.useEffect(() => {
useEffect(() => {
if (!childOpen || !childRec) return;
loadChannel(childRec.channel);
}, [childOpen, childRec, loadChannel]);
@ -188,7 +277,7 @@ const RecordingDetailsModal = ({
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
if (now.isAfter(s) && now.isBefore(e)) {
if (isAfter(now, s) && isBefore(now, e)) {
const ch =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
@ -228,14 +317,11 @@ const RecordingDetailsModal = ({
const saveMetadata = async () => {
try {
await API.updateRecordingMetadata(recording.id, {
title: editTitle || 'Custom Recording',
description: editDescription,
});
await updateRecordingMetadata(recording, editTitle, editDescription);
setSavedTitle(editTitle || 'Custom Recording');
setSavedDescription(editDescription);
setEditing(false);
notifications.show({
showNotification({
title: 'Saved',
message: 'Recording metadata updated',
color: 'green',
@ -249,8 +335,8 @@ const RecordingDetailsModal = ({
const handleRefreshArtwork = async (e) => {
e.stopPropagation?.();
try {
await API.refreshArtwork(recording.id);
notifications.show({
await refreshArtwork(recording.id);
showNotification({
title: 'Refreshing artwork',
message: 'Poster resolution started',
color: 'blue.5',
@ -265,7 +351,7 @@ const RecordingDetailsModal = ({
e.stopPropagation?.();
try {
await runComSkip(recording);
notifications.show({
showNotification({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
color: 'blue.5',
@ -278,86 +364,6 @@ const RecordingDetailsModal = ({
if (!recording) return null;
const EpisodeRow = ({ rec }) => {
const cp = rec.custom_properties || {};
const pr = cp.program || {};
const start = toUserTime(rec.start_time);
const end = toUserTime(rec.end_time);
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
const epChannel =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
const onRemove = async (e) => {
e?.stopPropagation?.();
try {
await deleteRecordingById(rec.id);
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
// recording_cancelled WS event triggers the debounced fetchRecordings()
};
const handleOnMainCardClick = () => {
setChildRec(rec);
setChildOpen(true);
};
return (
<Card
withBorder
radius="md"
padding="sm"
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
onClick={handleOnMainCardClick}
>
<Flex gap="sm" align="center">
<Image
src={purl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={pr.title || recordingName}
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
/>
<Stack gap={4} flex={1}>
<Group justify="space-between">
<Text
fw={600}
size="sm"
lineClamp={1}
title={pr.sub_title || pr.title}
>
{pr.sub_title || pr.title}
</Text>
{se && (
<Badge color="gray" variant="light">
{se}
</Badge>
)}
</Group>
<Text size="xs">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button size="xs" color="red" variant="light" onClick={onRemove}>
Remove
</Button>
</Group>
</Flex>
</Card>
);
};
const WatchLive = () => {
return (
<Button
@ -414,7 +420,21 @@ const RecordingDetailsModal = ({
</Text>
)}
{upcomingEpisodes.map((ep) => (
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
<EpisodeRow
key={`ep-${ep.id}`}
rec={ep}
recording={recording}
channel={channel}
channelsById={channelsById}
livePosterUrl={livePosterUrl}
toUserTime={toUserTime}
dateformat={dateformat}
timeformat={timeformat}
onOpenChild={(rec) => {
setChildRec(rec);
setChildOpen(true);
}}
/>
))}
{childOpen && childRec && (
<RecordingDetailsModal
@ -468,7 +488,7 @@ const RecordingDetailsModal = ({
<Group gap={8}>
{onWatchLive && <WatchLive />}
{onWatchRecording && <WatchRecording />}
{onEdit && start.isAfter(userNow()) && <Edit />}
{onEdit && isAfter(start, userNow()) && <Edit />}
{(customProps.status === 'completed' ||
customProps.status === 'stopped' ||
customProps.status === 'interrupted') &&
@ -486,8 +506,8 @@ const RecordingDetailsModal = ({
</Group>
</Group>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
{format(start, `${dateformat}, YYYY ${timeformat}`)} {' '}
{format(end, timeformat)}
</Text>
{rating && (
<Group gap={8}>

View file

@ -1,16 +1,15 @@
import useChannelsStore from '../../store/channels.jsx';
import API from '../../api.js';
import {
parseDate,
format,
getNow,
RECURRING_DAY_OPTIONS,
toDate,
toTimeString,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from '@mantine/form';
import dayjs from 'dayjs';
import { notifications } from '@mantine/notifications';
import {
Badge,
Button,
@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import {
deleteRecurringRuleById,
getChannelOptions,
getFormDefaults,
getUpcomingOccurrences,
updateRecurringRule,
updateRecurringRuleEnabled,
} from '../../utils/forms/RecurringRuleModalUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import {
getChannelsSummary,
getRecurringFormDefaults,
recurringFormValidators,
sortedChannelOptions,
} from '../../utils/forms/RecordingUtils.js';
const RecurringRuleModal = ({
opened,
@ -56,66 +62,18 @@ const RecurringRuleModal = ({
const rule = recurringRules.find((r) => r.id === ruleId);
const channelOptions = useMemo(() => {
return getChannelOptions(allChannels);
return sortedChannelOptions(allChannels);
}, [allChannels]);
const form = useForm({
mode: 'controlled',
initialValues: {
channel_id: '',
days_of_week: [],
rule_name: '',
start_time: dayjs().startOf('hour').format('HH:mm'),
end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
start_date: dayjs().toDate(),
end_date: dayjs().toDate(),
enabled: true,
},
validate: {
channel_id: (value) => (value ? null : 'Select a channel'),
days_of_week: (value) =>
value && value.length ? null : 'Pick at least one day',
end_time: (value, values) => {
if (!value) return 'Select an end time';
const startValue = dayjs(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
true
);
const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
if (
startValue.isValid() &&
endValue.isValid() &&
endValue.diff(startValue, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
},
end_date: (value, values) => {
const endDate = dayjs(value);
const startDate = dayjs(values.start_date);
if (!value) return 'Select an end date';
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
return 'End date cannot be before start date';
}
return null;
},
},
initialValues: { ...getRecurringFormDefaults(), enabled: true },
validate: recurringFormValidators,
});
useEffect(() => {
if (opened && rule) {
form.setValues({
channel_id: `${rule.channel}`,
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
rule_name: rule.name || '',
start_time: toTimeString(rule.start_time),
end_time: toTimeString(rule.end_time),
start_date: parseDate(rule.start_date) || dayjs().toDate(),
end_date: parseDate(rule.end_date),
enabled: Boolean(rule.enabled),
});
form.setValues(getFormDefaults(rule));
} else {
form.reset();
}
@ -127,7 +85,7 @@ const RecurringRuleModal = ({
let cancelled = false;
(async () => {
try {
const chans = await API.getChannelsSummary();
const chans = await getChannelsSummary();
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
console.warn('Failed to load channels for recurring rule modal', e);
@ -149,7 +107,7 @@ const RecurringRuleModal = ({
try {
await updateRecurringRule(ruleId, values);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
showNotification({
title: 'Recurring rule updated',
message: 'Schedule adjustments saved',
color: 'green',
@ -169,7 +127,7 @@ const RecurringRuleModal = ({
try {
await deleteRecurringRuleById(ruleId);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
showNotification({
title: 'Recurring rule removed',
message: 'All future occurrences were cancelled',
color: 'red',
@ -189,7 +147,7 @@ const RecurringRuleModal = ({
try {
await updateRecurringRuleEnabled(ruleId, checked);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
showNotification({
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
message: checked
? 'Future occurrences will resume'
@ -210,7 +168,7 @@ const RecurringRuleModal = ({
try {
await deleteRecordingById(occurrence.id);
// recording_cancelled WS event handles recording list update
notifications.show({
showNotification({
title: 'Occurrence cancelled',
message: 'The selected airing was removed',
color: 'yellow',
@ -246,7 +204,7 @@ const RecurringRuleModal = ({
setDeleting(true);
try {
await deleteRecordingById(sourceRecording.id);
notifications.show({
showNotification({
title: 'Recording deleted',
color: 'green',
autoClose: 2500,
@ -275,7 +233,7 @@ const RecurringRuleModal = ({
};
const handleStartDateChange = (value) => {
form.setFieldValue('start_date', value || dayjs().toDate());
form.setFieldValue('start_date', value || toDate(getNow()));
};
const handleEndDateChange = (value) => {
@ -302,10 +260,11 @@ const RecurringRuleModal = ({
<Group justify="space-between" align="center">
<Stack gap={2} flex={1}>
<Text fw={600} size="sm">
{occStart.format(`${dateformat}, YYYY`)}
{format(occStart, `${dateformat}, YYYY`)}
</Text>
<Text size="xs" c="dimmed">
{occStart.format(timeformat)} {occEnd.format(timeformat)}
{format(occStart, timeformat)} {' '}
{format(occEnd, timeformat)}
</Text>
</Stack>
<Group gap={6}>

View file

@ -38,8 +38,8 @@ import {
Popover,
ActionIcon,
Group,
Divider,
SimpleGrid,
PopoverTarget,
PopoverDropdown,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { validateCronExpression } from '../../utils/cronUtils';
@ -118,12 +118,12 @@ export default function ScheduleInput({
<Group gap="xs">
Cron Expression
<Popover width={320} position="top" withArrow shadow="md">
<Popover.Target>
<PopoverTarget>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="sm">
</PopoverTarget>
<PopoverDropdown p="sm">
<Text size="xs" fw={600} mb="xs" c="dimmed">
COMMON EXAMPLES
</Text>
@ -169,7 +169,7 @@ export default function ScheduleInput({
<Code size="xs">30 14 1 * *</Code>
</Group>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
</Group>
}

View file

@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = {
regex: 'Title regex',
};
const renderRuleSummary = (r) => {
const titleMode = (r.title_mode || 'exact').toLowerCase();
const parts = [];
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
if (r.title) {
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
}
if (r.description) {
parts.push(`Description: "${r.description}"`);
}
return parts.join(' | ');
};
export default function SeriesRecordingModal({
opened,
onClose,
@ -59,19 +72,6 @@ export default function SeriesRecordingModal({
onRulesUpdate(updated);
};
const renderRuleSummary = (r) => {
const titleMode = (r.title_mode || 'exact').toLowerCase();
const parts = [];
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
if (r.title) {
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
}
if (r.description) {
parts.push(`Description: "${r.description}"`);
}
return parts.join(' | ');
};
return (
<>
<Modal

View file

@ -1,64 +1,37 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Divider,
Group,
Modal,
ScrollAreaAutosize,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Textarea,
SegmentedControl,
Group,
Button,
Select,
Badge,
Divider,
ScrollArea,
Alert,
TextInput,
} from '@mantine/core';
import API from '../../api.js';
import useChannelsStore from '../../store/channels.jsx';
import useEPGsStore from '../../store/epgs.jsx';
import { useDebounce } from '../../utils.js';
import { showNotification } from '../../utils/notificationUtils.js';
const TITLE_MODES = [
{ label: 'Exact', value: 'exact' },
{ label: 'Contains', value: 'contains' },
{ label: 'Whole word', value: 'search' },
{ label: 'Regex', value: 'regex' },
];
const DESCRIPTION_MODES = [
{ label: 'Contains', value: 'contains' },
{ label: 'Whole word', value: 'search' },
{ label: 'Regex', value: 'regex' },
];
const EPISODE_MODES = [
{ label: 'All episodes', value: 'all' },
{ label: 'New only', value: 'new' },
];
function formatRange(start, end) {
try {
const s = new Date(start);
const e = new Date(end);
const sameDay = s.toDateString() === e.toDateString();
const dateStr = s.toLocaleDateString();
const startStr = s.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const endStr = e.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return sameDay
? `${dateStr} ${startStr} - ${endStr}`
: `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
} catch {
return `${start} - ${end}`;
}
}
import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js';
import {
createSeriesRule,
evaluateSeriesRulesByTvgId,
} from '../../utils/guideUtils.js';
import {
DESCRIPTION_MODES,
EPISODE_MODES,
formatRange,
getChannelOptions,
getTvgOptions,
previewSeriesRule,
TITLE_MODES,
} from '../../utils/forms/SeriesRuleEditorModalUtils.js';
export default function SeriesRuleEditorModal({
opened,
@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({
useEffect(() => {
if (!opened) return;
let cancelled = false;
API.getChannelsSummary()
getChannelsSummary()
.then((chans) => {
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
})
@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({
setPreviewLoading(true);
setPreviewError(null);
API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal })
previewSeriesRule(debouncedPreviewKey, controller)
.then((resp) => {
if (controller.signal.aborted) return;
setPreview(resp || { matches: [], total: 0 });
@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({
// EPG channel options for the tvg_id selector. Deduplicate by tvg_id value
// since the same channel can appear across multiple EPG sources.
const tvgOptions = useMemo(() => {
const seen = new Set();
const options = [];
for (const t of tvgs || []) {
if (!t.tvg_id || seen.has(t.tvg_id)) continue;
seen.add(t.tvg_id);
options.push({
value: t.tvg_id,
label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
});
}
return options.sort((a, b) => a.label.localeCompare(b.label));
return getTvgOptions(tvgs);
}, [tvgs]);
// Channel select options: prefer channels matching the selected tvg_id.
const channelOptions = useMemo(() => {
const sorted = [...allChannels].sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum !== bNum) return aNum - bNum;
return (a.name || '').localeCompare(b.name || '');
});
const matching = [];
const others = [];
for (const c of sorted) {
const item = {
value: String(c.id),
label: c.channel_number
? `${c.channel_number} - ${c.name || `Channel ${c.id}`}`
: c.name || `Channel ${c.id}`,
};
const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null;
if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
matching.push(item);
} else {
others.push(item);
}
}
return [...matching, ...others];
return getChannelOptions(allChannels, tvgsById, tvgId);
}, [allChannels, tvgsById, tvgId]);
const canSave = !!(payload.title || payload.description);
@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({
const handleSave = async () => {
setSaving(true);
try {
await API.createSeriesRule(payload);
await createSeriesRule(payload);
// Trigger evaluation so matching upcoming programs get scheduled.
try {
await API.evaluateSeriesRules(payload.tvg_id);
await evaluateSeriesRulesByTvgId(payload.tvg_id);
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to evaluate after save', e);
@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({
showNotification({ title: 'Series rule saved' });
if (onSaved) await onSaved();
onClose();
} catch (e) {
console.error('Failed to save series rule', e);
} finally {
setSaving(false);
}
@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({
</Alert>
)}
<ScrollArea.Autosize mah={240}>
<ScrollAreaAutosize mah={240}>
<Stack gap={4}>
{(preview.matches || []).map((p) => (
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</ScrollAreaAutosize>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" onClick={onClose}>

View file

@ -1,17 +1,14 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
import { Button, Flex, Modal, Select, TextInput } from '@mantine/core';
import useChannelsStore from '../../store/channels';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
});
import {
addStream,
getResolver,
updateStream,
} from '../../utils/forms/StreamUtils.js';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
@ -40,7 +37,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
@ -58,9 +55,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
};
if (stream?.id) {
await API.updateStream({ id: stream.id, ...payload });
await updateStream(stream.id, payload);
} else {
await API.addStream(payload);
await addStream(payload);
}
reset();

View file

@ -1,50 +1,25 @@
// StreamProfile form
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import {
Modal,
TextInput,
Textarea,
Select,
Button,
Flex,
Stack,
Checkbox,
Flex,
Modal,
Select,
Stack,
Textarea,
TextInput,
} from '@mantine/core';
// Built-in commands supported by Dispatcharr out of the box.
const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: 'streamlink', label: 'Streamlink' },
{ value: 'cvlc', label: 'VLC' },
{ value: 'yt-dlp', label: 'yt-dlp' },
{ value: '__custom__', label: 'Custom…' },
];
// Default parameter examples for each built-in command.
const COMMAND_EXAMPLES = {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
};
// Returns '__custom__' when the command isn't one of the built-ins,
// otherwise returns the command value itself.
const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string(),
});
import {
addStreamProfile,
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
getResolver,
toCommandSelection,
updateStreamProfile,
} from '../../utils/forms/StreamProfileUtils.js';
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
@ -73,7 +48,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
// Sync form + dropdown selection whenever the target profile or modal state changes
@ -84,9 +59,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const onSubmit = async (values) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
await updateStreamProfile(profile.id, values);
} else {
await API.addStreamProfile(values);
await addStreamProfile(values);
}
reset();
@ -102,6 +77,17 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgentValue = watch('user_agent');
const isActiveValue = watch('is_active');
const handleOnChangeCommand = (val) => {
setCommandSelection(val);
// For built-in selections, write the real command value immediately
if (val !== '__custom__') {
setValue('command', val, { shouldValidate: true });
} else {
// Clear so the user enters their own value
setValue('command', '', { shouldValidate: false });
}
};
return (
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={handleSubmit(onSubmit)}>
@ -127,16 +113,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
data={BUILT_IN_COMMANDS}
disabled={isLocked}
value={commandSelection}
onChange={(val) => {
setCommandSelection(val);
// For built-in selections, write the real command value immediately
if (val !== '__custom__') {
setValue('command', val, { shouldValidate: true });
} else {
// Clear so the user enters their own value
setValue('command', '', { shouldValidate: false });
}
}}
onChange={handleOnChangeCommand}
error={isCustom ? undefined : errors.command?.message}
/>

View file

@ -16,13 +16,21 @@ import useAuthStore from '../../store/auth';
import useSettingsStore from '../../store/settings';
import logo from '../../assets/logo.png';
const createSuperUser = (formData) => {
return API.createSuperUser({
username: formData.username,
password: formData.password,
email: formData.email,
});
};
function SuperuserForm() {
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
});
const [error, setError] = useState('');
const [_error, setError] = useState('');
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
const storedVersion = useSettingsStore((s) => s.version);
@ -43,11 +51,7 @@ function SuperuserForm() {
e.preventDefault();
try {
console.log(formData);
const response = await API.createSuperUser({
username: formData.username,
password: formData.password,
email: formData.email,
});
const response = await createSuperUser(formData);
if (response.superuser_exists) {
setSuperuserExists(true);
}

View file

@ -1,43 +1,41 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import React, { useEffect, useState } from 'react';
import {
TextInput,
Button,
Modal,
Select,
PasswordInput,
Group,
Stack,
MultiSelect,
ActionIcon,
Switch,
Button,
Group,
Modal,
MultiSelect,
NumberInput,
PasswordInput,
Select,
Stack,
Switch,
Tabs,
TabsList,
TabsPanel,
TabsTab,
TagsInput,
Text,
TextInput,
useMantineTheme,
} from '@mantine/core';
import { RotateCcwKey, X } from 'lucide-react';
import { Copy, Key } from 'lucide-react';
import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import useOutputProfilesStore from '../../store/outputProfiles';
import {
USER_LEVELS,
USER_LEVEL_LABELS,
NETWORK_ACCESS_OPTIONS,
} from '../../constants';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
const isValidNetworkEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
entry.match(IPV6_CIDR_REGEX) ||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
(entry + '/128').match(IPV6_CIDR_REGEX);
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
import {
createUser,
formValuesToPayload,
generateApiKey,
getFormInitialValues,
getFormValidators,
revokeApiKey,
updateUser,
userToFormValues,
} from '../../utils/forms/UserUtils.js';
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
@ -48,52 +46,15 @@ const User = ({ user = null, isOpen, onClose }) => {
const [, setEnableXC] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
const [generating, setGenerating] = useState(false);
const [generatedKey, setGeneratedKey] = useState(null);
const [_generatedKey, setGeneratedKey] = useState(null);
const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null);
const theme = useMantineTheme();
const form = useForm({
mode: 'uncontrolled',
initialValues: {
username: '',
first_name: '',
last_name: '',
email: '',
user_level: '0',
stream_limit: 0,
password: '',
xc_password: '',
output_format: '',
output_profile: '',
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],
},
validate: (values) => ({
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
? 'Password is required'
: null,
xc_password:
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
allowed_ips: (values.allowed_ips || []).some(
(t) => !isValidNetworkEntry(t)
)
? 'Invalid IP address or CIDR range'
: null,
}),
initialValues: getFormInitialValues(),
validate: getFormValidators(user),
});
const onChannelProfilesChange = (values) => {
@ -110,65 +71,18 @@ const User = ({ user = null, isOpen, onClose }) => {
};
const onSubmit = async () => {
const values = form.getValues();
const payload = formValuesToPayload(form.getValues(), user);
const customProps = user?.custom_properties || {};
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
customProps.output_format = values.output_format || null;
delete values.output_format;
customProps.output_profile = values.output_profile
? parseInt(values.output_profile, 10)
: null;
delete values.output_profile;
customProps.hide_adult_content = values.hide_adult_content || false;
delete values.hide_adult_content;
customProps.epg_days = values.epg_days || 0;
delete values.epg_days;
customProps.epg_prev_days = values.epg_prev_days || 0;
delete values.epg_prev_days;
values.custom_properties = customProps;
// Serialize per-user network restrictions into custom_properties (same list for all types)
const joined = (values.allowed_ips || []).join(',');
delete values.allowed_ips;
const allowed_networks = {};
if (joined)
NETWORK_KEYS.forEach((key) => {
allowed_networks[key] = joined;
});
customProps.allowed_networks = allowed_networks;
if (values.channel_profiles.includes('0')) {
values.channel_profiles = [];
}
if (!user && values.user_level == USER_LEVELS.STREAMER) {
values.password = Math.random().toString(36).slice(2);
if (!user && payload.user_level == USER_LEVELS.STREAMER) {
payload.password = Math.random().toString(36).slice(2);
}
if (!user) {
await API.createUser(values);
await createUser(payload);
} else {
if (!values.password) {
delete values.password;
}
const response = await API.updateUser(
user.id,
values,
isAdmin ? false : authUser.id === user.id
);
if (user.id == authUser.id) {
setUser(response);
}
if (!payload.password) delete payload.password;
const response = await updateUser(user.id, payload, isAdmin, authUser);
if (user.id == authUser.id) setUser(response);
}
form.reset();
@ -178,38 +92,9 @@ const User = ({ user = null, isOpen, onClose }) => {
useEffect(() => {
if (user?.id) {
const customProps = user.custom_properties || {};
const networks = customProps.allowed_networks || {};
form.setValues(userToFormValues(user));
form.setValues({
username: user.username,
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email,
user_level: `${user.user_level}`,
stream_limit: user.stream_limit || 0,
channel_profiles:
user.channel_profiles.length > 0
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
output_format: customProps.output_format || '',
output_profile: customProps.output_profile
? `${customProps.output_profile}`
: '',
hide_adult_content: customProps.hide_adult_content || false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
allowed_ips: [
...new Set(
NETWORK_KEYS.flatMap((key) =>
networks[key] ? networks[key].split(',').filter(Boolean) : []
)
),
],
});
if (customProps.xc_password) {
if (user.custom_properties?.xc_password) {
setEnableXC(true);
}
@ -248,13 +133,13 @@ const User = ({ user = null, isOpen, onClose }) => {
payload.user_id = user.id;
}
const resp = await API.generateApiKey(payload);
const resp = await generateApiKey(payload);
const newKey = resp && (resp.key || resp.raw_key);
if (newKey) {
setGeneratedKey(newKey);
setUserAPIKey(newKey);
}
} catch (e) {
} catch {
// API shows notifications
} finally {
setGenerating(false);
@ -271,7 +156,8 @@ const User = ({ user = null, isOpen, onClose }) => {
payload.user_id = user.id;
}
const resp = await API.revokeApiKey(payload);
const resp = await revokeApiKey(payload);
// backend returns { success: true } - clear local state
if (resp && resp.success) {
setGeneratedKey(null);
setUserAPIKey(null);
@ -280,7 +166,7 @@ const User = ({ user = null, isOpen, onClose }) => {
setUser({ ...authUser, api_key: null });
}
}
} catch (e) {
} catch {
// API shows notifications
} finally {
setGenerating(false);
@ -291,16 +177,16 @@ const User = ({ user = null, isOpen, onClose }) => {
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<Tabs defaultValue="account">
<Tabs.List mb="md">
<Tabs.Tab value="account">Account</Tabs.Tab>
<TabsList mb="md">
<TabsTab value="account">Account</TabsTab>
{showPermissions && (
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
<TabsTab value="permissions">Permissions</TabsTab>
)}
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
<Tabs.Tab value="api">API &amp; XC</Tabs.Tab>
</Tabs.List>
<TabsTab value="epg">EPG Defaults</TabsTab>
<TabsTab value="api">API &amp; XC</TabsTab>
</TabsList>
<Tabs.Panel value="account">
<TabsPanel value="account">
<Stack gap="sm">
<Group grow align="flex-start">
<TextInput
@ -335,10 +221,10 @@ const User = ({ user = null, isOpen, onClose }) => {
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
/>
</Stack>
</Tabs.Panel>
</TabsPanel>
{showPermissions && (
<Tabs.Panel value="permissions">
<TabsPanel value="permissions">
<Stack gap="sm">
<Group grow align="flex-start">
<Select
@ -375,10 +261,10 @@ const User = ({ user = null, isOpen, onClose }) => {
key={form.key('hide_adult_content')}
/>
</Stack>
</Tabs.Panel>
</TabsPanel>
)}
<Tabs.Panel value="epg">
<TabsPanel value="epg">
<Stack gap="sm">
<Text size="sm" c="dimmed">
These defaults apply when no URL parameters are specified and
@ -404,9 +290,9 @@ const User = ({ user = null, isOpen, onClose }) => {
/>
</Group>
</Stack>
</Tabs.Panel>
</TabsPanel>
<Tabs.Panel value="api">
<TabsPanel value="api">
<Stack gap="sm">
<TextInput
label="XC Password"
@ -524,7 +410,7 @@ const User = ({ user = null, isOpen, onClose }) => {
</Stack>
)}
</Stack>
</Tabs.Panel>
</TabsPanel>
</Tabs>
<Group justify="flex-end" mt="md">

View file

@ -1,26 +1,12 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import { Button, Checkbox, Flex, Modal, Space, TextInput } from '@mantine/core';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Space,
} from '@mantine/core';
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
});
addUserAgent,
getResolver,
updateUserAgent,
} from '../../utils/forms/UserAgentUtils.js';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(
@ -42,14 +28,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
await updateUserAgent(userAgent.id, values);
} else {
await API.addUserAgent(values);
await addUserAgent(values);
}
reset();

View file

@ -18,6 +18,12 @@ vi.mock('../../../utils/notificationUtils.js', () => ({
updateNotification: vi.fn(),
}));
vi.mock('../../../api', () => ({
default: {
getCurrentProgramForEpg: vi.fn().mockResolvedValue(null),
},
}));
vi.mock('../../../utils/forms/ChannelUtils.js', () => ({
addChannel: vi.fn(),
clearChannelOverrides: vi.fn(),
@ -117,8 +123,25 @@ vi.mock('@hookform/resolvers/yup', () => ({
// lucide-react mock
vi.mock('lucide-react', () => ({
Blocks: () => <svg data-testid="icon-blocks" />,
ChartLine: () => <svg data-testid="icon-chart-line" />,
Database: () => <svg data-testid="icon-database" />,
Download: () => <svg data-testid="icon-download" />,
FileImage: () => <svg data-testid="icon-file-image" />,
LayoutGrid: () => <svg data-testid="icon-layout-grid" />,
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Logs: () => <svg data-testid="icon-logs" />,
MonitorCog: () => <svg data-testid="icon-monitor-cog" />,
Package: () => <svg data-testid="icon-package" />,
Play: () => <svg data-testid="icon-play" />,
PlugZap: () => <svg data-testid="icon-plug-zap" />,
Radio: () => <svg data-testid="icon-radio" />,
Settings: () => <svg data-testid="icon-settings" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
Undo2: () => <svg data-testid="icon-undo" />,
User: () => <svg data-testid="icon-user" />,
Video: () => <svg data-testid="icon-video" />,
Webhook: () => <svg data-testid="icon-webhook" />,
X: () => <svg data-testid="icon-x" />,
Zap: () => <svg data-testid="icon-zap" />,
}));

View file

@ -0,0 +1,213 @@
/**
* Tests for the EPG preview fetch logic used in Channel.jsx.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
vi.mock('../../../api', () => ({
default: {
getCurrentProgramForEpg: vi.fn(),
},
}));
import API from '../../../api';
import { useEpgPreview } from '../../../hooks/useEpgPreview';
describe('Channel EPG preview hook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not call API when epg_data_id is empty', () => {
const { result } = renderHook(() => useEpgPreview(''));
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
expect(result.current.currentProgram).toBeNull();
});
it('does not call API when epg_data_id is "0"', () => {
renderHook(() => useEpgPreview('0'));
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
});
it('calls API when epg_data_id is set', async () => {
const program = { title: 'Live News', epg_data_id: 10 };
API.getCurrentProgramForEpg.mockResolvedValue(program);
const { result } = renderHook(() => useEpgPreview(10));
await waitFor(() => {
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith(10);
expect(result.current.currentProgram).toEqual(program);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
it('shows loading state while API call is in flight', () => {
API.getCurrentProgramForEpg.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useEpgPreview(10));
expect(result.current.isLoadingProgram).toBe(true);
expect(result.current.hasFetchedProgram).toBe(false);
});
it('shows null program after successful fetch with null response', async () => {
API.getCurrentProgramForEpg.mockResolvedValue(null);
const { result } = renderHook(() => useEpgPreview(10));
await waitFor(() => {
expect(result.current.currentProgram).toBeNull();
expect(result.current.hasFetchedProgram).toBe(true);
});
});
it('retries when response has parsing=true', async () => {
vi.useFakeTimers();
let callCount = 0;
API.getCurrentProgramForEpg.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve({ parsing: true });
return Promise.resolve({ title: 'Parsed Show', epg_data_id: 10 });
});
const { result } = renderHook(() => useEpgPreview(10));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(callCount).toBe(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(3100);
});
expect(callCount).toBe(2);
expect(result.current.currentProgram).toEqual({
title: 'Parsed Show',
epg_data_id: 10,
});
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
it('retries on API error and eventually succeeds', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'error').mockImplementation(() => {});
let callCount = 0;
API.getCurrentProgramForEpg.mockImplementation(() => {
callCount++;
if (callCount <= 2) return Promise.reject(new Error('Network error'));
return Promise.resolve({ title: 'Recovered Show', epg_data_id: 10 });
});
const { result } = renderHook(() => useEpgPreview(10));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(callCount).toBe(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(3100);
});
expect(callCount).toBe(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(4600);
});
expect(callCount).toBe(3);
expect(result.current.currentProgram).toEqual({
title: 'Recovered Show',
epg_data_id: 10,
});
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
it('sets null program after exhausting all retries on error', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'error').mockImplementation(() => {});
API.getCurrentProgramForEpg.mockRejectedValue(new Error('Persistent error'));
const { result } = renderHook(() => useEpgPreview(10));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Backoff ramps to a 15s cap; advance past the 180s deadline that bounds the loop.
for (let elapsed = 0; elapsed <= 190000; elapsed += 15100) {
await act(async () => {
await vi.advanceTimersByTimeAsync(15100);
});
}
expect(result.current.currentProgram).toBeNull();
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
it('cancels fetch on unmount', async () => {
let resolvePromise;
API.getCurrentProgramForEpg.mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve;
})
);
const { unmount, result } = renderHook(() => useEpgPreview(10));
expect(result.current.isLoadingProgram).toBe(true);
unmount();
await act(async () => {
resolvePromise({ title: 'Late Show', epg_data_id: 10 });
});
});
it('changing epg_data_id cancels previous fetch and starts new one', async () => {
let resolve1;
const promise1 = new Promise((resolve) => {
resolve1 = resolve;
});
API.getCurrentProgramForEpg
.mockReturnValueOnce(promise1)
.mockResolvedValueOnce({ title: 'New Show', epg_data_id: 20 });
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 10 },
});
expect(result.current.isLoadingProgram).toBe(true);
rerender({ id: 20 });
await waitFor(() => {
expect(result.current.currentProgram).toEqual({
title: 'New Show',
epg_data_id: 20,
});
});
await act(async () => {
resolve1({ title: 'Old Show', epg_data_id: 10 });
});
expect(result.current.currentProgram).toEqual({
title: 'New Show',
epg_data_id: 20,
});
});
});

View file

@ -0,0 +1,304 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Dependency mocks
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteRecordingById: vi.fn(),
deleteSeriesAndRule: vi.fn(),
}));
vi.mock('../../../utils/guideUtils.js', () => ({
deleteSeriesRuleByTvgId: vi.fn(),
}));
vi.mock('../SeriesRuleEditorModal.jsx', () => ({
default: ({ opened, onClose }) =>
opened ? (
<div data-testid="series-rule-editor-modal">
<button data-testid="series-rule-editor-close" onClick={onClose}>
Close Editor
</button>
</div>
) : null,
}));
vi.mock('@mantine/core', async () => ({
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Flex: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled, loading, color, variant }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-color={color}
data-variant={variant}
>
{children}
</button>
),
Anchor: ({ children, onClick }) => (
<a data-testid="anchor" onClick={onClick}>
{children}
</a>
),
}));
// Imports after mocks
import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
import {
deleteRecordingById,
deleteSeriesAndRule,
} from '../../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
// Helpers
const makeProgram = (overrides = {}) => ({
tvg_id: 'tvg-1',
title: 'Test Show',
...overrides,
});
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
...overrides,
});
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
program: makeProgram(),
recording: null,
existingRuleMode: null,
existingRule: null,
onRecordOne: vi.fn(),
onRecordSeriesAll: vi.fn(),
onRecordSeriesNew: vi.fn(),
onExistingRuleModeChange: vi.fn(),
...overrides,
});
// ProgramRecordingModal
describe('ProgramRecordingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined);
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<ProgramRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<ProgramRecordingModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<ProgramRecordingModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// handleRemoveRecording
describe('handleRemoveRecording', () => {
it('calls deleteRecordingById with the recording id', async () => {
const recording = makeRecording();
render(<ProgramRecordingModal {...defaultProps({ recording })} />);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
});
});
it('calls onClose after deleting recording', async () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('still calls onClose when deleteRecordingById throws', async () => {
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// handleRemoveSeries
describe('handleRemoveSeries', () => {
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' });
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ program, recording, existingRuleMode: 'series' })}
/>
);
fireEvent.click(screen.getByText(/Remove this series/i));
await waitFor(() => {
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
tvg_id: 'tvg-2',
title: 'My Series',
});
});
});
it('calls onClose after removing series', async () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ onClose, recording, existingRuleMode: 'series' })}
/>
);
fireEvent.click(screen.getByText(/Remove this series/i));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// handleRemoveSeriesRule
describe('handleRemoveSeriesRule', () => {
it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => {
const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' });
render(
<ProgramRecordingModal
{...defaultProps({ program, existingRuleMode: 'rule' })}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith(
'tvg-3',
'Rule Show'
);
});
});
it('calls onExistingRuleModeChange(null) after removing rule', async () => {
const onExistingRuleModeChange = vi.fn();
render(
<ProgramRecordingModal
{...defaultProps({
existingRuleMode: 'rule',
onExistingRuleModeChange,
})}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(onExistingRuleModeChange).toHaveBeenCalledWith(null);
});
});
it('calls onClose after removing rule', async () => {
const onClose = vi.fn();
render(
<ProgramRecordingModal
{...defaultProps({ existingRuleMode: 'rule', onClose })}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Record actions
describe('record actions', () => {
it('calls onRecordOne when "Record Once" is clicked', () => {
const onRecordOne = vi.fn();
render(<ProgramRecordingModal {...defaultProps({ onRecordOne })} />);
fireEvent.click(screen.getByText('Just this one'));
expect(onRecordOne).toHaveBeenCalled();
});
it('calls onRecordSeriesAll when "Record All" is clicked', () => {
const onRecordSeriesAll = vi.fn();
render(
<ProgramRecordingModal {...defaultProps({ onRecordSeriesAll })} />
);
fireEvent.click(screen.getByText('Every episode'));
expect(onRecordSeriesAll).toHaveBeenCalled();
});
it('calls onRecordSeriesNew when "Record New" is clicked', () => {
const onRecordSeriesNew = vi.fn();
render(
<ProgramRecordingModal {...defaultProps({ onRecordSeriesNew })} />
);
fireEvent.click(screen.getByText('New episodes only'));
expect(onRecordSeriesNew).toHaveBeenCalled();
});
});
// SeriesRuleEditorModal
describe('SeriesRuleEditorModal', () => {
it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
render(
<ProgramRecordingModal
{...defaultProps({
existingRuleMode: 'rule',
existingRule: { id: 1 },
})}
/>
);
fireEvent.click(screen.getByText(/Customize rule/i));
expect(
screen.getByTestId('series-rule-editor-modal')
).toBeInTheDocument();
});
it('closes SeriesRuleEditorModal when its onClose is called', () => {
render(
<ProgramRecordingModal
{...defaultProps({
existingRuleMode: 'rule',
existingRule: { id: 1 },
})}
/>
);
fireEvent.click(screen.getByText(/Customize rule/i));
fireEvent.click(screen.getByTestId('series-rule-editor-close'));
expect(
screen.queryByTestId('series-rule-editor-modal')
).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,580 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RecordingModal from '../Recording';
// Store mocks
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/dateTimeUtils.js', () => ({
RECURRING_DAY_OPTIONS: [
{ value: 'mon', label: 'Monday' },
{ value: 'tue', label: 'Tuesday' },
],
toTimeString: vi.fn((val) => val),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
buildRecurringPayload: vi.fn((v) => v),
buildSinglePayload: vi.fn((v) => v),
createRecording: vi.fn(),
createRecurringRule: vi.fn(),
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]),
numberedChannelLabel: vi.fn((item) =>
item.channel_number ? `${item.channel_number} - ${item.name}` : item.name
),
getChannelsSummary: vi.fn(),
getRecurringFormDefaults: vi.fn(() => ({
channel_id: '',
rule_name: '',
days_of_week: [],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
})),
getSingleFormDefaults: vi.fn(() => ({
channel_id: 'ch-1',
start_time: new Date('2024-06-01T10:00:00'),
end_time: new Date('2024-06-01T11:00:00'),
})),
recurringFormValidators: {},
singleFormValidators: {},
timeChange: vi.fn((fn) => (e) => fn(e.target.value)),
updateRecording: vi.fn(),
}));
// @mantine/form
vi.mock('@mantine/form', () => ({
useForm: vi.fn(({ initialValues }) => {
const values = { ...initialValues };
return {
values,
key: vi.fn((k) => k),
getInputProps: vi.fn((k) => ({
name: k,
value: values[k] ?? '',
onChange: vi.fn(),
})),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler(values);
}),
reset: vi.fn(),
setValues: vi.fn((newVals) => Object.assign(values, newVals)),
setFieldValue: vi.fn((k, v) => {
values[k] = v;
}),
validateField: vi.fn(),
};
}),
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Alert: ({ children, title }) => (
<div data-testid="alert">
<span data-testid="alert-title">{title}</span>
{children}
</div>
),
Button: ({ children, onClick, loading, type }) => (
<button
type={type}
onClick={onClick}
data-loading={loading}
disabled={loading}
>
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size, color }) => (
<span data-testid="loader" data-size={size} data-color={color} />
),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input
data-testid={`multiselect-${label}`}
placeholder={placeholder}
{...props}
/>
</div>
),
SegmentedControl: ({ value, onChange, data, disabled }) => (
<div data-testid="segmented-control">
{data.map((item) => (
<button
key={item.value}
data-testid={`mode-${item.value}`}
data-active={value === item.value}
disabled={disabled}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
))}
</div>
),
Select: ({ label, disabled, rightSection, data, ...props }) => (
<div>
<label>{label}</label>
{rightSection}
<select data-testid={`select-${label}`} disabled={disabled} {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
TextInput: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input
data-testid={`textinput-${label}`}
placeholder={placeholder}
{...props}
/>
</div>
),
}));
// @mantine/dates
vi.mock('@mantine/dates', () => ({
DatePickerInput: ({ label, value, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`datepicker-${label}`}
value={value ? (value.toISOString?.() ?? value) : ''}
onChange={(e) =>
onChange(e.target.value ? new Date(e.target.value) : null)
}
/>
</div>
),
DateTimePicker: ({ label, ...props }) => (
<div>
<label>{label}</label>
<input data-testid={`datetimepicker-${label}`} {...props} />
</div>
),
TimeInput: ({ label, value, onChange, onBlur }) => (
<div>
<label>{label}</label>
<input
data-testid={`timeinput-${label}`}
value={value ?? ''}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
CircleAlert: () => <svg data-testid="icon-circle-alert" />,
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels';
import { showNotification } from '../../../utils/notificationUtils.js';
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
const setupStoreMock = () => {
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({
fetchRecordings: mockFetchRecordings,
fetchRecurringRules: mockFetchRecurringRules,
})
);
return { mockFetchRecordings, mockFetchRecurringRules };
};
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
start_time: '2024-06-01T10:00:00Z',
end_time: '2024-06-01T11:00:00Z',
custom_properties: { program: { title: 'Test Show' } },
...overrides,
});
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
describe('RecordingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
{ id: 'ch-1', name: 'HBO', channel_number: 501 },
]);
vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined);
vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined);
vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined);
setupStoreMock();
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when isOpen is false', () => {
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Alert
describe('scheduling conflict alert', () => {
it('renders the scheduling conflicts alert', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert-title')).toHaveTextContent(
'Scheduling Conflicts'
);
});
});
// Mode switching
describe('mode switching', () => {
it('defaults to "single" mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('mode-single')).toHaveAttribute(
'data-active',
'true'
);
});
it('switches to recurring mode when Recurring button clicked', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByTestId('mode-recurring')).toHaveAttribute(
'data-active',
'true'
);
});
it('shows DateTimePicker fields in single mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument();
expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument();
});
it('shows recurring fields when in recurring mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
});
it('disables mode toggle when editing an existing recording', () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
expect(screen.getByTestId('mode-single')).toBeDisabled();
expect(screen.getByTestId('mode-recurring')).toBeDisabled();
});
it('shows "Schedule Recording" submit button in single mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByText('Schedule Recording')).toBeInTheDocument();
});
it('shows "Save Rule" submit button in recurring mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByText('Save Rule')).toBeInTheDocument();
});
});
// Channel loading
describe('channel loading', () => {
it('calls getChannelsSummary when modal opens', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
});
});
it('calls sortedChannelOptions with loaded channels', async () => {
const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }];
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels);
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
channels,
RecordingUtils.numberedChannelLabel
);
});
});
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
[],
RecordingUtils.numberedChannelLabel
);
});
});
it('does not load channels when modal is closed', () => {
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
});
});
// Single form submit (create)
describe('single mode create recording', () => {
it('calls buildSinglePayload with form values on submit', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled();
});
});
it('calls createRecording when no existing recording', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.createRecording).toHaveBeenCalled();
});
});
it('shows "Recording scheduled" notification after successful create', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recording scheduled',
color: 'green',
})
);
});
});
it('calls fetchRecordings after successful create', async () => {
const { mockFetchRecordings } = setupStoreMock();
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('calls onClose after successful create', async () => {
const onClose = vi.fn();
render(<RecordingModal isOpen onClose={onClose} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not call showNotification when createRecording throws', async () => {
vi.mocked(RecordingUtils.createRecording).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Single form submit (update)
describe('single mode update recording', () => {
it('calls updateRecording when editing an existing recording', async () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.updateRecording).toHaveBeenCalledWith(
'rec-1',
expect.anything()
);
});
});
it('does not call createRecording when updating', async () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.createRecording).not.toHaveBeenCalled();
});
});
it('shows "Recording updated" notification after successful update', async () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recording updated',
color: 'green',
})
);
});
});
});
// Recurring form submit
describe('recurring mode create rule', () => {
it('calls buildRecurringPayload with form values on submit', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled();
});
});
it('calls createRecurringRule on submit', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(RecordingUtils.createRecurringRule).toHaveBeenCalled();
});
});
it('calls fetchRecurringRules and fetchRecordings on success', async () => {
const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock();
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('shows "Recurring rule saved" notification on success', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule saved',
color: 'green',
})
);
});
});
it('calls onClose after successful recurring submit', async () => {
const onClose = vi.fn();
render(<RecordingModal isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not show notification when createRecurringRule throws', async () => {
vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Form initialization
describe('form initialization', () => {
it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => {
const recording = makeRecording();
const channel = makeChannel();
render(
<RecordingModal
isOpen
recording={recording}
channel={channel}
onClose={vi.fn()}
/>
);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
recording,
channel
);
});
it('calls getSingleFormDefaults with null when opening for new recording', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
null,
null
);
});
it('calls getRecurringFormDefaults with channel on open', () => {
const channel = makeChannel();
render(<RecordingModal isOpen channel={channel} onClose={vi.fn()} />);
expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(
channel
);
});
});
// Close / reset
describe('close and reset', () => {
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<RecordingModal isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,806 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../store/useVideoStore.jsx', () => ({
default: Object.assign(vi.fn(), {
getState: vi.fn(() => ({ showVideo: vi.fn() })),
}),
}));
// Utility mocks
vi.mock('../../../utils/dateTimeUtils.js', () => ({
format: vi.fn(),
isAfter: vi.fn(),
isBefore: vi.fn(),
useDateTimeFormat: vi.fn(),
useTimeHelpers: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteRecordingById: vi.fn(),
getChannelLogoUrl: vi.fn(),
getPosterUrl: vi.fn(),
getRecordingUrl: vi.fn(),
getSeasonLabel: vi.fn(),
getShowVideoUrl: vi.fn(),
runComSkip: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({
getChannel: vi.fn(),
getRating: vi.fn(),
getStatRows: vi.fn(),
getUpcomingEpisodes: vi.fn(),
refreshArtwork: vi.fn(),
updateRecordingMetadata: vi.fn(),
}));
vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
// lucide-react
vi.mock('lucide-react', () => ({
Check: () => <svg data-testid="icon-check" />,
Pencil: () => <svg data-testid="icon-pencil" />,
RefreshCcw: () => <svg data-testid="icon-refresh" />,
X: () => <svg data-testid="icon-x" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-size={size}
data-variant={variant}
data-color={color}
data-loading={loading}
>
{children}
</button>
),
Card: ({ children, onClick, style }) => (
<div data-testid="card" onClick={onClick} style={style}>
{children}
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Image: ({ src, alt, fallbackSrc }) => (
<img src={src} alt={alt} data-fallback={fallbackSrc} />
),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw, style }) => (
<span data-size={size} data-color={c} data-fw={fw} style={style}>
{children}
</span>
),
Textarea: ({ label, value, onChange, placeholder, ...props }) => (
<div>
<label>{label}</label>
<textarea
data-testid={`textarea-${label ?? placeholder ?? 'unknown'}`}
value={value ?? ''}
onChange={onChange}
{...props}
/>
</div>
),
TextInput: ({ label, value, onChange, placeholder, ...props }) => (
<div>
<label />
<input
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
value={value}
onChange={onChange}
placeholder={placeholder}
style={props.style}
/>
</div>
),
}));
// Imports after mocks
import RecordingDetailsModal from '../RecordingDetailsModal';
import useChannelsStore from '../../../store/channels.jsx';
import useVideoStore from '../../../store/useVideoStore.jsx';
import {
format,
isAfter,
isBefore,
useDateTimeFormat,
useTimeHelpers,
} from '../../../utils/dateTimeUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
import * as RecordingDetailsModalUtils from '../../../utils/forms/RecordingDetailsModalUtils.js';
import dayjs from 'dayjs';
// Helpers
const PAST = '2020-01-01T10:00:00Z';
const FUTURE = '2099-01-01T10:00:00Z';
const NOW = '2024-06-01T12:00:00Z';
const makeMoment = (isoString) => {
const d = dayjs(isoString);
return {
isAfter: (other) => d.isAfter(other?._d ?? other),
isBefore: (other) => d.isBefore(other?._d ?? other),
format: vi.fn((fmt) => d.format(fmt)),
_d: d.toDate(),
};
};
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
channel: 'ch-1',
start_time: PAST,
end_time: PAST,
_group_count: 1,
custom_properties: {
status: 'completed',
file_url: '/recordings/test.ts',
program: {
title: 'Test Show',
description: 'A test description',
sub_title: 'Pilot',
},
...overrides.custom_properties,
},
...overrides,
});
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
const mockShowVideo = vi.fn();
const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
const nowMoment = makeMoment(now);
const startMoment = makeMoment(recording.start_time);
const endMoment = makeMoment(recording.end_time);
vi.mocked(useTimeHelpers).mockReturnValue({
toUserTime: (iso) => {
if (iso === recording.start_time) return startMoment;
if (iso === recording.end_time) return endMoment;
return makeMoment(iso);
},
userNow: () => nowMoment,
});
vi.mocked(useDateTimeFormat).mockReturnValue({
timeFormat: 'HH:mm',
dateFormat: 'MM/DD',
});
vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt));
vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b));
vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b));
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ recordings: [recording] })
);
mockShowVideo.mockReset();
vi.mocked(useVideoStore).mockImplementation((sel) =>
sel({ showVideo: mockShowVideo })
);
useVideoStore.getState = vi.fn(() => ({ showVideo: mockShowVideo }));
vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(
'/recordings/test.ts'
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue(null);
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([]);
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([]);
vi.mocked(RecordingDetailsModalUtils.getChannel).mockResolvedValue(null);
};
const defaultProps = () => ({
opened: true,
onClose: vi.fn(),
recording: makeRecording(),
channel: makeChannel(),
posterUrl: '/poster.jpg',
onWatchLive: vi.fn(),
onWatchRecording: vi.fn(),
env_mode: 'production',
onEdit: vi.fn(),
});
// Tests
describe('RecordingDetailsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
vi.mocked(
RecordingDetailsModalUtils.updateRecordingMetadata
).mockResolvedValue(undefined);
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(
undefined
);
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when opened is false', () => {
render(<RecordingDetailsModal {...defaultProps()} opened={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('returns null when recording is not provided', () => {
const { container } = render(
<RecordingDetailsModal {...defaultProps()} recording={null} />
);
expect(container.firstChild).toBeNull();
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<RecordingDetailsModal {...defaultProps()} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Content rendering
describe('content rendering', () => {
it('renders the recording title', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
});
it('renders "Custom Recording" when no program title', () => {
const recording = makeRecording({
custom_properties: { status: 'completed', program: {} },
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
});
it('renders the description', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('A test description')).toBeInTheDocument();
});
it('renders the poster image', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByAltText('Test Show')).toHaveAttribute(
'src',
'/poster.jpg'
);
});
it('renders the season/episode label when present', () => {
const seriesRecording = makeRecording({ _group_count: 2 });
const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[mockEpisode]
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
render(
<RecordingDetailsModal
{...defaultProps()}
recording={seriesRecording}
/>
);
expect(screen.getByText('S01E02')).toBeInTheDocument();
});
it('renders rating when present', () => {
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue('TV-MA');
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('TV-MA')).toBeInTheDocument();
});
it('renders stat rows when available', () => {
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([
['Video Codec', 'H264'],
['Audio Codec', 'AAC'],
]);
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('Stream Stats')).toBeInTheDocument();
expect(screen.getByText('Video Codec')).toBeInTheDocument();
expect(screen.getByText('H264')).toBeInTheDocument();
});
});
// Watch buttons
describe('Watch button', () => {
it('renders Watch button for completed recording', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('Watch')).toBeInTheDocument();
});
it('calls onWatchRecording when Watch is clicked', () => {
const onWatchRecording = vi.fn();
render(
<RecordingDetailsModal
{...defaultProps()}
onWatchRecording={onWatchRecording}
/>
);
fireEvent.click(screen.getByText('Watch'));
expect(onWatchRecording).toHaveBeenCalled();
});
it('Watch button is disabled when no file_url', () => {
const recording = makeRecording({
custom_properties: {
status: 'completed',
program: { title: 'Test Show' },
},
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Watch')).toBeDisabled();
});
});
describe('Watch Live button', () => {
it('renders Watch Live button for in-progress recording', () => {
const recording = makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: {
status: 'recording',
file_url: '/f.ts',
program: { title: 'Live Show' },
},
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Watch Live')).toBeInTheDocument();
});
it('calls onWatchLive when Watch Live is clicked', () => {
const recording = makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: {
status: 'recording',
file_url: '/f.ts',
program: { title: 'Live Show' },
},
});
setupMocks({ recording });
const onWatchLive = vi.fn();
render(
<RecordingDetailsModal
{...defaultProps()}
recording={recording}
onWatchLive={onWatchLive}
/>
);
fireEvent.click(screen.getByText('Watch Live'));
expect(onWatchLive).toHaveBeenCalled();
});
});
// Edit button
describe('Edit button', () => {
it('renders the Edit button', () => {
const futureRecording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
});
setupMocks({ recording: futureRecording });
render(
<RecordingDetailsModal
{...defaultProps()}
recording={futureRecording}
/>
);
expect(screen.getByText('Edit')).toBeInTheDocument();
});
it('calls onEdit when Edit is clicked', () => {
const futureRecording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
});
setupMocks({ recording: futureRecording });
const onEdit = vi.fn();
render(
<RecordingDetailsModal
{...defaultProps()}
recording={futureRecording}
onEdit={onEdit}
/>
);
fireEvent.click(screen.getByText('Edit'));
expect(onEdit).toHaveBeenCalledWith(futureRecording);
});
});
// Inline editing
describe('inline metadata editing', () => {
it('shows edit inputs when pencil icon is clicked', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(
screen.getByTestId('textinput-Recording title')
).toBeInTheDocument();
});
it('pre-fills title input with current title', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textinput-Recording title')).toHaveValue(
'Test Show'
);
});
it('pre-fills description input with current description', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue(
'A test description'
);
});
it('cancels editing when X icon is clicked', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(
screen.getByTestId('textinput-Recording title')
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
expect(
screen.queryByTestId('textinput-Recording title')
).not.toBeInTheDocument();
});
it('calls updateRecordingMetadata with new title and description on save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: 'New Title' },
});
fireEvent.change(screen.getByTestId('textarea-Description (optional)'), {
target: { value: 'New Description' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(
RecordingDetailsModalUtils.updateRecordingMetadata
).toHaveBeenCalledWith(makeRecording(), 'New Title', 'New Description');
});
});
it('shows saved title optimistically after save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: 'Updated Title' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(screen.getByText('Updated Title')).toBeInTheDocument();
});
});
it('shows success notification after save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'green' })
);
});
});
it('does not show notification when updateRecordingMetadata throws', async () => {
vi.mocked(
RecordingDetailsModalUtils.updateRecordingMetadata
).mockRejectedValue(new Error('fail'));
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('uses "Custom Recording" when title is cleared on save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: '' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
});
});
});
// Refresh artwork
describe('refresh artwork', () => {
it('calls refreshArtwork with recording id on click', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Refresh artwork'));
await waitFor(() => {
expect(RecordingDetailsModalUtils.refreshArtwork).toHaveBeenCalledWith(
'rec-1'
);
});
});
it('shows success notification after refreshArtwork', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Refresh artwork'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'blue.5' })
);
});
});
it('does not show notification when refreshArtwork throws', async () => {
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockRejectedValue(
new Error('fail')
);
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Refresh artwork'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// ComSkip
describe('run comskip', () => {
it('calls runComSkip with the recording', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
makeRecording()
);
});
});
it('shows success notification after runComSkip', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: expect.any(String) })
);
});
});
it('does not show notification when runComSkip throws', async () => {
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
new Error('fail')
);
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('does not show Remove commercials when comskip is completed', () => {
const recording = makeRecording({
custom_properties: {
status: 'completed',
file_url: '/f.ts',
comskip: { status: 'completed' },
program: { title: 'Test Show' },
},
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
});
});
// Series / episodes
describe('series group', () => {
const makeSeriesRecording = () =>
makeRecording({
_group_count: 3,
custom_properties: {
status: 'scheduled',
program: { title: 'Series Show', tvg_id: 'series-1' },
},
start_time: FUTURE,
end_time: FUTURE,
});
const makeEpisode = (id = 'ep-1') => ({
id,
channel: 'ch-1',
start_time: FUTURE,
end_time: FUTURE,
custom_properties: {
status: 'scheduled',
program: { title: 'Episode', sub_title: `Sub ${id}` },
},
});
it('renders upcoming episodes list when isSeriesGroup is true', () => {
const recording = makeSeriesRecording();
const episode = makeEpisode();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[episode]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Sub ep-1')).toBeInTheDocument();
});
it('shows "No upcoming episodes" when episode list is empty for series', () => {
const recording = makeSeriesRecording();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText(/no upcoming episodes/i)).toBeInTheDocument();
});
it('opens child modal when an episode card is clicked', async () => {
const recording = makeSeriesRecording();
const episode = makeEpisode();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[episode]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
const episodeCards = screen.getAllByTestId('card');
fireEvent.click(episodeCards[episodeCards.length - 1]);
await waitFor(() => {
expect(screen.getAllByTestId('modal').length).toBeGreaterThanOrEqual(1);
});
});
it('calls deleteRecordingById when episode remove is clicked', async () => {
const recording = makeSeriesRecording();
const episode = makeEpisode();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[episode]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
'ep-1'
);
});
});
});
// safeRecording merging
describe('safeRecording store merge', () => {
it('picks updated recording from store while preserving _group_count', () => {
const original = makeRecording({ _group_count: 3 });
const storeVersion = {
...makeRecording(),
id: 'rec-1',
custom_properties: {
...makeRecording().custom_properties,
program: { title: 'Updated Title' },
},
};
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ recordings: [storeVersion] })
);
render(
<RecordingDetailsModal {...defaultProps()} recording={original} />
);
expect(screen.getByText(/Updated Title/i)).toBeInTheDocument();
});
});
// Optimistic state reset
describe('optimistic state reset on recording change', () => {
it('resets saved title when recording id changes', async () => {
const { rerender } = render(
<RecordingDetailsModal {...defaultProps()} />
);
// Save a new title
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: 'Custom' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() =>
expect(screen.getByText('Custom')).toBeInTheDocument()
);
// Switch to a different recording
const newRecording = makeRecording({ id: 'rec-2' });
setupMocks({ recording: newRecording });
rerender(
<RecordingDetailsModal {...defaultProps()} recording={newRecording} />
);
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,985 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/dateTimeUtils.js', () => ({
format: vi.fn((moment, fmt) => `formatted-${fmt}`),
getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
RECURRING_DAY_OPTIONS: [
{ value: 'mon', label: 'Monday' },
{ value: 'tue', label: 'Tuesday' },
{ value: 'wed', label: 'Wednesday' },
],
toDate: vi.fn((val) => new Date(val)),
toTimeString: vi.fn((val) => val),
useDateTimeFormat: vi.fn(),
useTimeHelpers: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteRecordingById: vi.fn(),
}));
vi.mock('../../../utils/forms/RecurringRuleModalUtils.js', () => ({
deleteRecurringRuleById: vi.fn(),
getFormDefaults: vi.fn(),
getUpcomingOccurrences: vi.fn(),
updateRecurringRule: vi.fn(),
updateRecurringRuleEnabled: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
getChannelsSummary: vi.fn(),
getRecurringFormDefaults: vi.fn(),
recurringFormValidators: {},
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: 'HBO' }]),
}));
// @mantine/form
vi.mock('@mantine/form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ initialValues }) => {
const [values, setValuesState] = React.useState({ ...initialValues });
return {
values,
key: vi.fn((k) => k),
getInputProps: vi.fn((k) => ({
name: k,
value: values[k] ?? '',
onChange: vi.fn((e) => {
const v = e?.currentTarget?.value ?? e?.target?.value ?? e;
setValuesState((prev) => ({ ...prev, [k]: v }));
}),
})),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler(values);
}),
reset: vi.fn(() => setValuesState({ ...initialValues })),
setValues: vi.fn((newVals) =>
setValuesState((prev) => ({ ...prev, ...newVals }))
),
setFieldValue: vi.fn((k, v) =>
setValuesState((prev) => ({ ...prev, [k]: v }))
),
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({
children,
onClick,
loading,
type,
variant,
color,
size,
disabled,
}) => (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
data-size={size}
>
{children}
</button>
),
Card: ({ children }) => <div data-testid="occurrence-card">{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, data, ...props }) => (
<div>
<label>{label}</label>
<select data-testid={`multiselect-${label}`} multiple {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Select: ({ label, data, ...props }) => (
<div>
<label>{label}</label>
<select data-testid={`select-${label}`} {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ checked, onChange, label, disabled }) => (
<label>
<input
data-testid="switch"
type="checkbox"
checked={checked ?? false}
disabled={disabled}
onChange={(e) => {
// Mirror the event shape the component expects: event.currentTarget.checked
onChange({ currentTarget: { checked: e.target.checked } });
}}
/>
<span>{label}</span>
</label>
),
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
TextInput: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
placeholder={placeholder}
{...props}
/>
</div>
),
}));
// @mantine/dates
vi.mock('@mantine/dates', () => ({
DatePickerInput: ({ label, value, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`datepicker-${label}`}
value={value ? (value.toISOString?.() ?? String(value)) : ''}
onChange={(e) =>
onChange(e.target.value ? new Date(e.target.value) : null)
}
/>
</div>
),
TimeInput: ({ label, value, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`timeinput-${label}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
</div>
),
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels.jsx';
import {
format,
toTimeString,
useDateTimeFormat,
useTimeHelpers,
} from '../../../utils/dateTimeUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import { deleteRecordingById } from '../../../utils/cards/RecordingCardUtils.js';
import * as RecurringRuleModalUtils from '../../../utils/forms/RecurringRuleModalUtils.js';
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
import { useForm } from '@mantine/form';
import RecurringRuleModal from '../RecurringRuleModal';
import dayjs from 'dayjs';
// Helpers
const FUTURE = '2099-01-01T10:00:00Z';
const NOW = '2024-06-01T12:00:00Z';
const makeMoment = (isoString) => {
const d = dayjs(isoString);
return {
isAfter: (other) => d.isAfter(other?._d ?? other),
isBefore: (other) => d.isBefore(other?._d ?? other),
format: vi.fn((fmt) => `formatted-${fmt}`),
_d: d.toDate(),
};
};
const makeRule = (overrides = {}) => ({
id: 'rule-1',
name: 'Morning News',
channel: 'ch-1',
channel_id: 'ch-1',
days_of_week: ['mon', 'tue'],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
enabled: true,
...overrides,
});
const makeOccurrence = (id = 'occ-1') => ({
id,
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { program: { title: 'Episode' } },
});
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { program: { title: 'Morning News' } },
...overrides,
});
const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
const nowMoment = makeMoment(NOW);
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({
recurringRules: rule ? [rule] : [],
fetchRecurringRules: mockFetchRecurringRules,
recordings: [],
})
);
vi.mocked(useTimeHelpers).mockReturnValue({
toUserTime: (iso) => makeMoment(iso),
userNow: () => nowMoment,
});
vi.mocked(useDateTimeFormat).mockReturnValue({
timeFormat: 'HH:mm',
dateFormat: 'MM/DD',
});
vi.mocked(format).mockImplementation((moment, fmt) => `formatted-${fmt}`);
vi.mocked(RecordingUtils.getRecurringFormDefaults).mockReturnValue({
channel_id: '',
rule_name: '',
days_of_week: [],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
enabled: rule?.enabled ?? true,
});
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
makeChannel(),
]);
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
channel_id: rule?.channel_id ?? '',
rule_name: rule?.name ?? '',
days_of_week: rule?.days_of_week ?? [],
start_date: rule?.start_date ?? new Date('2024-01-01'),
end_date: rule?.end_date ?? null,
start_time: rule?.start_time ?? '08:00',
end_time: rule?.end_time ?? '09:00',
enabled: rule?.enabled ?? true,
});
vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(
occurrences
);
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(
undefined
);
vi.mocked(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).mockResolvedValue(undefined);
vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(
undefined
);
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
return { mockFetchRecurringRules };
};
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
ruleId: 'rule-1',
recording: null,
onEditOccurrence: vi.fn(),
...overrides,
});
// Tests
describe('RecurringRuleModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when opened is false', () => {
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('uses rule name as modal title', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Morning News'
);
});
it('falls back to "Recurring Rule" when rule has no name', () => {
setupMocks({ rule: makeRule({ name: '' }) });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Recurring Rule'
);
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Missing rule fallback
describe('missing rule fallback', () => {
beforeEach(() => {
setupMocks({ rule: null });
});
it('renders fallback message when rule does not exist', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(
screen.getByText(
'The recurring rule for this recording no longer exists.'
)
).toBeInTheDocument();
});
it('shows Delete Recording button when sourceRecording is provided', () => {
render(
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
expect(screen.getByText('Delete Recording')).toBeInTheDocument();
});
it('does not show Delete Recording button when sourceRecording is null', () => {
render(<RecurringRuleModal {...defaultProps()} recording={null} />);
expect(screen.queryByText('Delete Recording')).not.toBeInTheDocument();
});
it('calls deleteRecordingById when Delete Recording is confirmed', async () => {
const onClose = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
recording={makeRecording()}
/>
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
});
});
it('shows "Recording deleted" notification after deleting orphaned recording', async () => {
render(
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recording deleted',
color: 'green',
})
);
});
});
it('calls onClose after deleting orphaned recording', async () => {
const onClose = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
recording={makeRecording()}
/>
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('calls onClose when Cancel is clicked in fallback', () => {
const onClose = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
recording={makeRecording()}
/>
);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
it('does not show notification when deleteRecordingById throws', async () => {
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
render(
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Form fields
describe('form fields', () => {
it('renders the Channel select', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('select-Channel')).toBeInTheDocument();
});
it('renders the Rule name input', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
});
it('renders the Every (days of week) multiselect', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('multiselect-Every')).toBeInTheDocument();
});
it('renders Start date and End date pickers', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('datepicker-Start date')).toBeInTheDocument();
expect(screen.getByTestId('datepicker-End date')).toBeInTheDocument();
});
it('renders Start time and End time inputs', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
});
it('renders the enabled/paused Switch', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('switch')).toBeInTheDocument();
});
it('switch reflects rule enabled state', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('switch')).toBeChecked();
});
it('switch is unchecked when rule is paused', () => {
setupMocks({ rule: makeRule({ enabled: false }) });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('switch')).not.toBeChecked();
});
it('renders channel name in header', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByText('HBO')).toBeInTheDocument();
});
it('renders channel number fallback when channel not in allChannels', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([]);
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByText('Channel ch-1')).toBeInTheDocument();
});
});
});
// Channel loading
describe('channel loading', () => {
it('calls getChannelsSummary when opened', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
});
});
it('does not call getChannelsSummary when closed', () => {
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
});
it('calls sortedChannelOptions with loaded channels', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([
makeChannel(),
]);
});
});
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
new Error('fail')
);
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([]);
});
});
});
// Form initialization from rule
describe('form initialization', () => {
it('calls getFormDefaults with rule when opened', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(RecurringRuleModalUtils.getFormDefaults).toHaveBeenCalledWith(
makeRule()
);
});
it('calls form.setValues with getFormDefaults result', () => {
const expectedDefaults = {
channel_id: 'ch-1',
rule_name: 'Morning News',
days_of_week: ['mon', 'tue'],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
enabled: true,
};
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue(
expectedDefaults
);
render(<RecurringRuleModal {...defaultProps()} />);
// form instance is created during render read after
const formMock = vi.mocked(useForm).mock.results[0].value;
expect(formMock.setValues).toHaveBeenCalledWith(expectedDefaults);
});
});
// Save changes
describe('save changes', () => {
it('calls updateRecurringRule on form submit', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(
RecurringRuleModalUtils.updateRecurringRule
).toHaveBeenCalledWith('rule-1', expect.any(Object));
});
});
it('calls fetchRecurringRules after save', async () => {
const { mockFetchRecurringRules } = setupMocks();
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
});
});
it('shows "Recurring rule updated" notification on success', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule updated',
color: 'green',
})
);
});
});
it('calls onClose after successful save', async () => {
const onClose = vi.fn();
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not show notification when updateRecurringRule throws', async () => {
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockRejectedValue(
new Error('fail')
);
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('Save changes button shows loading state while saving', async () => {
let resolve;
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(screen.getByText('Save changes')).toHaveAttribute(
'data-loading',
'true'
);
});
resolve();
});
});
// Delete rule
describe('delete rule', () => {
it('renders the Delete rule button', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByText('Delete rule')).toBeInTheDocument();
});
it('calls deleteRecurringRuleById when Delete rule is clicked', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(
RecurringRuleModalUtils.deleteRecurringRuleById
).toHaveBeenCalledWith('rule-1');
});
});
it('calls fetchRecurringRules after delete', async () => {
const { mockFetchRecurringRules } = setupMocks();
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
});
});
it('shows "Recurring rule removed" notification on success', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule removed',
color: 'red',
})
);
});
});
it('calls onClose after successful delete', async () => {
const onClose = vi.fn();
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not show notification when deleteRecurringRuleById throws', async () => {
vi.mocked(
RecurringRuleModalUtils.deleteRecurringRuleById
).mockRejectedValue(new Error('fail'));
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Toggle enabled
describe('toggle enabled', () => {
it('calls updateRecurringRuleEnabled with true when switch is toggled on', async () => {
setupMocks({ rule: makeRule({ enabled: false }) });
render(<RecurringRuleModal {...defaultProps()} />);
const sw = screen.getByTestId('switch');
expect(sw).not.toBeChecked();
fireEvent.click(sw);
await waitFor(() => {
expect(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).toHaveBeenCalledWith('rule-1', true);
});
});
it('calls updateRecurringRuleEnabled with false when switch is toggled off', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
const sw = screen.getByTestId('switch');
expect(sw).toBeChecked();
fireEvent.click(sw);
await waitFor(() => {
expect(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).toHaveBeenCalledWith('rule-1', false);
});
});
it('shows "Recurring rule enabled" notification when enabling', async () => {
setupMocks({ rule: makeRule({ enabled: false }) });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule enabled',
color: 'green',
})
);
});
});
it('shows "Recurring rule paused" notification when disabling', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule paused',
color: 'yellow',
})
);
});
});
it('calls fetchRecurringRules after toggle', async () => {
const { mockFetchRecurringRules } = setupMocks();
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
});
});
it('does not show notification when updateRecurringRuleEnabled throws', async () => {
vi.mocked(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).mockRejectedValue(new Error('fail'));
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Upcoming occurrences
describe('upcoming occurrences', () => {
it('renders the occurrence count badge', () => {
setupMocks({ occurrences: [makeOccurrence(), makeOccurrence('occ-2')] });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('badge')).toHaveTextContent('2');
});
it('shows "No future airings" when no upcoming occurrences', () => {
setupMocks({ occurrences: [] });
render(<RecurringRuleModal {...defaultProps()} />);
expect(
screen.getByText('No future airings currently scheduled.')
).toBeInTheDocument();
});
it('renders occurrence cards when occurrences exist', () => {
setupMocks({
occurrences: [makeOccurrence(), makeOccurrence('occ-2')],
});
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getAllByTestId('occurrence-card')).toHaveLength(2);
});
it('renders Edit and Cancel buttons for each occurrence', () => {
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(
RecurringRuleModalUtils.getUpcomingOccurrences
).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Function),
'rule-1',
expect.any(Function)
);
});
});
// Edit occurrence
describe('edit occurrence', () => {
it('calls onClose and onEditOccurrence when Edit is clicked', () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
const onClose = vi.fn();
const onEditOccurrence = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
onEditOccurrence={onEditOccurrence}
/>
);
fireEvent.click(screen.getByText('Edit'));
expect(onClose).toHaveBeenCalled();
expect(onEditOccurrence).toHaveBeenCalledWith(occ);
});
it('does not throw when onEditOccurrence is not provided', () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(
<RecurringRuleModal {...defaultProps()} onEditOccurrence={undefined} />
);
expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
});
});
// Cancel occurrence
describe('cancel occurrence', () => {
it('calls deleteRecordingById when Cancel is clicked', async () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('occ-1');
});
});
it('shows "Occurrence cancelled" notification on success', async () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Occurrence cancelled',
color: 'yellow',
})
);
});
});
it('does not show notification when deleteRecordingById throws', async () => {
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('shows loading state on Cancel button while deleting occurrence', async () => {
let resolve;
vi.mocked(deleteRecordingById).mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.getByText('Cancel')).toHaveAttribute(
'data-loading',
'true'
);
});
resolve();
});
});
// Time/date field handlers
describe('field change handlers', () => {
it('calls toTimeString when start time changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('timeinput-Start time'), {
target: { value: '09:00' },
});
expect(toTimeString).toHaveBeenCalledWith('09:00');
});
it('calls toTimeString when end time changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('timeinput-End time'), {
target: { value: '10:00' },
});
expect(toTimeString).toHaveBeenCalledWith('10:00');
});
it('updates end_date field when end date picker changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
const picker = screen.getByTestId('datepicker-End date');
fireEvent.change(picker, { target: { value: '2024-12-31' } });
// No throw handler wired correctly
});
it('updates start_date field when start date picker changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
const picker = screen.getByTestId('datepicker-Start date');
fireEvent.change(picker, { target: { value: '2024-07-01' } });
// No throw handler wired correctly
});
});
});

View file

@ -0,0 +1,427 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ScheduleInput from '../ScheduleInput';
// Mantine core
vi.mock('@mantine/core', () => ({
TextInput: ({
label,
placeholder,
description,
value,
onChange,
error,
disabled,
}) => (
<div>
<label>{typeof label === 'string' ? label : 'Cron Expression'}</label>
<input
data-testid="textinput-cron"
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
aria-invalid={!!error}
/>
{description && <span data-testid="cron-description">{description}</span>}
{error && <span data-testid="cron-error">{error}</span>}
</div>
),
NumberInput: ({ label, description, value, onChange, min, disabled }) => (
<div>
<label>{label}</label>
<input
data-testid="numberinput-interval"
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
disabled={disabled}
/>
{description && (
<span data-testid="interval-description">{description}</span>
)}
</div>
),
Anchor: ({ children, onClick }) => (
<a
data-testid={`anchor-${String(children).replace(/\s+/g, '-').toLowerCase()}`}
onClick={onClick}
href="#"
>
{children}
</a>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Code: ({ children }) => <code>{children}</code>,
Group: ({ children }) => <div>{children}</div>,
Popover: ({ children }) => <div>{children}</div>,
PopoverTarget: ({ children }) => <div>{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
ActionIcon: ({ children, onClick }) => (
<button data-testid="action-icon-info" onClick={onClick}>
{children}
</button>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Info: () => <svg data-testid="icon-info" />,
}));
// cronUtils
vi.mock('../../../utils/cronUtils', () => ({
validateCronExpression: vi.fn(),
}));
// CronBuilder
vi.mock('../CronBuilder', () => ({
default: ({ opened, onClose, onApply, currentValue }) =>
opened ? (
<div data-testid="cron-builder">
<span data-testid="cron-builder-value">{currentValue}</span>
<button
data-testid="cron-builder-apply"
onClick={() => onApply('0 3 * * *')}
>
Apply
</button>
<button data-testid="cron-builder-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
//
import { validateCronExpression } from '../../../utils/cronUtils';
const defaultIntervalProps = () => ({
scheduleType: 'interval',
onScheduleTypeChange: vi.fn(),
intervalValue: 6,
onIntervalChange: vi.fn(),
cronValue: '',
onCronChange: vi.fn(),
});
const defaultCronProps = () => ({
scheduleType: 'cron',
onScheduleTypeChange: vi.fn(),
cronValue: '0 3 * * *',
onCronChange: vi.fn(),
intervalValue: 0,
onIntervalChange: vi.fn(),
});
describe('ScheduleInput', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
});
// Interval mode
describe('interval mode', () => {
it('renders NumberInput in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
});
it('displays the intervalValue', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('numberinput-interval')).toHaveValue(6);
});
it('renders default interval label', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByText('Refresh Interval (hours)')).toBeInTheDocument();
});
it('renders custom intervalLabel', () => {
render(
<ScheduleInput
{...defaultIntervalProps()}
intervalLabel="My Custom Label"
/>
);
expect(screen.getByText('My Custom Label')).toBeInTheDocument();
});
it('renders default interval description', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('interval-description')).toHaveTextContent(
'How often to refresh (0 to disable)'
);
});
it('calls onIntervalChange when value changes', () => {
const props = defaultIntervalProps();
render(<ScheduleInput {...props} />);
fireEvent.change(screen.getByTestId('numberinput-interval'), {
target: { value: '12' },
});
expect(props.onIntervalChange).toHaveBeenCalledWith(12);
});
it('shows "Use cron schedule" link in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(
screen.getByTestId('anchor-use-cron-schedule')
).toBeInTheDocument();
});
it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
const props = defaultIntervalProps();
render(<ScheduleInput {...props} />);
fireEvent.click(screen.getByTestId('anchor-use-cron-schedule'));
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('cron');
});
it('does not show cron switch link when disabled', () => {
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
expect(
screen.queryByTestId('anchor-use-cron-schedule')
).not.toBeInTheDocument();
});
it('disables the NumberInput when disabled prop is true', () => {
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
expect(screen.getByTestId('numberinput-interval')).toBeDisabled();
});
it('renders custom switchToCronLabel', () => {
render(
<ScheduleInput
{...defaultIntervalProps()}
switchToCronLabel="Use custom cron schedule"
/>
);
expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
});
});
// Children (simple) mode
describe('children (simple) mode', () => {
it('renders children instead of NumberInput', () => {
render(
<ScheduleInput {...defaultIntervalProps()}>
<div data-testid="custom-child">Custom content</div>
</ScheduleInput>
);
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
expect(
screen.queryByTestId('numberinput-interval')
).not.toBeInTheDocument();
});
it('shows cron toggle link below children', () => {
render(
<ScheduleInput {...defaultIntervalProps()}>
<div>Child</div>
</ScheduleInput>
);
expect(
screen.getByTestId('anchor-use-cron-schedule')
).toBeInTheDocument();
});
it('does not show cron link when disabled with children', () => {
render(
<ScheduleInput {...defaultIntervalProps()} disabled>
<div>Child</div>
</ScheduleInput>
);
expect(
screen.queryByTestId('anchor-use-cron-schedule')
).not.toBeInTheDocument();
});
});
// Cron mode
describe('cron mode', () => {
it('renders TextInput in cron mode', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.getByTestId('textinput-cron')).toBeInTheDocument();
});
it('displays the cronValue', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.getByTestId('textinput-cron')).toHaveValue('0 3 * * *');
});
it('shows "Use interval schedule" link in cron mode', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(
screen.getByTestId('anchor-use-interval-schedule')
).toBeInTheDocument();
});
it('shows "Open Cron Builder" link', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(
screen.getByTestId('anchor-open-cron-builder')
).toBeInTheDocument();
});
it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.click(screen.getByTestId('anchor-use-interval-schedule'));
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('interval');
expect(props.onCronChange).toHaveBeenCalledWith('');
});
it('calls onCronChange when cron input changes', () => {
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.change(screen.getByTestId('textinput-cron'), {
target: { value: '0 5 * * *' },
});
expect(props.onCronChange).toHaveBeenCalledWith('0 5 * * *');
});
it('disables the TextInput when disabled prop is true', () => {
render(<ScheduleInput {...defaultCronProps()} disabled />);
expect(screen.getByTestId('textinput-cron')).toBeDisabled();
});
it('does not show cron links when disabled', () => {
render(<ScheduleInput {...defaultCronProps()} disabled />);
expect(
screen.queryByTestId('anchor-use-interval-schedule')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('anchor-open-cron-builder')
).not.toBeInTheDocument();
});
it('renders custom switchToIntervalLabel', () => {
render(
<ScheduleInput
{...defaultCronProps()}
switchToIntervalLabel="Use simple schedule"
/>
);
expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
});
});
// Cron validation
describe('cron validation', () => {
it('shows no error when cron is valid', () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
});
it('shows error message when cron is invalid', async () => {
vi.mocked(validateCronExpression).mockReturnValue({
valid: false,
error: 'Invalid cron expression',
});
render(<ScheduleInput {...defaultCronProps()} cronValue="bad cron" />);
await waitFor(() => {
expect(screen.getByTestId('cron-error')).toHaveTextContent(
'Invalid cron expression'
);
});
});
it('clears error when cron input is cleared', async () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const props = { ...defaultCronProps(), cronValue: '' };
render(<ScheduleInput {...props} />);
await waitFor(() => {
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
});
});
it('calls validateCronExpression on cron value change', () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.change(screen.getByTestId('textinput-cron'), {
target: { value: '0 6 * * 1' },
});
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * 1');
});
it('does not call validateCronExpression in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(validateCronExpression).not.toHaveBeenCalled();
});
});
// Cron Builder
describe('CronBuilder', () => {
it('CronBuilder is not visible initially', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
});
it('opens CronBuilder when "Open Cron Builder" is clicked', () => {
render(<ScheduleInput {...defaultCronProps()} />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
expect(screen.getByTestId('cron-builder')).toBeInTheDocument();
});
it('passes current cronValue to CronBuilder', () => {
render(<ScheduleInput {...defaultCronProps()} cronValue="0 4 * * *" />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
expect(screen.getByTestId('cron-builder-value')).toHaveTextContent(
'0 4 * * *'
);
});
it('closes CronBuilder when onClose is triggered', () => {
render(<ScheduleInput {...defaultCronProps()} />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
fireEvent.click(screen.getByTestId('cron-builder-close'));
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
});
it('calls onCronChange with applied value from CronBuilder', () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
fireEvent.click(screen.getByTestId('cron-builder-apply'));
expect(props.onCronChange).toHaveBeenCalledWith('0 3 * * *');
});
});
// Default props
describe('default props', () => {
it('defaults to interval mode when scheduleType is not provided', () => {
render(
<ScheduleInput
onScheduleTypeChange={vi.fn()}
onIntervalChange={vi.fn()}
onCronChange={vi.fn()}
/>
);
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
});
it('defaults intervalValue to 0', () => {
render(
<ScheduleInput
onScheduleTypeChange={vi.fn()}
onIntervalChange={vi.fn()}
onCronChange={vi.fn()}
/>
);
expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
});
});
});

View file

@ -0,0 +1,455 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SeriesRecordingModal from '../SeriesRecordingModal';
// Mantine core
vi.mock('@mantine/core', () => ({
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw }) => (
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, variant, color, disabled }) => (
<button
onClick={onClick}
data-variant={variant}
data-color={color}
disabled={disabled}
>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
}));
// Store
vi.mock('../../../store/channels.jsx', () => ({
default: { getState: vi.fn() },
}));
// Utils
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteSeriesAndRule: vi.fn(),
}));
vi.mock('../../../utils/guideUtils.js', () => ({
evaluateSeriesRulesByTvgId: vi.fn(),
fetchRules: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
// SeriesRuleEditorModal
vi.mock('../SeriesRuleEditorModal', () => ({
default: ({ opened, onClose, initialRule, onSaved }) =>
opened ? (
<div data-testid="series-rule-editor">
<span data-testid="editor-rule">
{initialRule ? initialRule.title : 'new'}
</span>
<button data-testid="editor-close" onClick={onClose}>
Close
</button>
<button data-testid="editor-save" onClick={onSaved}>
Save
</button>
</div>
) : null,
}));
//
import useChannelsStore from '../../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
import {
evaluateSeriesRulesByTvgId,
fetchRules,
} from '../../../utils/guideUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
const makeRule = (overrides = {}) => ({
tvg_id: 'tvg-1',
title: 'Test Show',
mode: 'new',
title_mode: 'exact',
description: '',
channel_id: null,
...overrides,
});
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
rules: [makeRule()],
onRulesUpdate: vi.fn(),
...overrides,
});
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
const setupMocks = () => {
vi.mocked(useChannelsStore.getState).mockReturnValue({
fetchRecordings: mockFetchRecordings,
});
vi.mocked(fetchRules).mockResolvedValue([makeRule()]);
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
};
describe('SeriesRecordingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders when opened is true', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when opened is false', () => {
render(<SeriesRecordingModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders modal title "Series Recording Rules"', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Series Recording Rules'
);
});
it('calls onClose when modal close button is clicked', () => {
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(props.onClose).toHaveBeenCalled();
});
});
// Empty state
describe('empty state', () => {
it('shows "No series rules configured" when rules is empty', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: [] })} />);
expect(
screen.getByText('No series rules configured')
).toBeInTheDocument();
});
it('shows "No series rules configured" when rules is null', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: null })} />);
expect(
screen.getByText('No series rules configured')
).toBeInTheDocument();
});
it('does not show "No series rules configured" when rules are present', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(
screen.queryByText('No series rules configured')
).not.toBeInTheDocument();
});
});
// Rule rendering
describe('rule rendering', () => {
it('renders rule title', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByText('Test Show')).toBeInTheDocument();
});
it('falls back to tvg_id when rule has no title', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title: '', tvg_id: 'tvg-fallback' })],
})}
/>
);
expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
});
it('renders "Pinned channel" badge when channel_id is set', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ channel_id: 'ch-1' })] })}
/>
);
expect(screen.getByText('Pinned channel')).toBeInTheDocument();
});
it('does not render "Pinned channel" badge when channel_id is null', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.queryByText('Pinned channel')).not.toBeInTheDocument();
});
it('renders multiple rules', () => {
const rules = [
makeRule({ tvg_id: 'tvg-1', title: 'Show One' }),
makeRule({ tvg_id: 'tvg-2', title: 'Show Two' }),
];
render(<SeriesRecordingModal {...defaultProps({ rules })} />);
expect(screen.getByText('Show One')).toBeInTheDocument();
expect(screen.getByText('Show Two')).toBeInTheDocument();
});
it('renders Edit, Evaluate Now, and Remove buttons for each rule', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Evaluate Now')).toBeInTheDocument();
expect(screen.getByText('Remove')).toBeInTheDocument();
});
it('renders "Add rule" button', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByText('Add rule')).toBeInTheDocument();
});
});
// Rule summary
describe('rule summary', () => {
it('shows "New episodes" for mode new', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ mode: 'new' })] })}
/>
);
expect(screen.getByText(/New episodes/)).toBeInTheDocument();
});
it('shows "Every episode" for mode all', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ mode: 'all' })] })}
/>
);
expect(screen.getByText(/Every episode/)).toBeInTheDocument();
});
it('shows exact title label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title_mode: 'exact', title: 'Test Show' })],
})}
/>
);
expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
});
it('shows contains label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title_mode: 'contains', title: 'Test' })],
})}
/>
);
expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
});
it('shows regex label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title_mode: 'regex', title: '^Test' })],
})}
/>
);
expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
});
it('shows description in summary when present', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ description: 'A drama' })] })}
/>
);
expect(screen.getByText(/Description: "A drama"/)).toBeInTheDocument();
});
it('does not show description in summary when empty', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.queryByText(/Description:/)).not.toBeInTheDocument();
});
});
// Evaluate Now
describe('Evaluate Now', () => {
it('calls evaluateSeriesRulesByTvgId with rule tvg_id', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalledWith('tvg-1');
});
});
it('calls fetchRecordings after evaluation', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('shows "Evaluated" notification after evaluation', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Evaluated',
message: 'Checked for episodes',
})
);
});
});
it('still shows notification when fetchRecordings rejects', async () => {
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Evaluated' })
);
});
});
});
// Remove series
describe('Remove series', () => {
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
tvg_id: 'tvg-1',
title: 'Test Show',
});
});
});
it('calls fetchRecordings after removal', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('calls fetchRules after removal', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(fetchRules).toHaveBeenCalled();
});
});
it('calls onRulesUpdate with fetched rules after removal', async () => {
const updated = [makeRule({ title: 'Updated Show' })];
vi.mocked(fetchRules).mockResolvedValue(updated);
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
});
});
it('still calls onRulesUpdate when fetchRecordings rejects', async () => {
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(props.onRulesUpdate).toHaveBeenCalled();
});
});
});
// Add rule / Editor
describe('Add rule', () => {
it('opens SeriesRuleEditorModal when "Add rule" is clicked', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
});
it('passes null initialRule when opening via "Add rule"', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
expect(screen.getByTestId('editor-rule')).toHaveTextContent('new');
});
it('closes SeriesRuleEditorModal when editor onClose fires', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
fireEvent.click(screen.getByTestId('editor-close'));
expect(
screen.queryByTestId('series-rule-editor')
).not.toBeInTheDocument();
});
});
// Edit rule
describe('Edit rule', () => {
it('opens SeriesRuleEditorModal with the selected rule when Edit is clicked', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
expect(screen.getByTestId('editor-rule')).toHaveTextContent('Test Show');
});
it('calls fetchRules and onRulesUpdate when editor onSaved fires', async () => {
const updated = [makeRule({ title: 'Saved Show' })];
vi.mocked(fetchRules).mockResolvedValue(updated);
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByText('Edit'));
fireEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => {
expect(fetchRules).toHaveBeenCalled();
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
});
});
it('closes editor after save', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Edit'));
fireEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => {
// editor is still open (onSaved doesn't auto-close) just verify no error
expect(fetchRules).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,730 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Store mocks
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../store/epgs.jsx', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils.js', () => ({
useDebounce: vi.fn((val) => val),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
getChannelsSummary: vi.fn(),
}));
vi.mock('../../../utils/guideUtils.js', () => ({
createSeriesRule: vi.fn(),
evaluateSeriesRulesByTvgId: vi.fn(),
}));
vi.mock('../../../utils/forms/SeriesRuleEditorModalUtils.js', () => ({
TITLE_MODES: [
{ label: 'Exact', value: 'exact' },
{ label: 'Contains', value: 'contains' },
],
DESCRIPTION_MODES: [
{ label: 'Contains', value: 'contains' },
{ label: 'Exact', value: 'exact' },
],
EPISODE_MODES: [
{ label: 'All', value: 'all' },
{ label: 'New only', value: 'new' },
],
formatRange: vi.fn((start, end) => `${start} - ${end}`),
getChannelOptions: vi.fn(() => [{ value: '10', label: '501 - HBO' }]),
getTvgOptions: vi.fn(),
previewSeriesRule: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Alert: ({ children, color }) => (
<div data-testid="alert" data-color={color}>
{children}
</div>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, variant }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
>
{children}
</button>
),
Divider: () => <hr />,
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
ScrollAreaAutosize: ({ children }) => <div>{children}</div>,
SegmentedControl: ({ data, value, onChange, size }) => (
<div data-testid="segmented-control" data-size={size}>
{data.map((item) => (
<button
key={item.value}
data-active={value === item.value}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
))}
</div>
),
Select: ({ label, placeholder, data, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && (
<span data-testid="select-description">{description}</span>
)}
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">{placeholder}</option>
{(data || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Textarea: ({ label, placeholder, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && <span>{description}</span>}
<textarea
data-testid="textarea-description"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
</div>
),
TextInput: ({ label, placeholder, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && <span>{description}</span>}
<input
data-testid="input-title"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
</div>
),
}));
// Imports after mocks
import SeriesRuleEditorModal from '../SeriesRuleEditorModal';
import useChannelsStore from '../../../store/channels.jsx';
import useEPGsStore from '../../../store/epgs.jsx';
import { useDebounce } from '../../../utils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
import {
createSeriesRule,
evaluateSeriesRulesByTvgId,
} from '../../../utils/guideUtils.js';
import {
getTvgOptions,
previewSeriesRule,
formatRange,
} from '../../../utils/forms/SeriesRuleEditorModalUtils.js';
// Shared test data
const mockTvgs = [
{ id: 1, tvg_id: 'tvg-1', name: 'Channel One' },
{ id: 2, tvg_id: 'tvg-2', name: 'Channel Two' },
];
const mockTvgsById = {
1: { tvg_id: 'tvg-1', name: 'Channel One' },
2: { tvg_id: 'tvg-2', name: 'Channel Two' },
};
const mockChannels = [
{ id: 10, name: 'HBO', channel_number: 501, epg_data_id: 1 },
{ id: 20, name: 'CNN', channel_number: 102, epg_data_id: 2 },
];
const mockTvgOptions = [
{ value: 'tvg-1', label: 'Channel One (tvg-1)' },
{ value: 'tvg-2', label: 'Channel Two (tvg-2)' },
];
const mockPreviewResponse = {
matches: [
{
id: 'p1',
title: 'Match Show',
sub_title: 'Episode 1',
description: 'A great show',
start_time: '2024-01-01T10:00:00Z',
end_time: '2024-01-01T11:00:00Z',
tvg_id: 'tvg-1',
is_new: true,
},
],
total: 1,
epg_found: true,
};
// Setup helper
const setupMocks = () => {
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
vi.mocked(useEPGsStore).mockImplementation((selector) =>
selector({ tvgs: mockTvgs, tvgsById: mockTvgsById })
);
// Mock getState for the imperative call in handleSave
useChannelsStore.getState = vi.fn().mockReturnValue({
fetchRecordings: mockFetchRecordings,
});
vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [],
total: 0,
epg_found: true,
});
vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
vi.mocked(useDebounce).mockImplementation((val) => val);
vi.mocked(formatRange).mockImplementation((s, e) => `${s} - ${e}`);
return { mockFetchRecordings };
};
//
describe('SeriesRuleEditorModal', () => {
let defaultProps;
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
defaultProps = {
opened: true,
onClose: vi.fn(),
initialRule: null,
onSaved: vi.fn(),
};
});
afterEach(() => {
vi.clearAllMocks();
});
// Rendering
describe('rendering', () => {
it('renders the modal when opened is true', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('shows "New Series Rule" title when initialRule is null', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'New Series Rule'
);
});
it('shows "Edit Series Rule" title when initialRule is provided', () => {
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ tvg_id: 'tvg-1', mode: 'all', title: 'Test' }}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Edit Series Rule'
);
});
it('renders the title input', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('input-title')).toBeInTheDocument();
});
it('renders the description textarea', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('textarea-description')).toBeInTheDocument();
});
it('renders Cancel and Save rule buttons', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Save rule')).toBeInTheDocument();
});
it('Save rule button is disabled when no title or description', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByText('Save rule')).toBeDisabled();
});
it('Save rule button is enabled when title is provided', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'My Show' },
});
expect(screen.getByText('Save rule')).not.toBeDisabled();
});
it('Save rule button is enabled when description is provided', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('textarea-description'), {
target: { value: 'some description' },
});
expect(screen.getByText('Save rule')).not.toBeDisabled();
});
});
// State hydration from initialRule
describe('state hydration', () => {
const initialRule = {
tvg_id: 'tvg-1',
mode: 'new',
title: 'Pre-filled Title',
title_mode: 'contains',
description: 'Pre-filled description',
description_mode: 'exact',
channel_id: 10,
};
it('pre-fills title from initialRule', () => {
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
});
it('pre-fills description from initialRule', () => {
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
expect(screen.getByTestId('textarea-description')).toHaveValue(
'Pre-filled description'
);
});
it('uses default mode "all" when initialRule has no mode', () => {
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={{ title: 'X' }} />
);
// "All" segmented button should be active (data-active=true)
const allBtn = screen
.getAllByText('All')
.find((el) => el.closest('[data-testid="segmented-control"]'));
expect(allBtn).toBeTruthy();
});
it('resets fields when modal is reopened without initialRule', async () => {
const { rerender } = render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
rerender(
<SeriesRuleEditorModal
{...defaultProps}
opened={false}
initialRule={null}
/>
);
rerender(
<SeriesRuleEditorModal
{...defaultProps}
opened={true}
initialRule={null}
/>
);
expect(screen.getByTestId('input-title')).toHaveValue('');
expect(screen.getByTestId('textarea-description')).toHaveValue('');
});
});
// Channel loading
describe('channel loading', () => {
it('calls getChannelsSummary when modal opens', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
await waitFor(() => {
expect(getChannelsSummary).toHaveBeenCalled();
});
});
it('does not call getChannelsSummary when modal is closed', () => {
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
expect(getChannelsSummary).not.toHaveBeenCalled();
});
it('handles getChannelsSummary rejection gracefully', async () => {
vi.mocked(getChannelsSummary).mockRejectedValue(
new Error('Network error')
);
render(<SeriesRuleEditorModal {...defaultProps} />);
// Should not throw; channel options just empty
await waitFor(() => {
expect(getChannelsSummary).toHaveBeenCalled();
});
});
});
// TVG options
describe('EPG channel options', () => {
it('calls getTvgOptions with tvgs from store', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(getTvgOptions).toHaveBeenCalledWith(mockTvgs);
});
});
// Preview
describe('preview', () => {
it('does not call previewSeriesRule when title, description, and tvg_id are all empty', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
await waitFor(() => expect(getChannelsSummary).toHaveBeenCalled());
expect(previewSeriesRule).not.toHaveBeenCalled();
});
it('calls previewSeriesRule when title is entered', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'My Show' },
});
await waitFor(() => {
expect(previewSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ title: 'My Show' }),
expect.any(Object)
);
});
});
it('calls previewSeriesRule when description is entered', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('textarea-description'), {
target: { value: 'some words' },
});
await waitFor(() => {
expect(previewSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ description: 'some words' }),
expect.any(Object)
);
});
});
it('renders preview match results', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Match Show' },
});
await waitFor(() => {
expect(screen.getByText(/Match Show/)).toBeInTheDocument();
});
});
it('renders preview badge with match count', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [{ id: 'p1', title: 'X' }],
total: 5,
epg_found: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'X' },
});
await waitFor(() => {
expect(screen.getByTestId('badge')).toHaveTextContent('1 of 5');
});
});
it('shows preview error alert when previewSeriesRule rejects', async () => {
vi.mocked(previewSeriesRule).mockRejectedValue(
new Error('Preview failed')
);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Boom' },
});
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert')).toHaveTextContent('Preview failed');
});
});
it('shows "No EPG channel matches" warning when epg_found is false and tvg_id is set', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [],
total: 0,
epg_found: false,
});
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ tvg_id: 'tvg-999', title: 'X' }}
/>
);
await waitFor(() => {
expect(
screen.getByText(/No EPG channel matches this tvg_id/)
).toBeInTheDocument();
});
});
it('shows warn alert when preview.warn is true', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: Array(50).fill({ id: 'x', title: 'X' }),
total: 50,
epg_found: true,
warn: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'X' },
});
await waitFor(() => {
expect(
screen.getByText(/This rule matches many programs/)
).toBeInTheDocument();
});
});
it('shows "No matching upcoming programs" when matches empty and filter set', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [],
total: 0,
epg_found: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Unknown Show' },
});
await waitFor(() => {
expect(
screen.getByText(/No matching upcoming programs/)
).toBeInTheDocument();
});
});
it('renders sub_title in preview match', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Match Show' },
});
await waitFor(() => {
expect(screen.getByText(/Episode 1/)).toBeInTheDocument();
});
});
it('renders "(NEW)" marker for new episodes in preview', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Match Show' },
});
await waitFor(() => {
expect(screen.getByText(/(NEW)/)).toBeInTheDocument();
});
});
});
// handleSave
describe('handleSave', () => {
const renderWithTitle = () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'My Show' },
});
};
it('calls createSeriesRule with correct payload', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ title: 'My Show', mode: 'all' })
);
});
});
it('calls evaluateSeriesRulesByTvgId after createSeriesRule', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalled();
});
});
it('calls fetchRecordings after save', async () => {
const { mockFetchRecordings } = setupMocks();
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('calls showNotification on success', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Series rule saved' })
);
});
});
it('calls onSaved callback on success', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(defaultProps.onSaved).toHaveBeenCalled();
});
});
it('calls onClose after save', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('does not call showNotification when createSeriesRule throws', async () => {
vi.mocked(createSeriesRule).mockRejectedValue(new Error('Server error'));
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalled();
});
expect(showNotification).not.toHaveBeenCalled();
});
it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(
new Error('eval fail')
);
renderWithTitle();
await expect(
waitFor(() => {
fireEvent.click(screen.getByText('Save rule'));
})
).resolves.not.toThrow();
});
it('does not crash when fetchRecordings throws', async () => {
useChannelsStore.getState = vi.fn().mockReturnValue({
fetchRecordings: vi.fn().mockRejectedValue(new Error('fetch fail')),
});
renderWithTitle();
await expect(
waitFor(() => {
fireEvent.click(screen.getByText('Save rule'));
})
).resolves.not.toThrow();
});
it('includes channel_id in payload when channelId is set', async () => {
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ channel_id: 10, title: 'Show' }}
/>
);
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ channel_id: 10 })
);
});
});
it('omits channel_id from payload when no channel selected', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
const call = vi.mocked(createSeriesRule).mock.calls[0][0];
expect(call).not.toHaveProperty('channel_id');
});
});
});
// Cancel button
describe('Cancel button', () => {
it('calls onClose when Cancel is clicked', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
// SegmentedControl interactions
describe('SegmentedControl', () => {
it('changes title mode when SegmentedControl is clicked', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(
screen
.getAllByText('Contains')
.find((el) => el.closest('[data-testid="segmented-control"]'))
);
// No error thrown; state updated without crash
});
it('changes episode mode to "New only"', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(screen.getByText('New only'));
// Payload mode becomes 'new'; verify via save
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Test' },
});
fireEvent.click(screen.getByText('Save rule'));
waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'new' })
);
});
});
});
});

View file

@ -0,0 +1,572 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state (survives re-renders, accessible in beforeEach)
const __form = { values: {}, resetSpy: null };
// Store mocks
vi.mock('../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/StreamUtils.js', () => ({
addStream: vi.fn(),
getResolver: vi.fn(() => undefined),
updateStream: vi.fn(),
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const setValue = (name, value) => updateField(name, value);
const watch = (name) => formValues[name];
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
// Keep reset stable across re-renders so useEffect([defaultValues, reset])
// in Stream.jsx does not fire on every render and cause an infinite loop.
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
const reset = resetRef.current;
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset,
setValue,
watch,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Select: ({ label, value, onChange, data, placeholder, error }) => (
<div>
<label>{label}</label>
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">{placeholder ?? `-- ${label} --`}</option>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
TextInput: ({ label, name, value, onChange, error, ...rest }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import Stream from '../Stream';
import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
import useChannelsStore from '../../../store/channels.jsx';
import * as StreamUtils from '../../../utils/forms/StreamUtils.js';
// Shared test data
const mockProfiles = [
{ id: 1, name: 'Default' },
{ id: 2, name: 'HD Profile' },
];
const mockChannelGroups = {
10: { id: 10, name: 'Sports' },
20: { id: 20, name: 'News' },
};
const makeStream = (overrides = {}) => ({
id: 'stream-1',
name: 'Test Stream',
url: 'http://example.com/stream',
channel_group: 10,
stream_profile_id: 1,
...overrides,
});
const setupMocks = () => {
vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
sel({ profiles: mockProfiles })
);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ channelGroups: mockChannelGroups })
);
vi.mocked(StreamUtils.addStream).mockResolvedValue(undefined);
vi.mocked(StreamUtils.updateStream).mockResolvedValue(undefined);
};
const defaultProps = (overrides = {}) => ({
stream: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
//
describe('Stream', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset module-level form state before each test
__form.values = {};
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when isOpen is false', () => {
render(<Stream {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Stream" as the modal title', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream');
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<Stream {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders Stream Name input', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-name')).toBeInTheDocument();
});
it('renders Stream URL input', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-url')).toBeInTheDocument();
});
it('renders Group select', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('select-group')).toBeInTheDocument();
});
it('renders Stream Profile select', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('select-stream-profile')).toBeInTheDocument();
});
it('renders Submit button', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Submit')).toBeInTheDocument();
});
it('populates Group select with channel groups', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Sports')).toBeInTheDocument();
expect(screen.getByText('News')).toBeInTheDocument();
});
it('populates Stream Profile select with profiles', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Default')).toBeInTheDocument();
expect(screen.getByText('HD Profile')).toBeInTheDocument();
});
it('shows "Optional" placeholder for Stream Profile', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Optional')).toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('pre-fills name from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('input-stream-name')).toHaveValue(
'Test Stream'
);
});
it('pre-fills url from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('input-stream-url')).toHaveValue(
'http://example.com/stream'
);
});
it('pre-selects group from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('select-group')).toHaveValue('10');
});
it('pre-selects stream profile from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('select-stream-profile')).toHaveValue('1');
});
it('renders empty name when no stream provided', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-name')).toHaveValue('');
});
it('renders empty url when no stream provided', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-url')).toHaveValue('');
});
it('renders empty group when stream has no channel_group', () => {
render(
<Stream
{...defaultProps({ stream: makeStream({ channel_group: null }) })}
/>
);
expect(screen.getByTestId('select-group')).toHaveValue('');
});
it('renders empty profile when stream has no stream_profile_id', () => {
render(
<Stream
{...defaultProps({ stream: makeStream({ stream_profile_id: null }) })}
/>
);
expect(screen.getByTestId('select-stream-profile')).toHaveValue('');
});
});
// Create stream
describe('create stream (no existing stream)', () => {
it('calls addStream on submit when no stream id', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'New Stream' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://new.stream/live' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Stream',
url: 'http://new.stream/live',
})
);
});
});
it('does not call updateStream when creating', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'New Stream' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://new.stream' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).not.toHaveBeenCalled();
});
});
it('calls onClose after successful create', async () => {
const onClose = vi.fn();
render(<Stream {...defaultProps({ onClose })} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'New Stream' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://new.stream' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('converts channel_group string to integer in payload', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.change(screen.getByTestId('select-group'), {
target: { value: '10' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
expect.objectContaining({ channel_group: 10 })
);
});
});
it('sets channel_group to null when no group selected', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
expect(call.channel_group).toBeNull();
});
});
it('converts stream_profile_id string to integer in payload', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.change(screen.getByTestId('select-stream-profile'), {
target: { value: '2' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
expect.objectContaining({ stream_profile_id: 2 })
);
});
});
it('sets stream_profile_id to null when no profile selected', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
expect(call.stream_profile_id).toBeNull();
});
});
});
// Update stream
describe('update stream (existing stream)', () => {
it('calls updateStream with the stream id on submit', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
'stream-1',
expect.objectContaining({
name: 'Test Stream',
url: 'http://example.com/stream',
})
);
});
});
it('does not call addStream when updating', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).not.toHaveBeenCalled();
});
});
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
render(<Stream {...defaultProps({ stream: makeStream(), onClose })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('converts channel_group back to integer for update payload', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
'stream-1',
expect.objectContaining({ channel_group: 10 })
);
});
});
it('converts stream_profile_id back to integer for update payload', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
'stream-1',
expect.objectContaining({ stream_profile_id: 1 })
);
});
});
});
// getResolver
describe('getResolver', () => {
it('calls getResolver on mount', () => {
render(<Stream {...defaultProps()} />);
expect(StreamUtils.getResolver).toHaveBeenCalled();
});
});
// Form reset on stream change
describe('form reset', () => {
it('resets the form when stream prop changes', () => {
const { rerender } = render(<Stream {...defaultProps()} />);
rerender(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(__form.resetSpy).toHaveBeenCalled();
});
});
// Submit button state
describe('submit button', () => {
it('Submit button is not disabled by default', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Submit')).not.toBeDisabled();
});
});
});

View file

@ -0,0 +1,647 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state
const __form = { values: {}, resetSpy: null };
// Store mocks
vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
addStreamProfile: vi.fn(),
updateStreamProfile: vi.fn(),
getResolver: vi.fn(() => undefined),
toCommandSelection: vi.fn((cmd) => {
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
return builtins.includes(cmd) ? cmd : '__custom__';
}),
BUILT_IN_COMMANDS: [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: 'streamlink', label: 'Streamlink' },
{ value: 'cvlc', label: 'VLC' },
{ value: 'yt-dlp', label: 'yt-dlp' },
{ value: '__custom__', label: 'Custom…' },
],
COMMAND_EXAMPLES: {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink:
'{streamUrl} --http-header User-Agent={userAgent} best --stdout',
},
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const setValue = (name, value) => updateField(name, value);
const watch = (name) => formValues[name];
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset: resetRef.current,
setValue,
watch,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<div>
<label htmlFor="checkbox-is-active">{label}</label>
<input
id="checkbox-is-active"
data-testid="checkbox-is-active"
type="checkbox"
checked={checked ?? false}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Select: ({
label,
value,
onChange,
data,
placeholder,
error,
clearable,
disabled,
description,
}) => (
<div>
<label>{label}</label>
{description && <div>{description}</div>}
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(e.target.value || (clearable ? null : ''))}
>
<option value="">{placeholder ?? `-- ${label} --`}</option>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Textarea: ({
label,
name,
value,
onChange,
error,
placeholder,
description,
disabled,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && (
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
{description}
</div>
)}
<textarea
id={name}
name={name}
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import StreamProfile from '../StreamProfile';
import useUserAgentsStore from '../../../store/userAgents';
import * as StreamProfileUtils from '../../../utils/forms/StreamProfileUtils.js';
// Shared test data
const mockUserAgents = [
{ id: 1, name: 'Chrome' },
{ id: 2, name: 'Firefox' },
];
const makeProfile = (overrides = {}) => ({
id: 'sp-1',
name: 'My Profile',
command: 'ffmpeg',
parameters: '-i {streamUrl}',
is_active: true,
user_agent: '1',
locked: false,
...overrides,
});
const setupMocks = () => {
vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
sel({ userAgents: mockUserAgents })
);
vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(
undefined
);
vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
return builtins.includes(cmd) ? cmd : '__custom__';
});
};
const defaultProps = (overrides = {}) => ({
profile: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
describe('StreamProfile', () => {
beforeEach(() => {
vi.resetAllMocks();
__form.values = {};
__form.resetSpy = null;
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
render(<StreamProfile {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Stream Profile" as the modal title', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Stream Profile'
);
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<StreamProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders Name input', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toBeInTheDocument();
});
it('renders Command select', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('select-command')).toBeInTheDocument();
});
it('renders Parameters textarea', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
});
it('renders User-Agent select', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('select-user-agent')).toBeInTheDocument();
});
it('renders Is Active checkbox', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
});
it('renders Save button', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('populates Command select with built-in options', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
expect(screen.getByText('Streamlink')).toBeInTheDocument();
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
});
it('populates User-Agent select with user agents', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByText('Chrome')).toBeInTheDocument();
expect(screen.getByText('Firefox')).toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('pre-fills name from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('input-name')).toHaveValue('My Profile');
});
it('pre-fills parameters from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
'-i {streamUrl}'
);
});
it('pre-selects command from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
});
it('pre-selects user-agent from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('select-user-agent')).toHaveValue('1');
});
it('checkbox is checked when is_active is true', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('checkbox is unchecked when is_active is false', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
it('defaults name to empty string when no profile', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toHaveValue('');
});
it('defaults parameters to empty string when no profile', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
});
});
// Command selection
describe('command selection', () => {
it('does not show Custom Command input when a built-in is selected', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('shows Custom Command input when Custom… is selected', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
it('hides Custom Command input when switching from custom back to built-in', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('shows Custom Command input when profile has a custom command', () => {
vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue(
'__custom__'
);
render(
<StreamProfile
{...defaultProps({
profile: makeProfile({ command: '/usr/bin/mycmd' }),
})}
/>
);
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
it('sets command form value when built-in is selected', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'streamlink' },
});
expect(__form.values.command).toBe('streamlink');
});
it('clears command form value when Custom… is selected', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(__form.values.command).toBe('');
});
it('shows parameters example hint for ffmpeg', () => {
const { container } = render(
<StreamProfile {...defaultProps({ profile: makeProfile() })} />
);
expect(container.textContent).toMatch(/-user_agent \{userAgent\}/);
});
});
// Is Active checkbox
describe('Is Active checkbox', () => {
it('toggles is_active to false when unchecked', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(false);
});
it('toggles is_active to true when checked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(true);
});
});
// User-Agent select
describe('User-Agent select', () => {
it('updates user_agent form value when changed', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-user-agent'), {
target: { value: '2' },
});
expect(__form.values.user_agent).toBe('2');
});
it('sets user_agent to empty string when cleared', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.change(screen.getByTestId('select-user-agent'), {
target: { value: '' },
});
expect(__form.values.user_agent).toBe('');
});
});
// Locked profile
describe('locked profile', () => {
it('disables Name input when profile is locked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('input-name')).toBeDisabled();
});
it('disables Command select when profile is locked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('select-command')).toBeDisabled();
});
it('disables Parameters textarea when profile is locked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
});
it('does not disable inputs when profile is not locked', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('input-name')).not.toBeDisabled();
});
it('does not disable inputs when no profile is provided', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).not.toBeDisabled();
});
});
// Create profile
describe('create profile (no existing profile)', () => {
it('calls addStreamProfile on submit when no profile id', async () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.addStreamProfile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Profile' })
);
});
});
it('does not call updateStreamProfile when creating', async () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.updateStreamProfile).not.toHaveBeenCalled();
});
});
it('calls onClose after successful create', async () => {
const onClose = vi.fn();
render(<StreamProfile {...defaultProps({ onClose })} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Update profile
describe('update profile (existing profile)', () => {
it('calls updateStreamProfile with the profile id on submit', async () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.updateStreamProfile).toHaveBeenCalledWith(
'sp-1',
expect.objectContaining({ name: 'My Profile' })
);
});
});
it('does not call addStreamProfile when updating', async () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.addStreamProfile).not.toHaveBeenCalled();
});
});
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
render(
<StreamProfile {...defaultProps({ profile: makeProfile(), onClose })} />
);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Form reset
describe('form reset', () => {
it('resets the form when profile prop changes', () => {
const { rerender } = render(<StreamProfile {...defaultProps()} />);
rerender(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(__form.resetSpy).toHaveBeenCalled();
});
it('calls reset after successful submit', async () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(__form.resetSpy).toHaveBeenCalled();
});
});
});
// getResolver
describe('getResolver', () => {
it('calls getResolver on mount', () => {
render(<StreamProfile {...defaultProps()} />);
expect(StreamProfileUtils.getResolver).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,333 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Asset mock
vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
// API mock
vi.mock('../../../api', () => ({
default: {
createSuperUser: vi.fn(),
},
}));
// Store mocks
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, fullWidth }) => (
<button type={type} data-fullwidth={fullWidth}>
{children}
</button>
),
Center: ({ children, style }) => <div style={style}>{children}</div>,
Divider: ({ style }) => <hr style={style} />,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, color, align }) => (
<span data-size={size} data-color={color} data-align={align}>
{children}
</span>
),
TextInput: ({ label, name, value, onChange, required, type }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${name}`}
type={type ?? 'text'}
value={value}
onChange={onChange}
required={required}
/>
</div>
),
Title: ({ children, order, align }) => {
const Tag = `h${order ?? 1}`;
return <Tag data-align={align}>{children}</Tag>;
},
}));
// Imports after mocks
import SuperuserForm from '../SuperuserForm';
import API from '../../../api';
import useAuthStore from '../../../store/auth';
import useSettingsStore from '../../../store/settings';
// Helpers
const setupMocks = ({
version = {},
fetchVersion = vi.fn(),
setSuperuserExists = vi.fn(),
} = {}) => {
vi.mocked(useAuthStore).mockImplementation((sel) =>
sel({ setSuperuserExists })
);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ fetchVersion, version })
);
return { fetchVersion, setSuperuserExists };
};
describe('SuperuserForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Rendering
describe('rendering', () => {
it('renders the Dispatcharr title', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
});
it('renders the welcome message', () => {
setupMocks();
render(<SuperuserForm />);
expect(
screen.getByText(
'Welcome! Create your Super User Account to get started.'
)
).toBeInTheDocument();
});
it('renders the logo image', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
});
it('renders Username input', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByTestId('input-username')).toBeInTheDocument();
});
it('renders Password input with type="password"', () => {
setupMocks();
render(<SuperuserForm />);
const input = screen.getByTestId('input-password');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'password');
});
it('renders Email input with type="email"', () => {
setupMocks();
render(<SuperuserForm />);
const input = screen.getByTestId('input-email');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'email');
});
it('renders the Create Account button', () => {
setupMocks();
render(<SuperuserForm />);
expect(
screen.getByRole('button', { name: 'Create Account' })
).toBeInTheDocument();
});
it('does not render version text when version is not loaded', () => {
setupMocks({ version: {} });
render(<SuperuserForm />);
expect(screen.queryByText(/^v/)).not.toBeInTheDocument();
});
it('renders version text when version is loaded', () => {
setupMocks({ version: { version: '1.2.3' } });
render(<SuperuserForm />);
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
});
// useEffect
describe('useEffect', () => {
it('calls fetchVersion on mount', () => {
const { fetchVersion } = setupMocks();
render(<SuperuserForm />);
expect(fetchVersion).toHaveBeenCalledTimes(1);
});
it('does not call fetchVersion again on re-render with same fetchVersion ref', () => {
const { fetchVersion } = setupMocks();
const { rerender } = render(<SuperuserForm />);
rerender(<SuperuserForm />);
// fetchVersion is stable, so useEffect should only fire once
expect(fetchVersion).toHaveBeenCalledTimes(1);
});
});
// Form field interactions
describe('form field interactions', () => {
it('updates username field when typed', () => {
setupMocks();
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
expect(screen.getByTestId('input-username')).toHaveValue('admin');
});
it('updates password field when typed', () => {
setupMocks();
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-password'), {
target: { name: 'password', value: 'secret123' },
});
expect(screen.getByTestId('input-password')).toHaveValue('secret123');
});
it('updates email field when typed', () => {
setupMocks();
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-email'), {
target: { name: 'email', value: 'admin@example.com' },
});
expect(screen.getByTestId('input-email')).toHaveValue(
'admin@example.com'
);
});
it('initializes all fields as empty strings', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByTestId('input-username')).toHaveValue('');
expect(screen.getByTestId('input-password')).toHaveValue('');
expect(screen.getByTestId('input-email')).toHaveValue('');
});
});
// Form submission
describe('form submission', () => {
it('calls API.createSuperUser with form values on submit', async () => {
setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: false,
});
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
fireEvent.change(screen.getByTestId('input-password'), {
target: { name: 'password', value: 'secret' },
});
fireEvent.change(screen.getByTestId('input-email'), {
target: { name: 'email', value: 'admin@test.com' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalledWith({
username: 'admin',
password: 'secret',
email: 'admin@test.com',
});
});
});
it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => {
const { setSuperuserExists } = setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: true,
});
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(setSuperuserExists).toHaveBeenCalledWith(true);
});
});
it('does not call setSuperuserExists when response.superuser_exists is false', async () => {
const { setSuperuserExists } = setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: false,
});
render(<SuperuserForm />);
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalled();
});
expect(setSuperuserExists).not.toHaveBeenCalled();
});
it('does not throw when API.createSuperUser rejects', async () => {
setupMocks();
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
render(<SuperuserForm />);
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await expect(
waitFor(() => expect(API.createSuperUser).toHaveBeenCalled())
).resolves.not.toThrow();
});
it('does not call setSuperuserExists when API throws', async () => {
const { setSuperuserExists } = setupMocks();
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
render(<SuperuserForm />);
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalled();
});
expect(setSuperuserExists).not.toHaveBeenCalled();
});
it('submits with empty email when email field is left blank', async () => {
setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: false,
});
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
fireEvent.change(screen.getByTestId('input-password'), {
target: { name: 'password', value: 'secret' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalledWith(
expect.objectContaining({ email: '' })
);
});
});
});
});

View file

@ -0,0 +1,810 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Constants
vi.mock('../../../constants', () => ({
USER_LEVELS: { ADMIN: 1, STREAMER: 2, USER: 3 },
USER_LEVEL_LABELS: { 1: 'Admin', 2: 'Streamer', 3: 'User' },
}));
// Store mocks
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils', () => ({ copyToClipboard: vi.fn() }));
vi.mock('../../../utils/forms/UserUtils.js', () => ({
createUser: vi.fn(),
updateUser: vi.fn(),
generateApiKey: vi.fn(),
revokeApiKey: vi.fn(),
formValuesToPayload: vi.fn(),
getFormInitialValues: vi.fn(() => ({})),
getFormValidators: vi.fn(() => ({})),
userToFormValues: vi.fn(() => ({})),
}));
// Mantine form
const mockForm = {
getInputProps: vi.fn(() => ({})),
key: vi.fn((k) => k),
setValues: vi.fn(),
setFieldValue: vi.fn(),
reset: vi.fn(),
getValues: vi.fn(() => ({ user_level: '3' })),
onSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn();
}),
submitting: false,
};
vi.mock('@mantine/form', () => ({
useForm: vi.fn(() => mockForm),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Copy: () => <svg data-testid="icon-copy" />,
Key: () => <svg data-testid="icon-key" />,
RotateCcwKey: () => <svg data-testid="icon-rotate-ccw-key" />,
X: () => <svg data-testid="icon-x" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Button: ({ children, onClick, disabled, loading, type, leftSection }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-loading={String(loading)}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`multiselect-${label}`}
onChange={(e) =>
onChange?.(e.target.value ? e.target.value.split(',') : [])
}
/>
</div>
),
NumberInput: ({ label }) => (
<div>
<label>{label}</label>
<input />
</div>
),
PasswordInput: ({ label, disabled }) => (
<div>
<label>{label}</label>
<input type="password" disabled={disabled} />
</div>
),
Select: ({ label, disabled }) => (
<div>
<label>{label}</label>
<select disabled={disabled} />
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label }) => (
<div>
<label>{label}</label>
<input type="checkbox" />
</div>
),
Tabs: ({ children }) => <div>{children}</div>,
TabsList: ({ children }) => <div>{children}</div>,
TabsPanel: ({ children, value }) => (
<div data-testid={`panel-${value}`}>{children}</div>
),
TabsTab: ({ children, value }) => (
<button data-testid={`tab-${value}`}>{children}</button>
),
TagsInput: ({ label }) => (
<div>
<label>{label}</label>
<input />
</div>
),
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, value, disabled, rightSection }) => (
<div>
<label>{label}</label>
<input
data-testid={`textinput-${label}`}
defaultValue={value}
disabled={disabled}
/>
{rightSection}
</div>
),
useMantineTheme: () => ({
colors: { red: Array(10).fill('#ff0000') },
}),
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels';
import useOutputProfilesStore from '../../../store/outputProfiles';
import useAuthStore from '../../../store/auth';
import * as UserUtils from '../../../utils/forms/UserUtils.js';
import { copyToClipboard } from '../../../utils';
import User from '../User';
// Factories
const makeAdminUser = (overrides = {}) => ({
id: 1,
username: 'admin',
user_level: 1,
api_key: null,
...overrides,
});
const makeRegularUser = (overrides = {}) => ({
id: 2,
username: 'user1',
user_level: 3,
api_key: null,
...overrides,
});
const setupMocks = ({
authUser = makeAdminUser(),
profiles = {},
outputProfiles = [],
} = {}) => {
const mockSetUser = vi.fn();
vi.mocked(useChannelsStore).mockImplementation((sel) => sel({ profiles }));
vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
sel({ profiles: outputProfiles })
);
vi.mocked(useAuthStore).mockImplementation((sel) =>
sel({ user: authUser, setUser: mockSetUser })
);
// Reset form state
mockForm.getValues.mockReturnValue({ user_level: '3' });
mockForm.onSubmit.mockImplementation((fn) => (e) => {
e?.preventDefault?.();
return fn();
});
return { mockSetUser };
};
//
describe('User', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(UserUtils.createUser).mockResolvedValue({});
vi.mocked(UserUtils.updateUser).mockResolvedValue({});
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
key: 'new-api-key',
});
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
vi.mocked(UserUtils.getFormValidators).mockReturnValue({});
vi.mocked(UserUtils.userToFormValues).mockReturnValue({});
});
// Visibility
describe('visibility', () => {
it('renders nothing when isOpen is false', () => {
setupMocks();
render(<User isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders the modal when isOpen is true', () => {
setupMocks();
render(<User isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('calls onClose when the modal close button is clicked', () => {
setupMocks();
const onClose = vi.fn();
render(<User isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Tabs
describe('tabs', () => {
it('always renders Account, EPG, and API tabs', () => {
setupMocks();
render(<User isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('tab-account')).toBeInTheDocument();
expect(screen.getByTestId('tab-epg')).toBeInTheDocument();
expect(screen.getByTestId('tab-api')).toBeInTheDocument();
});
it('shows Permissions tab when admin edits another user', () => {
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} user={makeRegularUser()} />);
expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
});
it('hides Permissions tab when admin edits themselves', () => {
const admin = makeAdminUser();
setupMocks({ authUser: admin });
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
});
it('hides Permissions tab for non-admin user', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 99 })}
/>
);
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
});
});
// Admin-only fields
describe('admin-only fields', () => {
it('shows Output Format Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Output Format Override')).toBeInTheDocument();
});
it('hides Output Format Override for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
expect(
screen.queryByText('Output Format Override')
).not.toBeInTheDocument();
});
it('shows Output Profile Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
});
it('shows Allowed IPs for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
});
it('hides Allowed IPs for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
});
});
// API key generation
describe('API key generation', () => {
it('shows "Generate API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
it('shows "Regenerate API Key" when user already has an api_key', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'existing-key' })}
/>
);
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
});
it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
key: 'brand-new-key',
});
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
expect(UserUtils.generateApiKey).toHaveBeenCalledWith({ user_id: 2 });
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
});
});
it('also sets the key when response contains raw_key instead of key', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
raw_key: 'raw-value',
});
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
});
});
it('does not call generateApiKey when canGenerateKey is false', async () => {
// non-admin editing someone else
setupMocks({ authUser: makeRegularUser({ id: 5 }) });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
// No button visible since canGenerateKey is false
expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
expect(UserUtils.generateApiKey).not.toHaveBeenCalled();
});
it('does not update key when generateApiKey returns a response without key/raw_key', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
// button text should NOT change since no key was received
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
});
it('shows the generated API key in a text input', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
const keyInput = screen.getByTestId('textinput-API Key');
expect(keyInput).toHaveValue('show-me');
});
});
});
// API key revocation
describe('API key revocation', () => {
it('shows "Revoke API Key" when a key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
/>
);
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
});
it('hides "Revoke API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
});
it('calls revokeApiKey with user_id and clears the key on success', async () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
/>
);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(UserUtils.revokeApiKey).toHaveBeenCalledWith({ user_id: 2 });
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
});
it('updates auth store when admin revokes their own key', async () => {
const admin = makeAdminUser({ id: 1, api_key: 'my-key' });
const { mockSetUser } = setupMocks({ authUser: admin });
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(
expect.objectContaining({ api_key: null })
);
});
});
it('does not clear key when revokeApiKey returns success: false', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: false });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'still-here' })}
/>
);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
});
});
it("does not update auth store when revoking another user's key", async () => {
const admin = makeAdminUser({ id: 1 });
const { mockSetUser } = setupMocks({ authUser: admin });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'other-key' })}
/>
);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(mockSetUser).not.toHaveBeenCalled();
});
});
});
// Copy to clipboard
describe('copy API key to clipboard', () => {
it('calls copyToClipboard with the current key', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'copy-me' })}
/>
);
const copyButton = screen.getByTestId('icon-copy').closest('button');
fireEvent.click(copyButton);
expect(copyToClipboard).toHaveBeenCalledWith(
'copy-me',
expect.objectContaining({ successTitle: 'API Key Copied!' })
);
});
});
// Form submission
describe('form submission', () => {
it('calls createUser when no user prop is given', async () => {
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(UserUtils.createUser).toHaveBeenCalled();
});
});
it('calls updateUser when editing an existing user', async () => {
const admin = makeAdminUser();
setupMocks({ authUser: admin });
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(UserUtils.updateUser).toHaveBeenCalledWith(
2,
expect.any(Object),
true, // isAdmin
admin
);
});
});
it('updates auth store when admin saves their own profile', async () => {
const admin = makeAdminUser({ id: 1 });
const updatedAdmin = { ...admin, email: 'new@example.com' };
const { mockSetUser } = setupMocks({ authUser: admin });
vi.mocked(UserUtils.updateUser).mockResolvedValue(updatedAdmin);
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(updatedAdmin);
});
});
it('resets form and calls onClose after successful submission', async () => {
setupMocks({ authUser: makeAdminUser() });
const onClose = vi.fn();
render(<User isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockForm.reset).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
it('removes password from payload when updating and payload.password is falsy', async () => {
const admin = makeAdminUser();
setupMocks({ authUser: admin });
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({
user_level: 3,
password: '',
});
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
const payload = vi.mocked(UserUtils.updateUser).mock.calls[0][1];
expect(payload).not.toHaveProperty('password');
});
});
});
// useEffect
describe('useEffect on user prop', () => {
it('calls userToFormValues and sets form values when user has an id', () => {
const user = makeRegularUser({ id: 2 });
vi.mocked(UserUtils.userToFormValues).mockReturnValue({
username: 'user1',
});
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} user={user} />);
expect(UserUtils.userToFormValues).toHaveBeenCalledWith(user);
expect(mockForm.setValues).toHaveBeenCalledWith({ username: 'user1' });
});
it('resets form when no user is provided', () => {
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} />);
expect(mockForm.reset).toHaveBeenCalled();
});
});
// XC password generation
describe('XC password generation', () => {
it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const rotateButton = screen
.getByTestId('icon-rotate-ccw-key')
.closest('button');
fireEvent.click(rotateButton);
expect(mockForm.setValues).toHaveBeenCalledWith(
expect.objectContaining({ xc_password: expect.any(String) })
);
});
it('disables the XC password rotate button for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
const rotateButton = screen
.getByTestId('icon-rotate-ccw-key')
.closest('button');
expect(rotateButton).toBeDisabled();
});
});
// Channel profiles logic
describe('channel profiles logic', () => {
const profiles = { 1: { id: 1, name: 'HD' }, 2: { id: 2, name: 'SD' } };
it('excludes "All" (0) when other profiles are selected alongside it', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '1,2' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'channel_profiles',
expect.not.arrayContaining(['0'])
);
});
it('sets only ["0"] when "All" is newly selected', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '0' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
'0',
]);
});
it('allows multiple non-all profiles together', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '1,2' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
'1',
'2',
]);
});
});
});

View file

@ -0,0 +1,279 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Utility mocks
vi.mock('../../../utils/forms/UserAgentUtils.js', () => ({
addUserAgent: vi.fn(),
updateUserAgent: vi.fn(),
getResolver: vi.fn(() => undefined),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Button: ({ children, disabled, type }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
data-testid="checkbox-is-active"
checked={checked}
onChange={onChange}
/>
{label}
</label>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Space: () => <div />,
TextInput: ({ label, error, ...rest }) => (
<div>
<label>{label}</label>
<input data-testid={`input-${label}`} aria-label={label} {...rest} />
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import * as UserAgentUtils from '../../../utils/forms/UserAgentUtils.js';
import UserAgent from '../UserAgent';
// Helpers
const makeUserAgent = (overrides = {}) => ({
id: 1,
name: 'Chrome',
user_agent: 'Mozilla/5.0',
description: 'Chrome browser',
is_active: true,
...overrides,
});
describe('UserAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(UserAgentUtils.addUserAgent).mockResolvedValue({});
vi.mocked(UserAgentUtils.updateUserAgent).mockResolvedValue({});
vi.mocked(UserAgentUtils.getResolver).mockReturnValue(undefined);
});
// Visibility
describe('visibility', () => {
it('renders nothing when isOpen is false', () => {
render(<UserAgent isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders the modal when isOpen is true', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders modal title "User-Agent"', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('User-Agent');
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<UserAgent isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Default values
describe('default values', () => {
it('renders empty fields when no userAgent prop is given', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('input-Name')).toHaveValue('');
expect(screen.getByTestId('input-User-Agent')).toHaveValue('');
expect(screen.getByTestId('input-Description')).toHaveValue('');
});
it('pre-fills fields from userAgent prop', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
expect(screen.getByTestId('input-User-Agent')).toHaveValue('Mozilla/5.0');
expect(screen.getByTestId('input-Description')).toHaveValue(
'Chrome browser'
);
});
it('checks Is Active checkbox when userAgent.is_active is true', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: true })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: false })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
it('defaults Is Active to checked when no userAgent prop given', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
});
// Field interactions
describe('field interactions', () => {
it('toggles Is Active checkbox', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: true })}
/>
);
const checkbox = screen.getByTestId('checkbox-is-active');
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
});
});
// Form submission
describe('form submission', () => {
it('calls addUserAgent when no userAgent id is given', async () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.addUserAgent).toHaveBeenCalled();
});
});
it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.updateUserAgent).toHaveBeenCalledWith(
1,
expect.objectContaining({ name: 'Chrome' })
);
});
});
it('does not call addUserAgent when updating', async () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.addUserAgent).not.toHaveBeenCalled();
});
});
it('does not call updateUserAgent when creating', async () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.updateUserAgent).not.toHaveBeenCalled();
});
});
it('calls onClose after successful submission', async () => {
const onClose = vi.fn();
render(<UserAgent isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('passes is_active value in the submitted payload', async () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
const checkbox = screen.getByTestId('checkbox-is-active');
fireEvent.click(checkbox); // uncheck it
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.addUserAgent).toHaveBeenCalledWith(
expect.objectContaining({ is_active: false })
);
});
});
});
// useEffect reset
describe('form reset on userAgent change', () => {
it('resets form values when userAgent prop changes', () => {
const { rerender } = render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
rerender(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({
id: 2,
name: 'Firefox',
user_agent: 'Gecko/20100101',
})}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
});
it('resets to empty when userAgent prop is removed', () => {
const { rerender } = render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
rerender(<UserAgent isOpen={true} onClose={vi.fn()} userAgent={null} />);
expect(screen.getByTestId('input-Name')).toHaveValue('');
});
});
});

View file

@ -0,0 +1,477 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mock
vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
// lucide-react
vi.mock('lucide-react', () => ({
CircleCheck: () => <svg data-testid="icon-circle-check" />,
CircleX: () => <svg data-testid="icon-circle-x" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled, color, variant }) => (
<button
onClick={onClick}
disabled={disabled}
data-color={color}
data-variant={variant}
>
{children}
</button>
),
Checkbox: ({ label, checked, onChange, disabled }) => (
<label>
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(e) =>
onChange?.({ currentTarget: { checked: e.target.checked } })
}
disabled={disabled}
/>
{label}
</label>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
SimpleGrid: ({ children }) => <div data-testid="simple-grid">{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, value, onChange, placeholder }) => (
<input
aria-label={label ?? placeholder}
value={value}
onChange={onChange}
placeholder={placeholder}
/>
),
SegmentedControl: ({ value, onChange, data }) => (
<div data-testid="segmented-control">
{data.map((item) => (
<button
key={item.value ?? item}
data-testid={`segment-${item.value ?? item}`}
onClick={() => onChange(item.value ?? item)}
data-active={value === (item.value ?? item) ? 'true' : 'false'}
>
{item.label ?? item}
</button>
))}
</div>
),
}));
// Imports after mocks
import useVODStore from '../../../store/useVODStore';
import VODCategoryFilter from '../VODCategoryFilter';
// Helpers
const makeCategories = () => [
{
id: 1,
name: 'Action',
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'movies',
},
{
id: 2,
name: 'Comedy',
m3u_accounts: [{ m3u_account: 10, enabled: false }],
category_type: 'movies',
},
{
id: 3,
name: 'Drama',
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'movies',
},
{
id: 4,
name: 'News',
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'series',
},
];
const makePlaylist = (overrides = {}) => ({
id: 10,
name: 'My Playlist',
...overrides,
});
const categoriesToDict = (arr) =>
arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
const setupMocks = ({ categories = makeCategories() } = {}) => {
const dict = Array.isArray(categories)
? categoriesToDict(categories)
: categories;
vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
};
const defaultProps = (overrides = {}) => {
return {
playlist: makePlaylist(),
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
setCategoryStates: vi.fn(),
type: 'movies',
autoEnableNewGroups: true,
setAutoEnableNewGroups: vi.fn(),
...overrides,
};
};
//
describe('VODCategoryFilter', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
// Rendering
describe('rendering', () => {
it('renders without crashing', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByTestId('simple-grid')).toBeInTheDocument();
});
it('renders a button for each category matching the type and playlist', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
it('does not render categories of a different type', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.queryByRole('checkbox', { name: 'News' })
).not.toBeInTheDocument();
});
it('renders the text filter input', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument();
});
it('renders the segmented status control', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
});
it('renders Enable All and Disable All buttons', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByText('Select Visible')).toBeInTheDocument();
expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
});
it('renders the Auto-enable new groups checkbox', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.getByLabelText(/automatically enable new/i)
).toBeInTheDocument();
});
});
// Text filter
describe('text filter', () => {
it('hides categories that do not match the filter', () => {
render(<VODCategoryFilter {...defaultProps()} />);
const input = screen.getByPlaceholderText(/filter/i);
fireEvent.change(input, { target: { value: 'act' } });
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Comedy' })
).not.toBeInTheDocument();
});
it('is case-insensitive', () => {
render(<VODCategoryFilter {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'COMEDY' },
});
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('shows all categories when filter is cleared', () => {
render(<VODCategoryFilter {...defaultProps()} />);
const input = screen.getByPlaceholderText(/filter/i);
fireEvent.change(input, { target: { value: 'act' } });
fireEvent.change(input, { target: { value: '' } });
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
it('shows no categories when filter matches nothing', () => {
render(<VODCategoryFilter {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'zzznomatch' },
});
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
});
// Status filter
describe('status filter', () => {
it('shows only enabled categories when "Enabled" segment is selected', () => {
const props = defaultProps({
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-enabled'));
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Comedy' })
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
it('shows only disabled categories when "Disabled" segment is selected', () => {
const props = defaultProps({
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-disabled'));
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
it('shows all categories when "All" segment is active', () => {
const props = defaultProps({
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-disabled'));
fireEvent.click(screen.getByTestId('segment-all'));
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
});
// Combined filters
describe('combined text + status filters', () => {
it('applies both text and status filters simultaneously', () => {
const props = defaultProps();
render(<VODCategoryFilter {...props} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'o' },
});
fireEvent.click(screen.getByTestId('segment-disabled'));
// "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
});
// Enable All / Disable All
describe('Enable All button', () => {
it('calls setCategoryStates with all visible categories set to true', () => {
const setCategoryStates = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [
{ id: 1, name: 'Action', enabled: false },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: false },
],
})}
/>
);
fireEvent.click(screen.getByText('Select Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(true);
expect(called.find((s) => s.id === 2).enabled).toBe(true);
expect(called.find((s) => s.id === 3).enabled).toBe(true);
});
it('only enables filtered categories when a text filter is active', () => {
const setCategoryStates = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [
{ id: 1, name: 'Action', enabled: false },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: false },
],
})}
/>
);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'act' },
});
fireEvent.click(screen.getByText('Select Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(true);
// Comedy and Drama were filtered out their state should be unchanged
expect(called.find((s) => s.id === 2).enabled).toBe(false);
expect(called.find((s) => s.id === 3).enabled).toBe(false);
});
});
describe('Disable All button', () => {
it('calls setCategoryStates with all visible categories set to false', () => {
const setCategoryStates = vi.fn();
render(<VODCategoryFilter {...defaultProps({ setCategoryStates })} />);
fireEvent.click(screen.getByText('Deselect Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(false);
expect(called.find((s) => s.id === 2).enabled).toBe(false);
expect(called.find((s) => s.id === 3).enabled).toBe(false);
});
it('only disables filtered categories when a text filter is active', () => {
const setCategoryStates = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: true },
{ id: 3, name: 'Drama', enabled: true },
],
})}
/>
);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'comedy' },
});
fireEvent.click(screen.getByText('Deselect Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 2).enabled).toBe(false);
expect(called.find((s) => s.id === 1).enabled).toBe(true);
expect(called.find((s) => s.id === 3).enabled).toBe(true);
});
});
// Auto-enable new groups
describe('autoEnableNewGroups checkbox', () => {
it('calls setAutoEnableNewGroups(true) when checked', () => {
const setAutoEnableNewGroups = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
autoEnableNewGroups: false,
setAutoEnableNewGroups,
})}
/>
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(true);
});
it('calls setAutoEnableNewGroups(false) when unchecked', () => {
const setAutoEnableNewGroups = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
autoEnableNewGroups: true,
setAutoEnableNewGroups,
})}
/>
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(false);
});
});
// No playlist / empty categories
describe('edge cases', () => {
it('renders no category buttons when categories list is empty', () => {
setupMocks({ categories: [] });
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('renders no category buttons when categoryStates is empty', () => {
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('renders categories only for the matching playlist id', () => {
setupMocks({
categories: [
...makeCategories(),
{
id: 99,
name: 'Foreign',
m3u_accounts: [{ m3u_account: 99, enabled: true }],
category_type: 'movies',
},
],
});
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.queryByRole('button', { name: 'Foreign' })
).not.toBeInTheDocument();
});
});
});

View file

@ -1,5 +1,5 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
getChangedSettings,
parseSettings,
@ -13,6 +13,7 @@ import {
Flex,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
@ -34,6 +35,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
path: '',
exists: false,
});
const isSavingRef = useRef(false);
const form = useForm({
mode: 'controlled',
@ -45,7 +47,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
}, [active]);
useEffect(() => {
if (settings) {
if (settings && !isSavingRef.current) {
const formValues = parseSettings(settings);
form.setValues(formValues);
@ -114,17 +116,27 @@ const DvrSettingsForm = React.memo(({ active }) => {
const onSubmit = async () => {
setSaved(false);
isSavingRef.current = true;
const changedSettings = getChangedSettings(form.getValues(), settings);
// Update each changed setting in the backend (create if missing)
try {
await saveChangedSettings(settings, changedSettings);
isSavingRef.current = false;
const latestSettings = useSettingsStore.getState().settings;
if (latestSettings) {
const formValues = parseSettings(latestSettings);
form.setValues(formValues);
if (formValues['comskip_custom_path']) {
setComskipConfig((prev) => ({
path: formValues['comskip_custom_path'],
exists: prev.exists,
}));
}
}
setSaved(true);
} catch (error) {
// Error notifications are already shown by API functions
// Just don't show the success message
isSavingRef.current = false;
console.error('Error saving settings:', error);
}
};
@ -136,13 +148,39 @@ const DvrSettingsForm = React.memo(({ active }) => {
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<Switch
label="Enable Comskip (remove commercials after recording)"
label="Enable Comskip (commercial detection after recording)"
{...form.getInputProps('comskip_enabled', {
type: 'checkbox',
})}
id="comskip_enabled"
name="comskip_enabled"
/>
<Select
label="Comskip mode"
description="Cut: permanently removes commercials from the file. Mark: keeps the file intact and writes an EDL file for players that support EDL-based commercial skipping."
data={[
{ value: 'cut', label: 'Cut (remove commercials from file)' },
{
value: 'mark',
label: 'Mark (store timestamps, keep file intact)',
},
]}
{...form.getInputProps('comskip_mode')}
id="comskip_mode"
name="comskip_mode"
/>
<Select
label="Hardware acceleration"
description="Offloads video decoding to a hardware decoder. Requires the corresponding driver/device to be available inside the container."
data={[
{ value: 'none', label: 'None (software decode)' },
{ value: 'cuvid', label: 'NVIDIA NVDEC (--cuvid)' },
{ value: 'qsv', label: 'Intel Quick Sync (--qsv)' },
]}
{...form.getInputProps('comskip_hw_accel')}
id="comskip_hw_accel"
name="comskip_hw_accel"
/>
<TextInput
label="Custom comskip.ini path"
description="Leave blank to use the built-in defaults."

View file

@ -26,6 +26,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const isModular =
useSettingsStore((s) => s.environment.env_mode) === 'modular';
const ipLookupEnvDisabled = useSettingsStore(
(s) => s.environment.ip_lookup_env_disabled
);
const [saved, setSaved] = useState(false);
@ -108,6 +111,23 @@ const SystemSettingsForm = React.memo(({ active }) => {
id="auto_import_mapped_files"
/>
</Group>
{!ipLookupEnvDisabled && (
<Group justify="space-between" pt={5}>
<div>
<Text size="sm" fw={500}>
Enable IP Lookup
</Text>
<Text size="xs" c="dimmed">
Fetch and display the instance's public IP and country flag in the
sidebar.
</Text>
</div>
<Switch
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
id="enable_ip_lookup"
/>
</Group>
)}
{isModular && (
<>
<Divider my="md" label="Connection Security" labelPosition="left" />

View file

@ -79,6 +79,22 @@ vi.mock('@mantine/core', () => ({
onChange={onChange}
/>
),
Select: ({ label, id, name, data, ...rest }) => (
<select
data-testid={id}
id={id}
name={name}
aria-label={label}
value={rest.value ?? ''}
onChange={(e) => rest.onChange?.(e.target.value)}
>
{(data || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, id, name, placeholder, ...rest }) => (
<input
@ -115,6 +131,8 @@ import {
const mockFormValues = {
comskip_enabled: false,
comskip_custom_path: '',
comskip_mode: 'cut',
comskip_hw_accel: 'none',
pre_offset_minutes: 0,
post_offset_minutes: 0,
tv_template: '',
@ -199,6 +217,20 @@ describe('DvrSettingsForm', () => {
});
});
it('renders the comskip_mode select', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('comskip_mode')).toBeInTheDocument();
});
});
it('renders the comskip_hw_accel select', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('comskip_hw_accel')).toBeInTheDocument();
});
});
it('renders the file input for comskip.ini upload', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {

View file

@ -0,0 +1,13 @@
import React from 'react';
export const GitHubIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
export const DiscordIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
);

View file

@ -291,7 +291,6 @@ const ChannelsTable = ({ onReady }) => {
const setSelectedChannelIds = useChannelsTableStore(
(s) => s.setSelectedChannelIds
);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const setExpandedChannelId = useChannelsTableStore(
(s) => s.setExpandedChannelId
);
@ -613,7 +612,7 @@ const ChannelsTable = ({ onReady }) => {
table.setSelectedTableIds([]);
if (selectedChannelIds.length > 0) {
if (table.selectedTableIds.length > 0) {
// Use bulk delete for multiple selections
setIsBulkDelete(true);
setChannelToDelete(null);
@ -1726,7 +1725,7 @@ const ChannelsTable = ({ onReady }) => {
/>
<ChannelBatchForm
channelIds={selectedChannelIds}
channelIds={table.selectedTableIds}
isOpen={channelBatchModalOpen}
onClose={closeChannelBatchForm}
/>

View file

@ -76,6 +76,7 @@ const CustomTable = ({ table }) => {
tableCellProps={table.tableCellProps}
enableDragDrop={table.enableDragDrop}
selectedTableIdsSet={table.selectedTableIdsSet}
handleRowClickRef={table.handleRowClickRef}
/>
</Box>
);

View file

@ -17,6 +17,7 @@ const MemoizedTableRow = React.memo(
isSelected,
renderBodyCellRef,
expandedRowRendererRef,
handleRowClickRef,
getRowStyles,
tableCellProps,
enableDragDrop,
@ -35,11 +36,15 @@ const MemoizedTableRow = React.memo(
<Box
key={`tr-${row.id}`}
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'} ${customClassName}`}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
onClick={(e) => handleRowClickRef?.current?.(row.original.id, e)}
style={{
display: 'flex',
width: '100%',
minWidth: '100%',
...(row.getIsSelected() && {
...(isSelected && {
backgroundColor: '#163632',
}),
...customRowStyles,
@ -98,6 +103,7 @@ const CustomTableBody = ({
tableCellProps,
enableDragDrop = false,
selectedTableIdsSet,
handleRowClickRef,
}) => {
// Store callbacks in refs so memoized rows always access the latest versions
// without the function references themselves triggering re-renders.
@ -124,6 +130,7 @@ const CustomTableBody = ({
}
renderBodyCellRef={renderBodyCellRef}
expandedRowRendererRef={expandedRowRendererRef}
handleRowClickRef={handleRowClickRef}
getRowStyles={getRowStyles}
tableCellProps={tableCellProps}
enableDragDrop={enableDragDrop}

View file

@ -30,7 +30,11 @@ const useTable = ({
selectedTableIdsRef.current = selectedTableIds;
const [expandedRowIds, setExpandedRowIds] = useState([]);
const [lastClickedId, setLastClickedId] = useState(null);
const [isShiftKeyDown, setIsShiftKeyDown] = useState(false);
const lastClickedIdRef = useRef(lastClickedId);
lastClickedIdRef.current = lastClickedId;
const allRowIdsRef = useRef(allRowIds);
allRowIdsRef.current = allRowIds;
const handleRowClickRef = useRef(null);
// Use shared table preferences hook
const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
@ -39,21 +43,17 @@ const useTable = ({
// Event handlers for shift key detection with improved handling
const handleKeyDown = useCallback((e) => {
if (e.key === 'Shift') {
setIsShiftKeyDown(true);
// Apply the class to disable text selection immediately
document.body.classList.add('shift-key-active');
// Set a style attribute directly on body for extra assurance
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
document.body.style.msUserSelect = 'none';
document.body.style.cursor = 'pointer';
document.body.style.cursor = 'default';
}
}, []);
const handleKeyUp = useCallback((e) => {
if (e.key === 'Shift') {
setIsShiftKeyDown(false);
// Remove the class when shift is released
document.body.classList.remove('shift-key-active');
// Reset the style attributes
document.body.style.removeProperty('user-select');
@ -68,27 +68,19 @@ const useTable = ({
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
// Also detect blur/focus events to handle cases where shift is held and window loses focus
window.addEventListener('blur', () => {
setIsShiftKeyDown(false);
const handleBlur = () => {
document.body.classList.remove('shift-key-active');
document.body.style.removeProperty('user-select');
document.body.style.removeProperty('-webkit-user-select');
document.body.style.removeProperty('-ms-user-select');
document.body.style.removeProperty('cursor');
});
};
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', () => {
setIsShiftKeyDown(false);
document.body.classList.remove('shift-key-active');
document.body.style.removeProperty('user-select');
document.body.style.removeProperty('-webkit-user-select');
document.body.style.removeProperty('-ms-user-select');
document.body.style.removeProperty('cursor');
});
window.removeEventListener('blur', handleBlur);
};
}, [handleKeyDown, handleKeyUp]);
@ -129,16 +121,6 @@ const useTable = ({
}
};
const rowSelection = useMemo(() => {
const selection = {};
table.getRowModel().rows.forEach((row) => {
if (selectedTableIdsSet.has(row.original.id)) {
selection[row.id] = true;
}
});
return selection;
}, [selectedTableIdsSet, table.getRowModel().rows]);
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
@ -162,22 +144,22 @@ const useTable = ({
// Handle the shift+click selection
const handleShiftSelect = (rowId, isShiftKey) => {
if (!isShiftKey || lastClickedId === null) {
if (!isShiftKey || lastClickedIdRef.current === null) {
// Normal selection behavior
setLastClickedId(rowId);
return false; // Return false to indicate we're not handling it
}
// Handle shift-click range selection
const currentIndex = allRowIds.indexOf(rowId);
const lastIndex = allRowIds.indexOf(lastClickedId);
const currentIndex = allRowIdsRef.current.indexOf(rowId);
const lastIndex = allRowIdsRef.current.indexOf(lastClickedIdRef.current);
if (currentIndex === -1 || lastIndex === -1) return false;
// Determine range
const startIndex = Math.min(currentIndex, lastIndex);
const endIndex = Math.max(currentIndex, lastIndex);
const rangeIds = allRowIds.slice(startIndex, endIndex + 1);
const rangeIds = allRowIdsRef.current.slice(startIndex, endIndex + 1);
// Preserve existing selections outside the range
const idsOutsideRange = selectedTableIdsRef.current.filter(
@ -190,6 +172,29 @@ const useTable = ({
return true; // Return true to indicate we've handled it
};
const handleRowClick = (rowId, e) => {
if (
e.target.closest(
'button, a, input, select, textarea, [role="menuitem"], [role="option"], [role="button"]'
)
) {
return;
}
if (e.shiftKey) {
handleShiftSelect(rowId, true);
} else if (e.ctrlKey || e.metaKey) {
const newSet = new Set(selectedTableIdsRef.current);
if (newSet.has(rowId)) {
newSet.delete(rowId);
} else {
newSet.add(rowId);
setLastClickedId(rowId);
}
updateSelectedTableIds([...newSet]);
}
};
handleRowClickRef.current = handleRowClick;
const renderBodyCell = ({ row, cell }) => {
if (bodyCellRenderFns[cell.column.id]) {
return bodyCellRenderFns[cell.column.id]({ row, cell });
@ -228,7 +233,8 @@ const useTable = ({
return (
<Center
style={{ width: '100%', cursor: 'pointer' }}
onClick={() => {
onClick={(e) => {
e.stopPropagation();
onRowExpansion(row);
}}
>
@ -252,14 +258,13 @@ const useTable = ({
...options,
selectedTableIds,
updateSelectedTableIds,
rowSelection,
allRowIds,
onSelectAllChange,
selectedTableIdsSet,
expandedRowIds,
expandedRowRenderer,
setSelectedTableIds,
isShiftKeyDown, // Include shift key state in the table instance
handleRowClickRef,
headerPinned,
setHeaderPinned,
tableSize,
@ -269,7 +274,6 @@ const useTable = ({
selectedTableIdsSet,
expandedRowIds,
allRowIds,
isShiftKeyDown,
options,
headerPinned,
setHeaderPinned,

Some files were not shown because too many files have changed in this diff Show more