Merge pull request #1327 from Dispatcharr/dev

Dispatcharr - v0.26.0
This commit is contained in:
SergeantPanda 2026-06-07 16:26:12 -05:00 committed by GitHub
commit 2d4e65176f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
137 changed files with 20450 additions and 3237 deletions

View file

@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **EPG logo auto-apply for XMLTV and Schedules Direct.** Channel logos are applied from `EPGData.icon_url` through a shared helper used by bulk "Set Logos from EPG", optional per-source auto-apply on refresh (`auto_apply_epg_logos` in source `custom_properties`), and a new `epg_source_id` option on the set-logos-from-epg API. Large libraries are processed in 500-channel chunks without loading every mapped row into memory.
- **Schedules Direct EPG integration.** Dispatcharr now supports Schedules Direct as a first-class EPG source type alongside XMLTV and dummy EPG, with credential auth, lineup management, and guide refresh through the existing EPG pipeline and WebSocket progress. (Closes #1246) — Thanks [@Shokkstokk](https://github.com/Shokkstokk)
- Lineup manager in EPG source settings: search by postal code, add/remove up to four active lineups, with SD's six-adds-per-24-hours limit and midnight-UTC reset surfaced in the UI.
- MD5 delta refresh skips unchanged schedule and program downloads; a two-hour minimum interval between full refreshes is enforced (bypassable via force refresh).
- Station logos in dark, light, gray, or white variants (`logo_style`), stored in `EPGData.icon_url` alongside XMLTV channel icons.
- Optional program poster fetch (off by default): configurable style preference (SD Recommended via Gracenote's `primary` flag, or portrait/landscape banner/iconic variants with fallbacks), served through a backend proxy cached by nginx on first view.
- XMLTV-compatible cast output (`role` for character names, `guest` for guest stars).
- Lightweight stations-only fetch on source creation so EPG entries exist for auto-matching before the first full schedule pull.
- **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
- **`send_websocket_update` now detects Celery worker context when gevent is monkey-patched.** EPG refresh progress (including Schedules Direct) uses the shared `send_epg_update` helper instead of a duplicate synchronous Redis sender. In Celery prefork workers that inherit gevent patching, WebSocket messages are delivered synchronously via the existing `_gevent_ws_send` Redis path; uWSGI continues to use `gevent.spawn` as before.
- **Schedules Direct EPG delete confirmation shows username only.** The delete dialog no longer surfaces password or API key fields for credential-based SD sources.
- **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).
- **nginx logo/poster proxy cache moved to `/data/logo_cache`.** `proxy_cache_path` now uses the persistent data volume instead of `/app/logo_cache`, and the init script ensures the directory exists with correct ownership on container start.
- **`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)
- **Official plugin repository manifest URL moved to GitHub Pages.** The default `OFFICIAL_REPO_URL` now points to `https://dispatcharr.github.io/Plugins/manifest.json` instead of the raw GitHub content URL. GitHub Pages avoids opaque caching on raw content that could serve stale manifests without indication. A data migration updates existing `PluginRepo` rows marked `is_official=True` in place; fresh installs pick up the new URL from the model default after the seed migration runs. — Thanks [@sethwv](https://github.com/sethwv)
### Performance
- **M3U and EPG channel logo URLs no longer call `build_absolute_uri_with_port()` per channel.** The host/port/scheme base URL and logo path prefix/suffix are computed once per request; each row appends only the logo ID. M3U proxy stream URLs use the same pattern for channel UUIDs.
- **`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)
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (2 moderate, 2 high, 1 critical):
- Updated `react-router` and `react-router-dom` 7.13.0 → 7.17.0, resolving **high** unauthenticated RCE via turbo-stream TYPE_ERROR deserialization ([GHSA-49rj-9fvp-4h2h](https://github.com/advisories/GHSA-49rj-9fvp-4h2h)), **high** open redirect via protocol-relative `//` paths ([GHSA-2j2x-hqr9-3h42](https://github.com/advisories/GHSA-2j2x-hqr9-3h42)), **high** XSS in unstable RSC redirect handling via `javascript:` targets ([GHSA-8646-j5j9-6r62](https://github.com/advisories/GHSA-8646-j5j9-6r62)), **high** stored XSS via unescaped `Location` header in prerendered redirect HTML ([GHSA-f22v-gfqf-p8f3](https://github.com/advisories/GHSA-f22v-gfqf-p8f3)), **high** DoS via unbounded path expansion in the `__manifest` endpoint ([GHSA-8x6r-g9mw-2r78](https://github.com/advisories/GHSA-8x6r-g9mw-2r78)), and **high** DoS via reflected user input in single-fetch ([GHSA-rxv8-25v2-qmq8](https://github.com/advisories/GHSA-rxv8-25v2-qmq8))
- Updated `vitest` 3.2.4 → 4.1.8, resolving **critical** arbitrary file read and execution when the Vitest UI server is listening ([GHSA-5xrq-8626-4rwp](https://github.com/advisories/GHSA-5xrq-8626-4rwp))
- Updated `brace-expansion` 5.0.5 → 5.0.6, resolving **moderate** DoS when large numeric ranges defeat the documented `max` limit ([GHSA-jxxr-4gwj-5jf2](https://github.com/advisories/GHSA-jxxr-4gwj-5jf2))
- Updated `ws` 8.19.0 → 8.21.0, resolving **moderate** uninitialized memory disclosure ([GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx))
### Fixed
- **HDHR lineup and discovery URLs now use the shared port-aware URI builder.** `lineup.json`, `discover.json`, and `device.xml` previously called Django's `request.build_absolute_uri()`, which drops non-standard ports behind reverse proxies and on dev installs. They now use `build_absolute_uri_with_port()` from `core/utils.py`, matching M3U, EPG, XC, and logo cache URLs.
- **EPG channel list did not refresh after a source finished parsing channels.** The `epg_refresh` WebSocket handler closed over a stale `epgs` snapshot from when the socket connected. When a newly created source was not found in that snapshot, the handler updated progress and returned early without calling `fetchEPGData()`, so the channel picker and EPG assignment UI stayed empty until a full page reload. The handler now reads the current store via `getState()` and still calls `fetchEPGData()` when `parsing_channels` completes.
- **DVR recordings no longer stop immediately when FFmpeg exits mid-stream.** If FFmpeg crashes, stalls, or loses the source before the scheduled end time, `run_recording` now restarts it in-process instead of going straight to HLS→MKV concat and marking the recording complete. Each restart reuses the same HLS working directory and continues segment numbering (`append_list`, `-start_number`) so the final MKV is a single continuous file. Retries are bounded by a per-outage time window matching live-proxy client tolerance (`STREAM_TIMEOUT` + `FAILOVER_GRACE_PERIOD`, default 80s); the window resets whenever new segments resume, so a long recording can survive multiple separate outages. Reconnect pacing between attempts follows the same `min(0.25 × attempt, 3s)` backoff used by the live-proxy `StreamManager`. Input demuxing also uses `-err_detect ignore_err` to tolerate minor TS corruption without aborting the process. If the outage window expires without recovery, the recording is saved as `interrupted` with `ffmpeg_outage_window_exhausted` rather than falsely reported as `completed`. (Closes #1170)
- **DVR HLS→MKV concat now tolerates timestamp splices and corrupt segments.** The finalize step (and startup recovery concat) previously used a bare `ffmpeg -f concat -c copy`, which could fail on truncated tail segments or PTS discontinuities from FFmpeg restarts mid-recording. Concat now uses a shared `_dvr_build_hls_concat_cmd` helper with `-fflags +genpts+igndts+discardcorrupt`, `-err_detect ignore_err`, and `-avoid_negative_ts make_zero`; the existing MP4-intermediate fallback path uses the same tolerant input flags.
- **DVR FFmpeg restart no longer skips HLS segment numbers.** On restart, `-start_number` was set to the existing segment count while `append_list` also incremented the internal sequence for each segment reloaded from `index.m3u8`, so the first post-restart segment could land at roughly double the expected index (e.g. `seg_00028.ts` after 14 segments). Restarts now pass `-start_number 0` when the playlist already lists segments and let `append_list` continue numbering; loose `.ts` files without a playlist still seed from the highest filename index + 1.
- **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)
- **Compact numbering repack is now idempotent.** Auto-synced channel numbers reshuffled within their configured range on every sync, even when the provider returned no changes. The compact repack queried channels with no `ORDER BY`, so packing followed PostgreSQL's physical row order, which drifts after each repack's UPDATEs and autovacuum. The channel query now uses `.order_by("id")` (creation order tracks provider stream order for the default "provider" sort), and explicit name / tvg_id / updated_at sorts carry `c.id` as a secondary tiebreaker so equal values keep a stable relative order instead of churning. (Fixes #1321) — Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.25.1] - 2026-05-23
### Fixed

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

@ -1525,32 +1525,52 @@ class ChannelViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["post"], url_path="set-logos-from-epg")
def set_logos_from_epg(self, request):
"""
Trigger a Celery task to set channel logos from EPG data
Trigger a Celery task to set channel logos from EPG data.
Provide channel_ids or epg_source_id (not both).
"""
from .tasks import set_channels_logos_from_epg
data = request.data
channel_ids = data.get("channel_ids", [])
channel_ids = data.get("channel_ids")
epg_source_id = data.get("epg_source_id")
if not channel_ids:
if channel_ids and epg_source_id:
return Response(
{"error": "channel_ids is required"},
{"error": "Provide either channel_ids or epg_source_id, not both"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(channel_ids, list):
if not channel_ids and not epg_source_id:
return Response(
{"error": "channel_ids must be a list"},
{"error": "channel_ids or epg_source_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Start the Celery task
task = set_channels_logos_from_epg.delay(channel_ids)
if channel_ids is not None:
if not isinstance(channel_ids, list):
return Response(
{"error": "channel_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
if not channel_ids:
return Response(
{"error": "channel_ids cannot be empty"},
status=status.HTTP_400_BAD_REQUEST,
)
task = set_channels_logos_from_epg.delay(channel_ids=channel_ids)
channel_count = len(channel_ids)
else:
from .utils import channels_with_epg_icon_queryset
task = set_channels_logos_from_epg.delay(epg_source_id=epg_source_id)
channel_count = channels_with_epg_icon_queryset(
epg_source_id=epg_source_id,
).count()
return Response({
"message": f"Started EPG logo setting task for {len(channel_ids)} channels",
"message": f"Started EPG logo setting task for {channel_count} channels",
"task_id": task.id,
"channel_count": len(channel_ids)
"channel_count": channel_count,
})
@action(detail=False, methods=["post"], url_path="set-tvg-ids-from-epg")

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,12 +306,35 @@ 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.
# order_by("id") makes the pack deterministic. Without it the query
# returns rows in unspecified physical order, which shifts after the
# renumber's own UPDATEs and autovacuum, so the default "provider" sort
# below would repack channels into different numbers on every sync.
# id order is creation order, which tracks the provider stream order.
channels = list(
Channel.objects.filter(
auto_created=True,
auto_created_by_id=account_id,
channel_group_id=group_id,
).select_related("override")
channel_group_id__in=group_ids,
).select_related("override").order_by("id")
)
visible = []
@ -285,21 +349,22 @@ def _repack_inner(group_relation):
visible.append(ch)
# Sort the visible set by the group's configured channel_sort_order.
# Provider order (the default) preserves DB-iteration order which is
# roughly creation order; treat unrecognized values the same way.
# Provider order (the default) keeps the id order from the query above.
# Each explicit sort carries c.id as a secondary key so equal values
# (e.g. blank tvg_id) break ties deterministically instead of churning.
if sort_order == "name":
visible.sort(
key=lambda c: natural_sort_key(c.name or ""),
key=lambda c: (natural_sort_key(c.name or ""), c.id),
reverse=sort_reverse,
)
elif sort_order == "tvg_id":
visible.sort(
key=lambda c: c.tvg_id or "",
key=lambda c: (c.tvg_id or "", c.id),
reverse=sort_reverse,
)
elif sort_order == "updated_at":
visible.sort(
key=lambda c: c.updated_at,
key=lambda c: (c.updated_at, c.id),
reverse=sort_reverse,
)

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

@ -22,7 +22,7 @@ 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 +57,18 @@ class LogoSerializer(serializers.ModelSerializer):
return instance
def get_cache_url(self, obj):
# return f"/api/channels/logos/{obj.id}/cache/"
# Cache-busting: append a short hash of the logo's source URL so the browser
# fetches fresh when the logo changes (e.g., M3U logo replaced by SD logo).
# The backend ignores the 'v' parameter — it's purely for browser cache invalidation.
# See SD integration PR notes for context on why this was added.
import hashlib
url_hash = hashlib.md5((obj.url or '').encode()).hexdigest()[:8]
base_path = reverse("api:channels:logo-cache", args=[obj.id])
cache_url = f"{base_path}?v={url_hash}"
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])
return build_absolute_uri_with_port(request, cache_url)
return cache_url
def get_channel_count(self, obj):
"""Get the number of channels using this logo"""

View file

@ -201,10 +201,19 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
if instance.epg_data:
# Skip XMLTV program parser for SD sources — program data is handled
# by fetch_schedules_direct() directly.
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
return
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
parse_programs_for_tvg_id.delay(instance.epg_data.id)
# For new channels with EPG data, also refresh
elif created and instance.epg_data:
# Skip XMLTV program parser for SD sources
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
return
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
parse_programs_for_tvg_id.delay(instance.epg_data.id)

File diff suppressed because it is too large Load diff

View file

@ -196,15 +196,17 @@ class InitialConnectionRetryTests(TestCase):
base URL before falling back to the next candidate."""
def test_reconnect_max_constant_exists_in_run_recording(self):
"""run_recording must define a max-reconnect limit to prevent
infinite retries on the same broken base URL."""
"""run_recording must use a time-bounded FFmpeg outage window to prevent
infinite restarts when the source stream is permanently down."""
import inspect
from apps.channels.tasks import run_recording
from apps.channels.tasks import run_recording, _dvr_ffmpeg_retry_window_seconds
source = inspect.getsource(run_recording)
# The reconnection counter pattern must be present
self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0)
self.assertIn("reconnect", source.lower(),
"run_recording must contain reconnection logic")
"run_recording must contain input reconnection flags")
self.assertIn("_ffmpeg_outage_started", source)
self.assertIn("_ffmpeg_retry_window", source)
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,272 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from apps.channels.models import Channel, Logo
from apps.channels.utils import (
apply_logos_from_epg_icon_url,
apply_logos_from_epg_for_source,
auto_apply_epg_logos_enabled,
maybe_auto_apply_epg_logos,
)
from apps.epg.models import EPGData, EPGSource
User = get_user_model()
class AutoApplyEpgLogosEnabledTests(TestCase):
def test_enabled_when_flag_true(self):
self.assertTrue(
auto_apply_epg_logos_enabled({'auto_apply_epg_logos': True})
)
def test_disabled_when_flag_false_or_missing(self):
self.assertFalse(
auto_apply_epg_logos_enabled({'auto_apply_epg_logos': False})
)
self.assertFalse(auto_apply_epg_logos_enabled({}))
self.assertFalse(auto_apply_epg_logos_enabled(None))
class ApplyLogosFromEpgIconUrlTests(TestCase):
def setUp(self):
self.source = EPGSource.objects.create(
name='XML EPG',
source_type='xmltv',
url='http://example.com/epg.xml',
)
self.epg_one = EPGData.objects.create(
tvg_id='ch.one',
name='Channel One',
icon_url='https://example.com/one.png',
epg_source=self.source,
)
self.epg_two = EPGData.objects.create(
tvg_id='ch.two',
name='Channel Two',
icon_url='https://example.com/one.png',
epg_source=self.source,
)
self.channel_one = Channel.objects.create(
channel_number=1,
name='Channel One',
tvg_id='ch.one',
epg_data=self.epg_one,
)
self.channel_two = Channel.objects.create(
channel_number=2,
name='Channel Two',
tvg_id='ch.two',
epg_data=self.epg_two,
)
def test_creates_logo_and_updates_channels(self):
channels = Channel.objects.filter(
id__in=[self.channel_one.id, self.channel_two.id],
).select_related('epg_data', 'logo')
stats = apply_logos_from_epg_icon_url(channels)
self.assertEqual(stats['updated_count'], 2)
self.assertEqual(stats['created_logos_count'], 1)
self.assertEqual(Logo.objects.count(), 1)
self.channel_one.refresh_from_db()
self.channel_two.refresh_from_db()
self.assertEqual(self.channel_one.logo.url, 'https://example.com/one.png')
self.assertEqual(self.channel_two.logo_id, self.channel_one.logo_id)
def test_skips_channels_already_using_icon_url(self):
existing_logo = Logo.objects.create(
name='Existing',
url='https://example.com/one.png',
)
self.channel_one.logo = existing_logo
self.channel_one.save(update_fields=['logo'])
channels = Channel.objects.filter(
id__in=[self.channel_one.id, self.channel_two.id],
).select_related('epg_data', 'logo')
stats = apply_logos_from_epg_icon_url(channels)
self.assertEqual(stats['updated_count'], 1)
self.assertEqual(stats['created_logos_count'], 0)
def test_skips_channels_without_icon_url(self):
self.epg_one.icon_url = None
self.epg_one.save(update_fields=['icon_url'])
channels = Channel.objects.filter(
id__in=[self.channel_one.id, self.channel_two.id],
).select_related('epg_data', 'logo')
stats = apply_logos_from_epg_icon_url(channels)
self.assertEqual(stats['updated_count'], 1)
self.channel_one.refresh_from_db()
self.assertIsNone(self.channel_one.logo_id)
class ApplyLogosForSourceTests(TestCase):
def setUp(self):
self.source = EPGSource.objects.create(
name='XML EPG',
source_type='xmltv',
url='http://example.com/epg.xml',
)
self.other_source = EPGSource.objects.create(
name='Other EPG',
source_type='xmltv',
url='http://example.com/other.xml',
)
self.epg = EPGData.objects.create(
tvg_id='mapped',
name='Mapped',
icon_url='https://example.com/mapped.png',
epg_source=self.source,
)
self.other_epg = EPGData.objects.create(
tvg_id='other',
name='Other',
icon_url='https://example.com/other.png',
epg_source=self.other_source,
)
self.mapped_channel = Channel.objects.create(
channel_number=1,
name='Mapped',
tvg_id='mapped',
epg_data=self.epg,
)
Channel.objects.create(
channel_number=2,
name='Other',
tvg_id='other',
epg_data=self.other_epg,
)
def test_only_updates_channels_mapped_to_source(self):
stats = apply_logos_from_epg_for_source(self.source)
self.assertEqual(stats['updated_count'], 1)
self.mapped_channel.refresh_from_db()
self.assertEqual(
self.mapped_channel.logo.url,
'https://example.com/mapped.png',
)
def test_processes_source_in_batches(self):
stats = apply_logos_from_epg_for_source(self.source, batch_size=1)
self.assertEqual(stats['updated_count'], 1)
self.mapped_channel.refresh_from_db()
self.assertEqual(
self.mapped_channel.logo.url,
'https://example.com/mapped.png',
)
class MaybeAutoApplyEpgLogosTests(TestCase):
def setUp(self):
self.source = EPGSource.objects.create(
name='XML EPG',
source_type='xmltv',
url='http://example.com/epg.xml',
custom_properties={'auto_apply_epg_logos': True},
)
self.epg = EPGData.objects.create(
tvg_id='mapped',
name='Mapped',
icon_url='https://example.com/mapped.png',
epg_source=self.source,
)
self.channel = Channel.objects.create(
channel_number=1,
name='Mapped',
tvg_id='mapped',
epg_data=self.epg,
)
def test_runs_when_enabled(self):
stats = maybe_auto_apply_epg_logos(self.source)
self.assertEqual(stats['updated_count'], 1)
self.channel.refresh_from_db()
self.assertIsNotNone(self.channel.logo_id)
def test_skips_when_disabled(self):
self.source.custom_properties = {'auto_apply_epg_logos': False}
self.source.save(update_fields=['custom_properties'])
stats = maybe_auto_apply_epg_logos(self.source)
self.assertIsNone(stats)
self.channel.refresh_from_db()
self.assertIsNone(self.channel.logo_id)
class SetLogosFromEpgApiTests(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.url = '/api/channels/channels/set-logos-from-epg/'
self.source = EPGSource.objects.create(
name='XML EPG',
source_type='xmltv',
url='http://example.com/epg.xml',
)
self.epg = EPGData.objects.create(
tvg_id='mapped',
name='Mapped',
icon_url='https://example.com/mapped.png',
epg_source=self.source,
)
self.channel = Channel.objects.create(
channel_number=1,
name='Mapped',
tvg_id='mapped',
epg_data=self.epg,
)
@patch('apps.channels.tasks.set_channels_logos_from_epg.delay')
def test_accepts_channel_ids(self, mock_delay):
mock_delay.return_value.id = 'task-1'
response = self.client.post(
self.url,
{'channel_ids': [self.channel.id]},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_delay.assert_called_once_with(channel_ids=[self.channel.id])
@patch('apps.channels.tasks.set_channels_logos_from_epg.delay')
def test_accepts_epg_source_id(self, mock_delay):
mock_delay.return_value.id = 'task-2'
response = self.client.post(
self.url,
{'epg_source_id': self.source.id},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_delay.assert_called_once_with(epg_source_id=self.source.id)
self.assertEqual(response.data['channel_count'], 1)
def test_rejects_both_parameters(self):
response = self.client.post(
self.url,
{
'channel_ids': [self.channel.id],
'epg_source_id': self.source.id,
},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

View file

@ -365,46 +365,44 @@ class RecordingStatusLifecycleTests(TestCase):
# =========================================================================
class ConcatFlagsTests(TestCase):
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
when concatenating pre-restart segments."""
"""Verify error-tolerant FFmpeg flags on the HLS segment concat command."""
def test_concat_command_includes_error_tolerant_flags(self):
"""Inspect the source code to confirm error-tolerant flags are present.
This is a static analysis test no ffmpeg execution needed."""
def test_hls_concat_cmd_includes_error_tolerant_flags(self):
from apps.channels.tasks import _dvr_build_hls_concat_cmd
cmd = _dvr_build_hls_concat_cmd("/data/concat.txt", "/data/out.mkv")
self.assertIn("+genpts+igndts+discardcorrupt", cmd)
self.assertIn("-err_detect", cmd)
self.assertEqual(cmd[cmd.index("-err_detect") + 1], "ignore_err")
self.assertIn("-avoid_negative_ts", cmd)
self.assertEqual(cmd[cmd.index("-avoid_negative_ts") + 1], "make_zero")
self.assertIn("concat", cmd)
self.assertEqual(cmd[-1], "/data/out.mkv")
def test_hls_concat_cmd_supports_mp4_fallback_extra_args(self):
from apps.channels.tasks import _dvr_build_hls_concat_cmd
cmd = _dvr_build_hls_concat_cmd(
"/data/concat.txt",
"/data/intermediate.mp4",
extra_args=["-bsf:a", "aac_adtstoasc"],
)
self.assertIn("aac_adtstoasc", cmd)
self.assertEqual(cmd[-1], "/data/intermediate.mp4")
def test_run_recording_uses_hls_concat_helper(self):
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
self.assertIn("_dvr_build_hls_concat_cmd", source)
# The concat subprocess.run call must include these flags
self.assertIn("+genpts+igndts+discardcorrupt", source,
"Concat must use +genpts+igndts+discardcorrupt fflags")
self.assertIn("ignore_err", source,
"Concat must use -err_detect ignore_err")
self.assertIn("-f", source)
self.assertIn("concat", source)
def test_concat_goes_directly_to_mkv(self):
"""Concat must produce MKV directly (not intermediate .ts) to
preserve timestamp boundaries and avoid playback freeze at splice."""
def test_recover_recordings_uses_hls_concat_helper(self):
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
from apps.channels.tasks import recover_recordings_on_startup
# Must contain reset_timestamps for proper segment boundary handling
self.assertIn("reset_timestamps", source,
"Concat must use -reset_timestamps 1 for seamless seeking")
# Must write directly to final_path (MKV), not an intermediate .ts
self.assertIn("_concat_did_remux", source,
"Concat path must set flag to skip separate remux step")
def test_segment_time_metadata_present(self):
"""Verify concat uses -segment_time_metadata for boundary awareness."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
self.assertIn("segment_time_metadata", source,
"Concat must use -segment_time_metadata 1 for segment boundary handling")
source = inspect.getsource(recover_recordings_on_startup)
self.assertIn("_dvr_build_hls_concat_cmd", source)
# =========================================================================
@ -486,6 +484,92 @@ class RecoverySkipListTests(TestCase):
mock_run.apply_async.assert_not_called()
# =========================================================================
# 7. FFmpeg in-process retry loop
# =========================================================================
class FfmpegRetryTests(TestCase):
"""Verify FFmpeg restart logic for mid-recording crashes and stalls."""
def test_ffmpeg_retry_constants_and_helpers_exist(self):
from apps.channels import tasks as dvr_tasks
self.assertGreater(dvr_tasks._dvr_ffmpeg_retry_window_seconds(), 0)
self.assertEqual(dvr_tasks._dvr_count_hls_segments(None), 0)
self.assertEqual(dvr_tasks._dvr_count_hls_segments("/nonexistent"), 0)
self.assertEqual(dvr_tasks._dvr_ffmpeg_retry_backoff_seconds(1), 0.25)
self.assertEqual(dvr_tasks._dvr_ffmpeg_retry_backoff_seconds(12), 3.0)
@patch("apps.proxy.live_proxy.config_helper.ConfigHelper.stream_timeout", return_value=60)
@patch("apps.proxy.live_proxy.config_helper.ConfigHelper.failover_grace_period", return_value=20)
def test_retry_window_matches_live_proxy_timeouts(self, _grace, _stream):
from apps.channels.tasks import _dvr_ffmpeg_retry_window_seconds
self.assertEqual(_dvr_ffmpeg_retry_window_seconds(), 80.0)
def test_hls_start_number_zero_when_playlist_exists(self):
import tempfile
from apps.channels.tasks import _dvr_hls_start_number
with tempfile.TemporaryDirectory() as tmp:
m3u8 = os.path.join(tmp, "index.m3u8")
open(os.path.join(tmp, "seg_00000.ts"), "wb").write(b"\x00")
open(os.path.join(tmp, "seg_00013.ts"), "wb").write(b"\x00")
with open(m3u8, "w") as f:
f.write("#EXTM3U\n#EXT-X-TARGETDURATION:4\n")
f.write("seg_00000.ts\nseg_00013.ts\n")
# append_list reloads playlist entries; start_number must stay 0.
self.assertEqual(_dvr_hls_start_number(tmp, m3u8), 0)
def test_hls_start_number_from_max_index_without_playlist(self):
import tempfile
from apps.channels.tasks import _dvr_hls_start_number
with tempfile.TemporaryDirectory() as tmp:
open(os.path.join(tmp, "seg_00000.ts"), "wb").write(b"\x00")
open(os.path.join(tmp, "seg_00013.ts"), "wb").write(b"\x00")
self.assertEqual(_dvr_hls_start_number(tmp, os.path.join(tmp, "index.m3u8")), 14)
def test_hls_start_number_zero_on_fresh_dir(self):
import tempfile
from apps.channels.tasks import _dvr_hls_start_number
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(_dvr_hls_start_number(tmp, os.path.join(tmp, "index.m3u8")), 0)
def test_build_ffmpeg_cmd_continues_hls_numbering(self):
from apps.channels.tasks import _dvr_build_ffmpeg_cmd
cmd = _dvr_build_ffmpeg_cmd(
"http://127.0.0.1:5656/proxy/ts/stream/uuid",
71,
"/data/recordings/.dvr_71_hls/index.m3u8",
"/data/recordings/.dvr_71_hls/seg_%05d.ts",
42,
)
self.assertIn("-start_number", cmd)
self.assertEqual(cmd[cmd.index("-start_number") + 1], "42")
hls_flags = cmd[cmd.index("-hls_flags") + 1]
self.assertIn("append_list", hls_flags)
self.assertIn("omit_endlist", hls_flags)
self.assertIn("-err_detect", cmd)
self.assertEqual(cmd[cmd.index("-err_detect") + 1], "ignore_err")
def test_run_recording_has_retry_loop(self):
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
self.assertIn("_ffmpeg_retry_count", source)
self.assertIn("_ffmpeg_outage_started", source)
self.assertIn("_ffmpeg_retry_window", source)
self.assertIn("_break_reason", source)
self.assertIn("ffmpeg_outage_window_exhausted", source)
self.assertIn("_dvr_build_ffmpeg_cmd", source)
self.assertIn("_dvr_hls_start_number", source)
self.assertIn("_ffmpeg_retry_count = 0", source)
# =========================================================================
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
# =========================================================================

View file

@ -1,5 +1,12 @@
import logging
import threading
logger = logging.getLogger(__name__)
# Bound memory/DB work per chunk for large libraries (20k+ channels).
EPG_LOGO_APPLY_BATCH_SIZE = 500
EPG_LOGO_APPLY_MAX_ERRORS = 100
lock = threading.Lock()
# Dictionary to track usage: {account_id: current_usage}
active_streams_map = {}
@ -35,3 +42,186 @@ def decrement_stream_count(account):
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def auto_apply_epg_logos_enabled(custom_properties):
"""Return whether channel logos should be auto-applied after EPG refresh."""
return bool((custom_properties or {}).get('auto_apply_epg_logos', False))
def _empty_logo_apply_stats():
return {
'updated_count': 0,
'created_logos_count': 0,
'error_count': 0,
'errors': [],
}
def _merge_logo_apply_stats(accumulated, batch_stats):
accumulated['updated_count'] += batch_stats['updated_count']
accumulated['created_logos_count'] += batch_stats['created_logos_count']
accumulated['error_count'] += batch_stats['error_count']
remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(accumulated['errors'])
if remaining > 0:
accumulated['errors'].extend(batch_stats['errors'][:remaining])
return accumulated
def apply_logos_from_epg_icon_url(channels):
"""
Set channel.logo from epg_data.icon_url for the given channels.
Expects channels to be pre-filtered with select_related('epg_data', 'logo').
Uses bulk logo lookup/create and a single channel bulk_update for efficiency.
"""
from .models import Channel, Logo
work = []
url_to_meta = {}
for channel in channels:
if not channel.epg_data:
continue
icon_url = (channel.epg_data.icon_url or '').strip()
if not icon_url:
continue
if channel.logo and channel.logo.url == icon_url:
continue
work.append((channel, icon_url))
if icon_url not in url_to_meta:
url_to_meta[icon_url] = (
channel.epg_data.name,
channel.epg_data.tvg_id,
)
if not work:
return _empty_logo_apply_stats()
unique_urls = list(url_to_meta.keys())
logo_by_url = {
logo.url: logo
for logo in Logo.objects.filter(url__in=unique_urls)
}
missing_urls = [url for url in unique_urls if url not in logo_by_url]
created_logos_count = 0
if missing_urls:
logos_to_create = [
Logo(
name=(url_to_meta[url][0] or f"Logo for {url_to_meta[url][1]}"),
url=url,
)
for url in missing_urls
]
created_logos_count = len(logos_to_create)
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
for logo in Logo.objects.filter(url__in=unique_urls):
logo_by_url[logo.url] = logo
channels_to_update = []
errors = []
for channel, icon_url in work:
logo = logo_by_url.get(icon_url)
if not logo:
errors.append(f"Channel {channel.id}: Logo not found for {icon_url}")
continue
if channel.logo_id != logo.id:
channel.logo = logo
channels_to_update.append(channel)
if channels_to_update:
Channel.objects.bulk_update(channels_to_update, ['logo'], batch_size=500)
return {
'updated_count': len(channels_to_update),
'created_logos_count': created_logos_count,
'error_count': len(errors),
'errors': errors,
}
def channels_with_epg_icon_queryset(*, epg_source=None, epg_source_id=None):
"""Channels mapped to a source that have a non-empty EPG icon URL."""
from .models import Channel
qs = Channel.objects.filter(epg_data__isnull=False)
if epg_source is not None:
qs = qs.filter(epg_data__epg_source=epg_source)
elif epg_source_id is not None:
qs = qs.filter(epg_data__epg_source_id=epg_source_id)
else:
raise ValueError("epg_source or epg_source_id is required")
return qs.exclude(
epg_data__icon_url__isnull=True,
).exclude(
epg_data__icon_url='',
)
def apply_logos_from_epg_queryset(channels_qs, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE):
"""
Apply logos for a potentially large queryset without loading every row at once.
Streams channel IDs from the database and processes fixed-size chunks.
"""
from .models import Channel
stats = _empty_logo_apply_stats()
batch_ids = []
id_stream = channels_qs.order_by('id').values_list('id', flat=True).iterator(
chunk_size=batch_size,
)
for channel_id in id_stream:
batch_ids.append(channel_id)
if len(batch_ids) >= batch_size:
batch = Channel.objects.filter(
id__in=batch_ids,
).select_related('epg_data', 'logo')
_merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch))
batch_ids = []
if batch_ids:
batch = Channel.objects.filter(
id__in=batch_ids,
).select_related('epg_data', 'logo')
_merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch))
return stats
def apply_logos_from_epg_for_source(epg_source, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE):
"""Apply EPG icon URLs to all channels mapped to the given EPG source."""
channels_qs = channels_with_epg_icon_queryset(epg_source=epg_source)
return apply_logos_from_epg_queryset(channels_qs, batch_size=batch_size)
def maybe_auto_apply_epg_logos(epg_source):
"""Auto-apply logos after refresh when enabled on the source. Non-fatal on error."""
if not auto_apply_epg_logos_enabled(epg_source.custom_properties):
return None
try:
stats = apply_logos_from_epg_for_source(epg_source)
if stats['updated_count'] or stats['created_logos_count']:
logger.info(
"Auto-applied EPG logos for source %s: updated %s channels, "
"created %s logos.",
epg_source.name,
stats['updated_count'],
stats['created_logos_count'],
)
else:
logger.info(
"Auto-apply EPG logos for source %s: all matched channels already current.",
epg_source.name,
)
return stats
except Exception as logo_error:
logger.warning(
"EPG logo auto-apply failed for source %s (non-fatal): %s",
epg_source.name,
logo_error,
exc_info=True,
)
return None

View file

@ -1,6 +1,11 @@
import logging, os, re
import hashlib
import logging
import os
import re
import time
from rest_framework import viewsets, status, serializers
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
@ -18,10 +23,11 @@ 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,
IsAdmin,
IsStandardUser,
permission_classes_by_action,
permission_classes_by_method,
@ -47,6 +53,10 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
if self.action in ('sd_lineups', 'sd_lineups_search'):
if self.request.method == 'GET':
return [IsStandardUser()]
return [IsAdmin()]
return [Authenticated()]
def get_queryset(self):
@ -54,6 +64,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'))
@ -108,6 +120,337 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
return super().partial_update(request, *args, **kwargs)
def _sd_authenticate(self, source):
"""
Authenticate with Schedules Direct using stored credentials.
Returns (token, None) on success or (None, Response) on failure.
"""
import hashlib
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
username = (source.username or '').strip()
password = (source.password or '').strip()
if not username or not password:
return None, Response(
{"error": "Username and password are required."},
status=status.HTTP_400_BAD_REQUEST
)
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
try:
auth_response = http_requests.post(
f"{SD_BASE_URL}/token",
json={'username': username, 'password': sha1_password},
headers={'Content-Type': 'application/json', 'User-Agent': f'Dispatcharr/{dispatcharr_version}'},
timeout=15,
)
auth_response.raise_for_status()
token = auth_response.json().get('token')
if not token:
return None, Response(
{"error": "Authentication failed. Check your credentials."},
status=status.HTTP_401_UNAUTHORIZED
)
return token, None
except http_requests.exceptions.RequestException as e:
return None, Response(
{"error": f"Authentication failed: {str(e)}"},
status=status.HTTP_502_BAD_GATEWAY
)
def _get_sd_reset_at(self, source):
"""Retrieve stored reset timestamp from EPGSource model field."""
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
return reset_at_str
def _get_sd_changes_remaining(self, source):
"""
Retrieve stored changesRemaining from EPGSource model field.
If a reset timestamp exists and has passed (midnight UTC), clears the
lockout automatically so the user can make adds again.
"""
from django.utils import timezone
cp = source.custom_properties or {}
changes_remaining = cp.get('sd_changes_remaining')
reset_at_str = cp.get('sd_changes_reset_at')
from django.utils.dateparse import parse_datetime
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
# If we have a reset timestamp and it has passed, clear the lockout
if changes_remaining == 0 and reset_at:
if timezone.now() >= reset_at:
cp = source.custom_properties or {}
cp.pop('sd_changes_remaining', None)
cp.pop('sd_changes_reset_at', None)
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
return None
return changes_remaining
def _save_sd_changes_remaining(self, source, changes_remaining):
"""Persist changesRemaining to EPGSource model field."""
cp = source.custom_properties or {}
cp['sd_changes_remaining'] = changes_remaining
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
def _save_sd_lockout(self, source):
"""
Persist a hard lockout to EPGSource custom_properties when SD returns
4100 MAX_LINEUP_CHANGES_REACHED. SD lineup change counters reset at
00:00Z (midnight UTC) per SD's documented behavior — error 4100 states
"lineup changes for today" and all SD rate counters reset at midnight UTC.
Lockout clears automatically when the next midnight UTC passes.
"""
from django.utils import timezone
from datetime import datetime, timedelta, timezone as dt_timezone
now = timezone.now()
# Calculate next midnight UTC — SD resets at 00:00Z not on a rolling window
tomorrow = (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0,
tzinfo=dt_timezone.utc
)
reset_at = tomorrow
cp = source.custom_properties or {}
cp['sd_changes_remaining'] = 0
cp['sd_changes_reset_at'] = reset_at.isoformat()
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
logger.warning(
f"SD source {source.id}: daily add limit reached (4100). "
f"Lockout set until {reset_at.isoformat()}."
)
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
def sd_lineups(self, request, pk=None):
"""
GET list lineups currently on the SD account
POST add a lineup (body: {"lineup": "USA-NJ29486-X"})
DELETE remove a lineup (body: {"lineup": "USA-NJ29486-X"})
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
source = self.get_object()
if source.source_type != 'schedules_direct':
return Response(
{"error": "This action is only available for Schedules Direct sources."},
status=status.HTTP_400_BAD_REQUEST
)
token, error = self._sd_authenticate(source)
if error:
return error
headers = {
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
'token': token,
}
if request.method == "GET":
try:
resp = http_requests.get(
f"{SD_BASE_URL}/lineups",
headers=headers,
timeout=15,
)
if resp.status_code == 400:
sd_data = resp.json()
sd_code = sd_data.get('code')
if sd_code == 4102:
return Response({
"lineups": [],
"max_lineups": 4,
"changes_remaining": self._get_sd_changes_remaining(source),
"changes_reset_at": self._get_sd_reset_at(source),
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
})
resp.raise_for_status()
data = resp.json()
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
return Response({
"lineups": lineups,
"max_lineups": 4,
"changes_remaining": self._get_sd_changes_remaining(source),
"changes_reset_at": self._get_sd_reset_at(source),
})
except http_requests.exceptions.RequestException as e:
return Response(
{"error": f"Failed to fetch lineups: {str(e)}"},
status=status.HTTP_502_BAD_GATEWAY
)
elif request.method == "POST":
lineup_id = request.data.get('lineup')
if not lineup_id:
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
try:
resp = http_requests.put(
f"{SD_BASE_URL}/lineups/{lineup_id}",
headers=headers,
timeout=15,
)
sd_data = resp.json()
sd_code = sd_data.get('code')
if resp.status_code == 400 or resp.status_code == 403:
if sd_code == 4100:
self._save_sd_lockout(source)
return Response({
"error": "daily_limit_reached",
"message": "You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period. Resets at midnight UTC.",
"changes_remaining": 0,
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
}, status=status.HTTP_200_OK)
if sd_code == 4101:
return Response({
"error": "max_lineups_reached",
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
"changes_remaining": self._get_sd_changes_remaining(source),
}, status=status.HTTP_200_OK)
if sd_code == 2100:
return Response({
"error": "duplicate_lineup",
"message": "This lineup is already on your Schedules Direct account.",
"changes_remaining": self._get_sd_changes_remaining(source),
}, status=status.HTTP_200_OK)
return Response({
"error": sd_data.get('message', 'Failed to add lineup.'),
"changes_remaining": self._get_sd_changes_remaining(source),
}, status=status.HTTP_200_OK)
resp.raise_for_status()
# Persist changesRemaining to custom_properties
changes_remaining = sd_data.get('changesRemaining')
if changes_remaining is not None:
self._save_sd_changes_remaining(source, changes_remaining)
logger.info(
f"SD lineup added for source {source.id}: {lineup_id}. "
f"changesRemaining: {changes_remaining}"
)
# Re-fetch stations so the new lineup's stations are available for matching
from apps.epg.tasks import fetch_schedules_direct_stations
fetch_schedules_direct_stations.delay(source.id)
return Response({
**sd_data,
"changes_remaining": changes_remaining,
})
except http_requests.exceptions.RequestException as e:
return Response(
{"error": f"Failed to add lineup: {str(e)}"},
status=status.HTTP_502_BAD_GATEWAY
)
elif request.method == "DELETE":
lineup_id = request.data.get('lineup')
if not lineup_id:
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
try:
resp = http_requests.delete(
f"{SD_BASE_URL}/lineups/{lineup_id}",
headers=headers,
timeout=15,
)
if resp.status_code == 400:
sd_data = resp.json()
sd_code = sd_data.get('code')
if sd_code == 2103:
return Response({
"response": "OK",
"code": 0,
"message": "Lineup not found on account — already removed.",
"changes_remaining": self._get_sd_changes_remaining(source),
})
resp.raise_for_status()
sd_data = resp.json()
# SD returns changesRemaining on deletes — persist it
changes_remaining = sd_data.get('changesRemaining')
if changes_remaining is not None:
self._save_sd_changes_remaining(source, changes_remaining)
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
return Response({
**sd_data,
"changes_remaining": self._get_sd_changes_remaining(source),
})
except http_requests.exceptions.RequestException as e:
return Response(
{"error": f"Failed to remove lineup: {str(e)}"},
status=status.HTTP_502_BAD_GATEWAY
)
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
def sd_lineups_search(self, request, pk=None):
"""
Search available headends/lineups by country and postal code.
Body: {"country": "USA", "postalcode": "07030"}
Returns a flat list of lineups across all matching headends.
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
source = self.get_object()
if source.source_type != 'schedules_direct':
return Response(
{"error": "This action is only available for Schedules Direct sources."},
status=status.HTTP_400_BAD_REQUEST
)
country = request.data.get('country', '').strip()
postalcode = request.data.get('postalcode', '').strip()
if not country or not postalcode:
return Response(
{"error": "country and postalcode are required."},
status=status.HTTP_400_BAD_REQUEST
)
token, error = self._sd_authenticate(source)
if error:
return error
headers = {
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
'token': token,
}
try:
resp = http_requests.get(
f"{SD_BASE_URL}/headends",
params={'country': country, 'postalcode': postalcode},
headers=headers,
timeout=15,
)
resp.raise_for_status()
headends = resp.json()
lineups = []
for headend in headends:
for lineup in headend.get('lineups', []):
lineups.append({
'lineup': lineup.get('lineup'),
'name': lineup.get('name'),
'transport': headend.get('transport'),
'location': headend.get('location'),
'headend': headend.get('headend'),
})
return Response({"lineups": lineups})
except http_requests.exceptions.RequestException as e:
return Response(
{"error": f"Failed to search headends: {str(e)}"},
status=status.HTTP_502_BAD_GATEWAY
)
# ─────────────────────────────
# 2) Program API (CRUD)
# ─────────────────────────────
@ -123,7 +466,13 @@ class ProgramViewSet(viewsets.ModelViewSet):
queryset = ProgramData.objects.select_related("epg").all()
serializer_class = ProgramDataSerializer
# Per-source in-memory caches (token and error state)
_sd_poster_token_cache: dict = {}
_sd_poster_error_cache: dict = {}
def get_permissions(self):
if self.action == 'poster':
return [AllowAny()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
@ -139,6 +488,101 @@ class ProgramViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(instance)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
def poster(self, request, pk=None):
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
program = self.get_object()
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
if not poster_sd_url:
return Response(status=status.HTTP_404_NOT_FOUND)
source = program.epg.epg_source if program.epg else None
if not source or source.source_type != 'schedules_direct':
return Response(status=status.HTTP_404_NOT_FOUND)
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
if error_cache and time.time() < error_cache['until']:
return Response(
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
cached = ProgramViewSet._sd_poster_token_cache.get(source.id)
token = cached['token'] if cached and time.time() < cached['expires'] else None
if not token:
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
try:
from version import __version__ as dispatcharr_version
auth_resp = http_requests.post(
f"{SD_BASE_URL}/token",
json={'username': source.username, 'password': sha1_password},
headers={
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
},
timeout=10,
)
auth_data = auth_resp.json()
token = auth_data.get('token')
if not token:
ProgramViewSet._sd_poster_error_cache[source.id] = {
'until': time.time() + 3600,
'reason': auth_data.get('message', 'Authentication failed'),
}
return Response(status=status.HTTP_502_BAD_GATEWAY)
token_expires = auth_data.get('tokenExpires', time.time() + 86400)
ProgramViewSet._sd_poster_token_cache[source.id] = {
'token': token,
'expires': token_expires,
}
except http_requests.exceptions.RequestException:
ProgramViewSet._sd_poster_error_cache[source.id] = {
'until': time.time() + 300,
'reason': 'Network error reaching Schedules Direct',
}
return Response(status=status.HTTP_502_BAD_GATEWAY)
try:
img_resp = http_requests.get(
poster_sd_url,
headers={'token': token},
timeout=15,
allow_redirects=True,
)
if img_resp.status_code in (401, 403):
ProgramViewSet._sd_poster_token_cache.pop(source.id, None)
ProgramViewSet._sd_poster_error_cache[source.id] = {
'until': time.time() + 3600,
'reason': f'SD returned {img_resp.status_code}',
}
return Response(status=status.HTTP_502_BAD_GATEWAY)
if img_resp.status_code == 400:
try:
err_code = img_resp.json().get('code')
except Exception:
err_code = None
if err_code == 5002:
ProgramViewSet._sd_poster_error_cache[source.id] = {
'until': time.time() + 3600,
'reason': 'Daily image download limit reached (SD error 5002)',
}
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
if img_resp.status_code != 200:
return Response(status=status.HTTP_502_BAD_GATEWAY)
from django.http import HttpResponse
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
response = HttpResponse(img_resp.content, content_type=content_type)
response['Cache-Control'] = 'public, max-age=86400'
return response
except http_requests.exceptions.RequestException:
return Response(status=status.HTTP_502_BAD_GATEWAY)
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.")
return super().list(request, *args, **kwargs)
@ -663,6 +1107,7 @@ class EPGImportAPIView(APIView):
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
epg_id = request.data.get("id", None)
force = bool(request.data.get("force", False))
# Check if this is a dummy EPG source
try:
@ -677,7 +1122,7 @@ class EPGImportAPIView(APIView):
except EPGSource.DoesNotExist:
pass # Let the task handle the missing source
refresh_epg_data.delay(epg_id) # Trigger Celery task
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response(
{"success": True, "message": "EPG data refresh initiated."},
@ -731,19 +1176,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(
@ -755,9 +1282,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 = []
@ -776,4 +1300,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

@ -0,0 +1,65 @@
# Generated by Django 6.0.5 on 2026-05-31 16:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0023_epgsource_programme_index'),
]
operations = [
migrations.RemoveField(
model_name='epgsource',
name='api_key',
),
migrations.AddField(
model_name='epgsource',
name='password',
field=models.CharField(blank=True, help_text='Password for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True),
),
migrations.AddField(
model_name='epgsource',
name='username',
field=models.CharField(blank=True, help_text='Username for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True),
),
migrations.AddField(
model_name='programdata',
name='program_id',
field=models.CharField(blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.', max_length=64, null=True),
),
migrations.AlterField(
model_name='epgsource',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for source-specific configuration', null=True),
),
migrations.CreateModel(
name='SDProgramMD5',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('program_id', models.CharField(help_text='Schedules Direct programID (e.g. EP123456789)', max_length=64)),
('md5', models.CharField(help_text='MD5 hash of the program metadata from Schedules Direct', max_length=22)),
('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_program_md5s', to='epg.epgsource')),
],
options={
'unique_together': {('epg_source', 'program_id')},
},
),
migrations.CreateModel(
name='SDScheduleMD5',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('station_id', models.CharField(help_text='Schedules Direct stationID', max_length=20)),
('date', models.DateField(help_text='Schedule date (UTC)')),
('md5', models.CharField(help_text='MD5 hash of the schedule for this station/date from Schedules Direct', max_length=22)),
('last_modified', models.DateTimeField(help_text='Last modified timestamp from Schedules Direct')),
('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_schedule_md5s', to='epg.epgsource')),
],
options={
'indexes': [models.Index(fields=['epg_source', 'station_id'], name='epg_sdsched_epg_sou_0d700e_idx')],
'unique_together': {('epg_source', 'station_id', 'date')},
},
),
]

View file

@ -30,7 +30,10 @@ class EPGSource(models.Model):
name = models.CharField(max_length=255, unique=True)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
username = models.CharField(max_length=255, blank=True, null=True,
help_text='Username for credential-based EPG sources (e.g. Schedules Direct)')
password = models.CharField(max_length=255, blank=True, null=True,
help_text='Password for credential-based EPG sources (e.g. Schedules Direct)')
is_active = models.BooleanField(default=True)
file_path = models.CharField(max_length=1024, blank=True, null=True)
extracted_file_path = models.CharField(max_length=1024, blank=True, null=True,
@ -43,7 +46,7 @@ class EPGSource(models.Model):
default=dict,
blank=True,
null=True,
help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)"
help_text="Custom properties for source-specific configuration"
)
priority = models.PositiveIntegerField(
default=0,
@ -59,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"
@ -74,18 +83,13 @@ class EPGSource(models.Model):
def get_cache_file(self):
import mimetypes
# Use a temporary extension for initial download
# The actual extension will be determined after content inspection
file_ext = ".tmp"
# If file_path is already set and contains an extension, use that
# This handles cases where we've already detected the proper type
if self.file_path and os.path.exists(self.file_path):
_, existing_ext = os.path.splitext(self.file_path)
if existing_ext:
file_ext = existing_ext
else:
# Try to detect the MIME type and map to extension
mime_type, _ = mimetypes.guess_type(self.file_path)
if mime_type:
if mime_type == 'application/gzip' or mime_type == 'application/x-gzip':
@ -94,48 +98,33 @@ class EPGSource(models.Model):
file_ext = '.zip'
elif mime_type == 'application/xml' or mime_type == 'text/xml':
file_ext = '.xml'
# For files without mime type detection, try peeking at content
else:
try:
with open(self.file_path, 'rb') as f:
header = f.read(4)
# Check for gzip magic number (1f 8b)
if header[:2] == b'\x1f\x8b':
file_ext = '.gz'
# Check for zip magic number (PK..)
elif header[:2] == b'PK':
file_ext = '.zip'
# Check for XML
elif header[:5] == b'<?xml' or header[:5] == b'<tv>':
file_ext = '.xml'
except Exception as e:
# If we can't read the file, just keep the default extension
except Exception:
pass
filename = f"{self.id}{file_ext}"
# Build full path in MEDIA_ROOT/cached_epg
cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
# Create directory if it doesn't exist
os.makedirs(cache_dir, exist_ok=True)
cache = os.path.join(cache_dir, filename)
return cache
def save(self, *args, **kwargs):
# Prevent auto_now behavior by handling updated_at manually
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
# Don't modify updated_at for regular updates
kwargs.setdefault('update_fields', [])
if 'updated_at' in kwargs['update_fields']:
kwargs['update_fields'].remove('updated_at')
super().save(*args, **kwargs)
class EPGData(models.Model):
# Removed the Channel foreign key. We now just store the original tvg_id
# and a name (which might simply be the tvg_id if no real channel exists).
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=512)
icon_url = models.URLField(max_length=500, null=True, blank=True)
@ -154,7 +143,6 @@ class EPGData(models.Model):
return f"EPG Data for {self.name}"
class ProgramData(models.Model):
# Each programme is associated with an EPGData record.
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
start_time = models.DateTimeField()
end_time = models.DateTimeField()
@ -162,7 +150,71 @@ class ProgramData(models.Model):
sub_title = models.TextField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
tvg_id = models.CharField(max_length=255, null=True, blank=True)
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})"
class SDScheduleMD5(models.Model):
"""
Caches per-station per-date MD5 hashes from Schedules Direct.
Used to detect schedule changes and avoid unnecessary re-downloads,
minimizing API calls against SD's rate-limited endpoints.
"""
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
related_name="sd_schedule_md5s",
)
station_id = models.CharField(
max_length=20,
help_text="Schedules Direct stationID"
)
date = models.DateField(
help_text="Schedule date (UTC)"
)
md5 = models.CharField(
max_length=22,
help_text="MD5 hash of the schedule for this station/date from Schedules Direct"
)
last_modified = models.DateTimeField(
help_text="Last modified timestamp from Schedules Direct"
)
class Meta:
unique_together = ('epg_source', 'station_id', 'date')
indexes = [
models.Index(fields=['epg_source', 'station_id']),
]
def __str__(self):
return f"SDScheduleMD5: {self.station_id} / {self.date} ({self.epg_source.name})"
class SDProgramMD5(models.Model):
"""
Caches per-program MD5 hashes from Schedules Direct.
Keyed by epg_source + program_id (SD's programID e.g. EP123456789).
Used for program-level delta detection to avoid re-downloading unchanged
program metadata, minimizing API calls against SD's rate-limited endpoints.
"""
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
related_name="sd_program_md5s",
)
program_id = models.CharField(
max_length=64,
help_text="Schedules Direct programID (e.g. EP123456789)"
)
md5 = models.CharField(
max_length=22,
help_text="MD5 hash of the program metadata from Schedules Direct"
)
class Meta:
unique_together = ('epg_source', 'program_id')
def __str__(self):
return f"SDProgramMD5: {self.program_id} ({self.epg_source.name})"

View file

@ -1,4 +1,4 @@
from core.utils import validate_flexible_url
from core.utils import validate_flexible_url, build_absolute_uri_with_port
from rest_framework import serializers
from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel, Stream
@ -22,7 +22,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
'name',
'source_type',
'url',
'api_key',
'username',
'password',
'is_active',
'file_path',
'refresh_interval',
@ -36,6 +37,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
'epg_data_count',
'has_channels',
]
extra_kwargs = {'password': {'write_only': True}}
def get_epg_data_count(self, obj):
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
@ -66,6 +68,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
instance._cron_expression = cron_expr
for attr, value in validated_data.items():
if attr == 'password' and not value:
continue
setattr(instance, attr, value)
instance.save()
return instance
@ -142,6 +146,20 @@ class ProgramDetailSerializer(ProgramDataSerializer):
previously_shown = cp.get('previously_shown_details') or {}
data['original_air_date'] = previously_shown.get('start')
# Content advisory (SD)
data['content_advisory'] = cp.get('content_advisory') or []
# Full content ratings array (SD — all regional ratings)
data['content_ratings'] = cp.get('content_ratings') or []
# Sports event details (SD)
data['event_details'] = cp.get('event_details')
# Runtime (duration without commercials)
length = cp.get('length') or {}
data['runtime'] = length.get('value') if length else None
data['runtime_units'] = length.get('units') if length else None
# External IDs
data['imdb_id'] = cp.get('imdb.com_id')
data['tmdb_id'] = cp.get('themoviedb.org_id')
@ -151,6 +169,17 @@ class ProgramDetailSerializer(ProgramDataSerializer):
data['icon'] = cp.get('icon')
data['images'] = cp.get('images') or []
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth
if cp.get('sd_icon'):
poster_path = f"/api/epg/programs/{obj.id}/poster/"
request = self.context.get('request')
if request:
data['poster_url'] = build_absolute_uri_with_port(request, poster_path)
else:
data['poster_url'] = poster_path
else:
data['poster_url'] = None
return data

View file

@ -1,7 +1,7 @@
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import EPGSource, EPGData
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id, fetch_schedules_direct_stations
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.utils import is_protected_path, send_websocket_update
import json
@ -12,9 +12,16 @@ logger = logging.getLogger(__name__)
@receiver(post_save, sender=EPGSource)
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
# Trigger refresh only if the source is newly created, active, and not a dummy EPG
# Trigger refresh only if the source is newly created, active, and not a dummy EPG.
# For Schedules Direct sources, run a stations-only fetch on creation so the user
# can run Auto-match EPG before committing to a full schedule/program fetch.
# A short countdown gives the frontend time to receive the API response and
# populate the store before WebSocket updates arrive.
if created and instance.is_active and instance.source_type != 'dummy':
refresh_epg_data.delay(instance.id)
if instance.source_type == 'schedules_direct':
fetch_schedules_direct_stations.apply_async((instance.id,), countdown=3)
else:
refresh_epg_data.delay(instance.id)
@receiver(post_save, sender=EPGSource)
def create_dummy_epg_data(sender, instance, created, **kwargs):

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,503 @@
"""
Tests for the Schedules Direct EPG integration.
Covers:
- EPGSource model: username field presence and help text
- EPGSource serializer: username field included in output
- fetch_schedules_direct: credential validation
- fetch_schedules_direct: SHA1 password hashing and token exchange
- fetch_schedules_direct: graceful error handling on auth failure
- parse_schedules_direct_time: correct UTC parsing
- EPG signals: SD sources skip the XMLTV program parser
"""
import hashlib
from datetime import datetime
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from apps.epg.models import EPGSource, EPGData
from apps.epg.serializers import EPGSourceSerializer
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class EPGSourceUsernameFieldTests(TestCase):
"""EPGSource.username must exist, be nullable, and carry help text."""
def test_username_field_exists(self):
source = EPGSource.objects.create(
name='SD Test',
source_type='schedules_direct',
username='testuser',
password='testpass',
)
source.refresh_from_db()
self.assertEqual(source.username, 'testuser')
def test_username_nullable(self):
source = EPGSource.objects.create(
name='SD Nullable',
source_type='schedules_direct',
)
source.refresh_from_db()
self.assertIsNone(source.username)
def test_username_help_text(self):
field = EPGSource._meta.get_field('username')
self.assertIn('Schedules Direct', field.help_text)
# ---------------------------------------------------------------------------
# Serializer tests
# ---------------------------------------------------------------------------
class EPGSourceSerializerSDTests(TestCase):
"""EPGSourceSerializer must include the username field."""
def test_username_in_serializer_fields(self):
source = EPGSource.objects.create(
name='SD Serializer Test',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
data = EPGSourceSerializer(source).data
self.assertIn('username', data)
self.assertEqual(data['username'], 'sduser')
def test_password_not_in_serializer_output(self):
source = EPGSource.objects.create(
name='SD API Key Test',
source_type='schedules_direct',
password='secret',
)
data = EPGSourceSerializer(source).data
self.assertNotIn('password', data)
# ---------------------------------------------------------------------------
# fetch_schedules_direct tests
# ---------------------------------------------------------------------------
class FetchSchedulesDirectCredentialTests(TestCase):
"""fetch_schedules_direct must reject sources missing credentials."""
def _make_source(self, username=None, password=None):
return EPGSource.objects.create(
name='SD Cred Test',
source_type='schedules_direct',
username=username,
password=password,
)
def test_missing_username_sets_error_status(self):
from apps.epg.tasks import fetch_schedules_direct
source = self._make_source(username=None, password='pass')
fetch_schedules_direct(source)
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
def test_missing_password_sets_error_status(self):
from apps.epg.tasks import fetch_schedules_direct
source = self._make_source(username='user', password=None)
fetch_schedules_direct(source)
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
def test_empty_username_sets_error_status(self):
from apps.epg.tasks import fetch_schedules_direct
source = self._make_source(username=' ', password='pass')
fetch_schedules_direct(source)
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
class FetchSchedulesDirectAuthTests(TestCase):
"""fetch_schedules_direct must SHA1-hash the password before sending."""
@patch('apps.epg.tasks.requests.post')
@patch('apps.epg.tasks.requests.get')
def test_password_sha1_hashed_in_token_request(self, mock_get, mock_post):
"""The token POST body must contain the SHA1 hash of the plaintext password."""
plaintext = 'mysecretpassword'
expected_hash = hashlib.sha1(plaintext.encode('utf-8')).hexdigest()
# Auth succeeds, status check returns empty data, lineups returns empty
mock_post.return_value = MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok123'}),
)
mock_get.return_value = MagicMock(
status_code=200,
json=MagicMock(return_value={}),
)
from apps.epg.tasks import fetch_schedules_direct
source = EPGSource.objects.create(
name='SD Hash Test',
source_type='schedules_direct',
username='sduser',
password=plaintext,
)
with patch('apps.epg.tasks.send_epg_update'):
fetch_schedules_direct(source)
# Verify the POST was called and the body contained the hash
self.assertTrue(mock_post.called)
call_kwargs = mock_post.call_args
posted_json = call_kwargs[1].get('json') or call_kwargs[0][1]
self.assertEqual(posted_json.get('password'), expected_hash)
self.assertEqual(posted_json.get('username'), 'sduser')
@patch('apps.epg.tasks.requests.post')
def test_auth_failure_sets_error_status(self, mock_post):
"""A non-zero SD response code must set STATUS_ERROR on the source."""
mock_post.return_value = MagicMock(
status_code=200,
json=MagicMock(return_value={
'code': 3000,
'message': 'Invalid credentials',
}),
)
from apps.epg.tasks import fetch_schedules_direct
source = EPGSource.objects.create(
name='SD Auth Fail',
source_type='schedules_direct',
username='baduser',
password='badpass',
)
with patch('apps.epg.tasks.send_epg_update'):
fetch_schedules_direct(source)
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
@patch('apps.epg.tasks.requests.post')
def test_network_error_sets_error_status(self, mock_post):
"""A network-level exception must set STATUS_ERROR and not crash."""
import requests as req_lib
mock_post.side_effect = req_lib.exceptions.ConnectionError('timeout')
from apps.epg.tasks import fetch_schedules_direct
source = EPGSource.objects.create(
name='SD Network Error',
source_type='schedules_direct',
username='user',
password='pass',
)
with patch('apps.epg.tasks.send_epg_update'):
fetch_schedules_direct(source) # Must not raise
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
class FetchSchedulesDirectStationsOnlyTests(TestCase):
"""stations_only fetch must signal channel parsing completion to the frontend."""
@patch('apps.epg.tasks.send_epg_update')
@patch('apps.epg.tasks.requests.get')
@patch('apps.epg.tasks.requests.post')
def test_stations_only_sends_parsing_channels_complete(
self, mock_post, mock_get, mock_send_epg_update
):
from apps.epg.tasks import fetch_schedules_direct
mock_post.return_value = MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok123'}),
)
def get_side_effect(url, **kwargs):
if url.endswith('/status'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'systemStatus': [{'status': 'Online'}]}),
)
if url.endswith('/lineups'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={
'lineups': [{'lineupID': 'USA-TEST-X'}],
}),
)
if '/lineups/USA-TEST-X' in url:
return MagicMock(
status_code=200,
json=MagicMock(return_value={
'stations': [{
'stationID': '10001',
'name': 'Test Station',
'callsign': 'TEST',
}],
}),
)
raise AssertionError(f'Unexpected GET URL: {url}')
mock_get.side_effect = get_side_effect
source = EPGSource.objects.create(
name='SD Stations Only',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
fetch_schedules_direct(source, stations_only=True)
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_SUCCESS)
self.assertEqual(EPGData.objects.filter(epg_source=source).count(), 1)
parsing_channel_complete = [
c
for c in mock_send_epg_update.call_args_list
if c[0][1] == 'parsing_channels' and c[0][2] == 100
]
self.assertEqual(len(parsing_channel_complete), 1)
complete_call = parsing_channel_complete[0]
self.assertEqual(complete_call[0][0], source.id)
self.assertEqual(complete_call[1]['status'], 'success')
self.assertEqual(complete_call[1]['channels_count'], 1)
# ---------------------------------------------------------------------------
# parse_schedules_direct_time tests
# ---------------------------------------------------------------------------
class ParseSchedulesDirectTimeTests(TestCase):
"""parse_schedules_direct_time must parse SD ISO timestamps to UTC-aware datetimes."""
def test_parses_valid_timestamp(self):
from apps.epg.tasks import parse_schedules_direct_time
result = parse_schedules_direct_time('2026-05-16T20:00:00Z')
self.assertEqual(result.year, 2026)
self.assertEqual(result.month, 5)
self.assertEqual(result.day, 16)
self.assertEqual(result.hour, 20)
self.assertIsNotNone(result.tzinfo)
def test_result_is_utc_aware(self):
from apps.epg.tasks import parse_schedules_direct_time
result = parse_schedules_direct_time('2026-01-01T00:00:00Z')
# Should be timezone-aware
self.assertIsNotNone(result.tzinfo)
def test_raises_on_invalid_format(self):
from apps.epg.tasks import parse_schedules_direct_time
with self.assertRaises(Exception):
parse_schedules_direct_time('not-a-timestamp')
# ---------------------------------------------------------------------------
# Signal tests
# ---------------------------------------------------------------------------
class SDSourceSignalTests(TestCase):
"""SD EPG sources must skip the XMLTV program parser signal."""
@patch('apps.channels.signals.parse_programs_for_tvg_id')
def test_sd_source_skips_xmltv_parse_on_channel_create(self, mock_parse):
"""Creating a channel linked to an SD EPG source must not trigger
the XMLTV program parser SD data is handled by fetch_schedules_direct."""
from apps.epg.models import EPGData
from apps.channels.models import Channel
sd_source = EPGSource.objects.create(
name='SD Signal Test',
source_type='schedules_direct',
username='u',
password='p',
)
epg_data = EPGData.objects.create(
tvg_id='sd-test-station',
name='SD Test Station',
epg_source=sd_source,
)
Channel.objects.create(
name='SD Channel',
epg_data=epg_data,
)
mock_parse.delay.assert_not_called()
# ---------------------------------------------------------------------------
# Poster selection tests
# ---------------------------------------------------------------------------
class SDPosterSelectionTests(TestCase):
"""_sd_pick_poster_url must honour style preference with sensible fallbacks."""
def _images(self):
return [
{
'uri': 'assets/iconic_portrait.jpg',
'width': '960',
'aspect': '2x3',
'category': 'Iconic',
},
{
'uri': 'assets/banner_portrait.jpg',
'width': '360',
'aspect': '2x3',
'category': 'Banner-L1',
},
{
'uri': 'assets/iconic_landscape.jpg',
'width': '1920',
'aspect': '16x9',
'category': 'Iconic',
},
{
'uri': 'assets/banner_landscape.jpg',
'width': '1280',
'aspect': '16x9',
'category': 'Banner-L1',
},
]
def test_portrait_iconic_prefers_iconic_over_banner(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'portrait_iconic'),
'assets/iconic_portrait.jpg',
)
def test_portrait_banner_prefers_banner(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'portrait_banner'),
'assets/banner_portrait.jpg',
)
def test_landscape_iconic_prefers_landscape_iconic(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'landscape_iconic'),
'assets/iconic_landscape.jpg',
)
def test_landscape_falls_back_to_portrait_when_unavailable(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [img for img in self._images() if img['aspect'] in ('2x3', '3x4')]
self.assertEqual(
_sd_pick_poster_url(images, 'landscape_iconic'),
'assets/iconic_portrait.jpg',
)
def test_unknown_style_defaults_to_sd_recommended(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'not_a_real_style'),
'assets/iconic_portrait.jpg',
)
def test_prefers_primary_when_category_and_aspect_match(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [
{
'uri': 'assets/banner_small.jpg',
'width': '120',
'aspect': '2x3',
'category': 'Banner-L1',
},
{
'uri': 'assets/banner_primary.jpg',
'width': '360',
'aspect': '2x3',
'category': 'Banner-L1',
'primary': 'true',
},
]
self.assertEqual(
_sd_pick_poster_url(images, 'portrait_banner'),
'assets/banner_primary.jpg',
)
def test_sd_recommended_uses_primary_poster_category(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [
{
'uri': 'assets/cast_primary.jpg',
'width': '500',
'aspect': '3x4',
'category': 'Cast in Character',
'primary': 'true',
},
{
'uri': 'assets/iconic_primary.jpg',
'width': '300',
'aspect': '16x9',
'category': 'Iconic',
'primary': 'true',
},
]
self.assertEqual(
_sd_pick_poster_url(images, 'sd_recommended'),
'assets/iconic_primary.jpg',
)
def test_sd_recommended_falls_back_to_portrait_iconic(self):
from apps.epg.tasks import _sd_pick_poster_url
self.assertEqual(
_sd_pick_poster_url(self._images(), 'sd_recommended'),
'assets/iconic_portrait.jpg',
)
def test_default_style_is_sd_recommended(self):
from apps.epg.tasks import _sd_pick_poster_url, SD_POSTER_STYLE_DEFAULT
self.assertEqual(SD_POSTER_STYLE_DEFAULT, 'sd_recommended')
images = [
{
'uri': 'assets/primary.jpg',
'width': '960',
'aspect': '16x9',
'category': 'Iconic',
'primary': 'true',
},
]
self.assertEqual(_sd_pick_poster_url(images), 'assets/primary.jpg')
def test_style_fallback_uses_primary_before_cross_orientation(self):
from apps.epg.tasks import _sd_pick_poster_url
images = [
{
'uri': 'assets/iconic_portrait.jpg',
'width': '960',
'aspect': '2x3',
'category': 'Iconic',
},
{
'uri': 'assets/landscape_primary.jpg',
'width': '1920',
'aspect': '16x9',
'category': 'Iconic',
'primary': 'true',
},
]
# square_iconic has no 1x1 images; should pick SD primary before portrait iconic fallback
self.assertEqual(
_sd_pick_poster_url(images, 'square_iconic'),
'assets/landscape_primary.jpg',
)

View file

@ -19,6 +19,7 @@ from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from dispatcharr.utils import network_access_allowed
from core.utils import build_absolute_uri_with_port
# Configure logger
logger = logging.getLogger(__name__)
@ -75,7 +76,7 @@ class DiscoverAPIView(APIView):
uri_parts.append("output_profile")
uri_parts.append(str(output_profile_id))
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/")
base_url = build_absolute_uri_with_port(request, f'/{"/".join(uri_parts)}/').rstrip("/")
device = HDHRDevice.objects.first()
from apps.m3u.utils import calculate_tuner_count
@ -166,6 +167,13 @@ class LineupAPIView(APIView):
resolved_output_profile_id = _resolve_hdhr_output_profile_id(output_profile_id)
_stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/")
_output_profile_qs = (
f"?output_profile={resolved_output_profile_id}"
if resolved_output_profile_id is not None
else ""
)
lineup = []
for ch in channels:
formatted = format_channel_number(ch.effective_channel_number, empty=None)
@ -173,9 +181,7 @@ class LineupAPIView(APIView):
continue
formatted_channel_number = str(formatted)
stream_url = request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
if resolved_output_profile_id is not None:
stream_url += f"?output_profile={resolved_output_profile_id}"
stream_url = f"{_stream_url_prefix}{ch.uuid}{_output_profile_qs}"
lineup.append(
{
@ -224,7 +230,7 @@ class HDHRDeviceXMLAPIView(APIView):
if blocked is not None:
return blocked
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>

View file

@ -15,6 +15,7 @@ from django.views import View
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from core.utils import build_absolute_uri_with_port
@login_required
@ -46,7 +47,7 @@ class DiscoverAPIView(APIView):
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
device = HDHRDevice.objects.first()
if not device:
@ -92,6 +93,8 @@ class LineupAPIView(APIView):
.exclude(hidden_from_output=True)
.order_by("effective_channel_number")
)
_stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/")
lineup = []
for ch in channels:
formatted = format_channel_number(ch.effective_channel_number, empty=None)
@ -102,7 +105,7 @@ class LineupAPIView(APIView):
{
"GuideNumber": formatted_channel_number,
"GuideName": ch.effective_name,
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
"URL": f"{_stream_url_prefix}{ch.uuid}",
}
)
return JsonResponse(lineup, safe=False)
@ -133,7 +136,7 @@ class HDHRDeviceXMLAPIView(APIView):
description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>

View file

@ -978,7 +978,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
existing_streams = {
s.stream_hash: s
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id'
)
}
@ -994,7 +994,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
obj.custom_properties != stream_props["custom_properties"] or
obj.is_adult != stream_props["is_adult"] or
obj.stream_id != stream_props["stream_id"] or
obj.stream_chno != stream_props["stream_chno"]
obj.stream_chno != stream_props["stream_chno"] or
obj.channel_group_id != stream_props["channel_group_id"]
)
if changed:
@ -1030,7 +1031,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
# Simplified bulk update for better performance
Stream.objects.bulk_update(
streams_to_update,
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id'],
batch_size=150 # Smaller batch size for XC processing
)
@ -1200,7 +1201,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
existing_streams = {
s.stream_hash: s
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id'
)
}
@ -1216,7 +1217,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.custom_properties != stream_props["custom_properties"] or
obj.is_adult != stream_props["is_adult"] or
obj.stream_id != stream_props["stream_id"] or
obj.stream_chno != stream_props["stream_chno"]
obj.stream_chno != stream_props["stream_chno"] or
obj.channel_group_id != stream_props["channel_group_id"]
)
# Always update last_seen
@ -1232,6 +1234,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.is_adult = stream_props["is_adult"]
obj.stream_id = stream_props["stream_id"]
obj.stream_chno = stream_props["stream_chno"]
obj.channel_group_id = stream_props["channel_group_id"]
obj.updated_at = timezone.now()
# Always mark as not stale since we saw it in this refresh
@ -1254,7 +1257,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
# Update all streams in a single bulk operation
Stream.objects.bulk_update(
streams_to_update,
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id'],
batch_size=200
)
except Exception as e:
@ -2955,7 +2958,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', 'exp_date'])
profile.save(update_fields=['custom_properties'])
profiles_updated += 1
logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})")

View file

@ -6,7 +6,10 @@ first (fails on HEAD prior to the Tier 2 patch), then is flipped to assert
the correct post-fix behavior. Comments call out the failure mode and the
fix location.
"""
from django.test import TestCase
from unittest import skipUnless
from django.db import connection
from django.test import TestCase, TransactionTestCase
from django.utils import timezone
from apps.channels.models import (
@ -2049,3 +2052,251 @@ 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}",
)
@skipUnless(
connection.vendor == "postgresql",
"Idempotency repro forces a physical heap reorder via CLUSTER, which is "
"PostgreSQL-specific (the suite's target DB).",
)
class CompactNumberingIdempotencyTests(TransactionTestCase):
"""
A compact repack must be idempotent: with no change to hide state or
overrides, repacking again must leave every channel on the same number.
The unpatched _repack_inner read its channels with no ORDER BY, so the
pack followed PostgreSQL's physical row order. That order drifts after
the UPDATEs each repack issues (and after autovacuum), so successive
syncs packed the same channels into different numbers. That is the daily
channel-number churn users reported.
This test forces the divergence deterministically. After the first pack
it rewrites every channel_number to the reverse of id order, then
physically clusters the table on that column so the heap order becomes
the reverse of id order. An unordered SELECT then returns the rows in the
opposite order from the first pass. Unpatched, the second pack assigns
numbers in that reversed order and the channel->number mapping flips;
patched, .order_by("id") keeps both packs identical.
Fail signature: channel->number mapping differs between the two repacks
= _repack_inner is following physical row order instead of id order.
Fix location: apps/channels/compact_numbering.py (_repack_inner channel
query .order_by("id")).
"""
# TransactionTestCase commits its rows (TestCase's savepoint rollback
# would hide them from CLUSTER, which also cannot run inside the
# transaction block TestCase wraps each test in).
def _mapping(self, account):
return {
c.id: c.channel_number
for c in Channel.objects.filter(
auto_created=True, auto_created_by=account
)
}
def test_repack_is_idempotent_under_physical_reorder(self):
from apps.channels.compact_numbering import repack_group
account = _make_account()
group = _make_group(name="Sports")
rel = _attach_group_to_account(
account, group, custom_properties={"compact_numbering": True}
)
rel.auto_sync_channel_start = 8000
rel.auto_sync_channel_end = 8099
rel.save()
# Eight visible auto channels; ascending id is creation order.
channels = [
Channel.objects.create(
name=f"C{i}",
channel_group=group,
auto_created=True,
auto_created_by=account,
)
for i in range(8)
]
repack_group(rel)
first = self._mapping(account)
# Provider-order pack (the default) assigns by id, so the lowest id
# takes the range start.
lowest_id = min(c.id for c in channels)
self.assertEqual(first[lowest_id], 8000)
# Set channel_number to the reverse of id order, then cluster the
# heap on that column so physical order becomes reverse-id order.
# Values sit above the range so they cannot collide with the pack.
table = Channel._meta.db_table
with connection.cursor() as cur:
for pos, ch in enumerate(channels):
cur.execute(
f"UPDATE {table} SET channel_number = %s WHERE id = %s",
[9000 - pos, ch.id],
)
cur.execute(
f"CREATE INDEX IF NOT EXISTS churn_cn_idx "
f"ON {table} (channel_number)"
)
cur.execute(f"CLUSTER {table} USING churn_cn_idx")
cur.execute("DROP INDEX IF EXISTS churn_cn_idx")
repack_group(rel)
second = self._mapping(account)
self.assertEqual(
first,
second,
"Repack is not idempotent: channel numbers changed on a second "
"pass with no hide or override change. _repack_inner is following "
"physical row order instead of id order.",
)

View file

@ -23,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__)
@ -126,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:
@ -205,11 +205,11 @@ def generate_m3u(request, profile_name=None, user=None):
xc_username = request.GET.get('username')
xc_password = request.GET.get('password')
is_xc_request = user is not None and xc_username and xc_password
_base_url = build_absolute_uri_with_port(request, '')
if is_xc_request:
# This is an XC API request - use XC-style EPG URL
base_url = build_absolute_uri_with_port(request, '')
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
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:
@ -239,6 +239,13 @@ def generate_m3u(request, profile_name=None, user=None):
# Add x-tvg-url and url-tvg attribute for EPG URL
m3u_content = f'#EXTM3U x-tvg-url="{epg_url}" url-tvg="{epg_url}"\n'
# Host/port/scheme are constant per request; precompute URL prefixes once.
_stream_url_prefix = None if is_xc_request else f"{_base_url}/proxy/ts/stream/"
_sample_logo_path = reverse("api:channels:logo-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
# Start building M3U content
channel_count = 0
for channel in channels:
@ -268,8 +275,7 @@ def generate_m3u(request, profile_name=None, user=None):
tvg_logo = ""
if effective_logo:
if use_cached_logos:
# Use cached logo as before
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
else:
# Try to find direct logo URL from channel's streams
direct_logo = effective_logo.url if effective_logo.url.startswith(('http://', 'https://')) else None
@ -277,7 +283,7 @@ def generate_m3u(request, profile_name=None, user=None):
if direct_logo:
tvg_logo = direct_logo
else:
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
# create possible gracenote id insertion
tvc_guide_stationid = ""
@ -293,7 +299,7 @@ def generate_m3u(request, profile_name=None, user=None):
# Determine the stream URL based on request type
if is_xc_request:
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}{xc_qs_suffix}"
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()
@ -303,11 +309,10 @@ def generate_m3u(request, profile_name=None, user=None):
stream_url = first_stream.url
else:
# Fall back to proxy URL if no direct URL available
stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
stream_url = f"{_stream_url_prefix}{channel.uuid}"
else:
# Standard behavior - use proxy URL
base_stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
stream_url = f"{base_stream_url}{proxy_qs_suffix}"
stream_url = f"{_stream_url_prefix}{channel.uuid}{proxy_qs_suffix}"
m3u_content += extinf_line + stream_url + "\n"
@ -1428,6 +1433,13 @@ def generate_epg(request, profile_name=None, user=None):
channel_num_map[channel.id] = candidate
used_numbers.add(candidate)
# Host/port/scheme are constant per request; precompute logo URL prefix once.
_base_url = build_absolute_uri_with_port(request, "")
_sample_logo_path = reverse("api:channels:logo-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
# Process channels for the <channel> section
for channel in channels:
effective_name = channel.effective_name
@ -1507,14 +1519,14 @@ def generate_epg(request, profile_name=None, user=None):
# If no custom dummy logo, use regular logo logic
if not tvg_logo and effective_logo:
if use_cached_logos:
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
else:
# Use direct URL if available, otherwise fall back to cached version
direct_logo = effective_logo.url if effective_logo.url.startswith(('http://', 'https://')) else None
if direct_logo:
tvg_logo = direct_logo
else:
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
display_name = effective_name
xml_lines.append(f' <channel id="{html.escape(channel_id)}">')
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
@ -1646,6 +1658,7 @@ def generate_epg(request, profile_name=None, user=None):
# to avoid skipping/duplicating rows if the table changes mid-stream.
last_epg_id = 0
last_id = 0
_poster_url_base = build_absolute_uri_with_port(request, "/api/epg/programs/")
while True:
program_chunk = list(
@ -1849,6 +1862,8 @@ def generate_epg(request, profile_name=None, user=None):
if "icon" in custom_data:
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
elif "sd_icon" in custom_data:
program_xml.append(f' <icon src="{html.escape(_poster_url_base)}{prog["id"]}/poster/" />')
# Add special flags as proper tags with enhanced handling
if custom_data.get("previously_shown", False):
@ -2549,60 +2564,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": "",
@ -2636,47 +2670,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 "",
})
@ -3109,87 +3166,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

@ -0,0 +1,25 @@
from django.db import migrations
def update_url(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.filter(is_official=True).update(
url="https://dispatcharr.github.io/Plugins/manifest.json"
)
def revert_url(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.filter(is_official=True).update(
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
)
class Migration(migrations.Migration):
dependencies = [
("plugins", "0002_pluginrepo"),
]
operations = [
migrations.RunPython(update_url, revert_url),
]

View file

@ -36,9 +36,7 @@ class PluginConfig(models.Model):
return f"{self.name} ({self.key})"
OFFICIAL_REPO_URL = (
"https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
)
OFFICIAL_REPO_URL = "https://dispatcharr.github.io/Plugins/manifest.json"
class PluginRepo(models.Model):

View file

@ -419,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
@ -460,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

@ -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,15 +166,7 @@ class StreamGenerator:
yield create_ts_packet('error', "Error: Channel is stopping")
return False
# Send null packets to keep the connection alive during initialization.
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")

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:

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

@ -407,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."""

View file

@ -312,6 +312,34 @@ class TaskLockRenewer:
return False
def _is_gevent_monkey_patched():
try:
import gevent.monkey
return gevent.monkey.is_module_patched('threading')
except Exception:
return False
def _is_celery_worker_context():
"""True when executing inside an active Celery task (prefork worker)."""
try:
from celery import current_task
request = getattr(current_task, 'request', None)
return bool(request and getattr(request, 'id', None))
except Exception:
return False
def _should_use_sync_websocket_send():
"""
Use synchronous Redis delivery when gevent is monkey-patched but no gevent
hub is driving the process e.g. Celery prefork workers that inherit
gevent patching from uWSGI imports. gevent.spawn in that context schedules
coroutines that never run.
"""
return _is_gevent_monkey_patched() and _is_celery_worker_context()
def _gevent_ws_send(group_name, message):
"""
Publishes a WebSocket group message synchronously through Redis.
@ -363,6 +391,12 @@ def _gevent_ws_send(group_name, message):
logger.warning(f"Failed to send WebSocket update: {e}")
def send_websocket_update_sync(group_name, event_type, data):
"""Send a WebSocket group message synchronously via Redis (channels_redis wire format)."""
message = {'type': event_type, 'data': data}
_gevent_ws_send(group_name, message)
def send_websocket_update(group_name, event_type, data, collect_garbage=False):
"""
Sends a WebSocket group message.
@ -370,28 +404,25 @@ def send_websocket_update(group_name, event_type, data, collect_garbage=False):
In gevent-patched uWSGI workers, asyncio event loop creation fails because
monkey-patching removes select.epoll. For those contexts a synchronous Redis
path is used instead, matching the channels_redis 4.x wire format.
Celery prefork workers may inherit gevent monkey-patching without a running
gevent hub; in that case gevent.spawn would never execute, so delivery is
synchronous via Redis instead.
"""
channel_layer = get_channel_layer()
message = {'type': event_type, 'data': data}
def _do_send():
if _should_use_sync_websocket_send():
_gevent_ws_send(group_name, message)
elif _is_gevent_monkey_patched():
import gevent
gevent.spawn(_gevent_ws_send, group_name, message)
else:
# Not gevent-patched (plain Celery, tests) — use asyncio channel layer
try:
async_to_sync(channel_layer.group_send)(group_name, message)
async_to_sync(get_channel_layer().group_send)(group_name, message)
except Exception as e:
logger.warning(f"Failed to send WebSocket update: {e}")
try:
import gevent.monkey
if gevent.monkey.is_module_patched('threading'):
import gevent
gevent.spawn(_gevent_ws_send, group_name, message)
return
except Exception:
pass
# Not in a gevent-patched environment (Celery, tests) — use asyncio path
_do_send()
if collect_garbage:
gc.collect()
@ -752,6 +783,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

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

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

@ -7,6 +7,7 @@
DATA_DIRS=(
"/data/backups"
"/data/logos"
"/data/logo_cache"
"/data/recordings"
"/data/uploads/m3us"
"/data/uploads/epgs"
@ -19,7 +20,6 @@ DATA_DIRS=(
# APP_DIRS live on the image layer and are always locally writable.
APP_DIRS=(
"/app/logo_cache"
"/app/media"
"/app/static"
)

View file

@ -1,4 +1,4 @@
proxy_cache_path /app/logo_cache levels=1:2 keys_zone=logo_cache:10m
proxy_cache_path /data/logo_cache levels=1:2 keys_zone=logo_cache:10m
inactive=24h use_temp_path=off;
server {
@ -58,6 +58,14 @@ server {
proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow
}
location ~ ^/api/epg/programs/(?<prog_id>\d+)/poster/ {
proxy_pass http://127.0.0.1:5656;
proxy_cache logo_cache;
proxy_cache_key "$scheme$request_uri";
proxy_cache_valid 200 24h;
proxy_cache_use_stale error timeout updating;
}
# admin disabled when not in dev mode
location ~ ^/admin/?$ {
return 301 /login;

View file

@ -50,7 +50,6 @@ export const WebsocketProvider = ({ children }) => {
const epgs = useEPGsStore((s) => s.epgs);
const updateEPG = useEPGsStore((s) => s.updateEPG);
const updateEPGProgress = useEPGsStore((s) => s.updateEPGProgress);
const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist);
@ -380,6 +379,11 @@ export const WebsocketProvider = ({ children }) => {
) {
API.batchSetEPG(parsedEvent.data.associations);
}
// Refresh EPG store first, then requery channels so the table
// cross-references updated epg_data_id assignments immediately
fetchEPGData();
API.requeryChannels();
break;
case 'epg_matching_progress': {
@ -635,81 +639,87 @@ export const WebsocketProvider = ({ children }) => {
}
break;
case 'epg_refresh':
// If we have source/account info, check if EPG exists before processing
if (parsedEvent.data.source || parsedEvent.data.account) {
const sourceId =
parsedEvent.data.source || parsedEvent.data.account;
const epg = epgs[sourceId];
case 'epg_refresh': {
const sourceId =
parsedEvent.data.source || parsedEvent.data.account;
if (!sourceId) break;
// Only update progress if the EPG still exists in the store
// This prevents crashes when receiving updates for deleted EPGs
if (epg) {
// Update the store with progress information
updateEPGProgress(parsedEvent.data);
} else {
// EPG was deleted, ignore this update
console.debug(
`Ignoring EPG refresh update for deleted EPG ${sourceId}`
// Read from the store directly. connectWebSocket closes over a stale
// epgs snapshot, so a newly created source is missed and the old early-
// return path never reached fetchEPGData on parsing_channels completion.
let { epgs: epgsState, updateEPG, updateEPGProgress, fetchEPGs, fetchEPGData } =
useEPGsStore.getState();
if (!epgsState[sourceId]) {
try {
await fetchEPGs();
} catch (e) {
console.warn(
'Failed to refresh EPG sources for progress update:',
e
);
break;
}
epgsState = useEPGsStore.getState().epgs;
}
if (epg) {
// Check for any indication of an error (either via status or error field)
const hasError =
parsedEvent.data.status === 'error' ||
!!parsedEvent.data.error ||
(parsedEvent.data.message &&
parsedEvent.data.message.toLowerCase().includes('error'));
updateEPGProgress(parsedEvent.data);
if (hasError) {
// Handle error state
const errorMessage =
parsedEvent.data.error ||
parsedEvent.data.message ||
'Unknown error occurred';
const epg = epgsState[sourceId];
if (!epg) break;
updateEPG({
...epg,
status: 'error',
last_message: errorMessage,
});
const hasError =
parsedEvent.data.status === 'error' ||
!!parsedEvent.data.error ||
(parsedEvent.data.message &&
parsedEvent.data.message.toLowerCase().includes('error'));
// Show notification for the error
notifications.show({
title: 'EPG Refresh Error',
message: errorMessage,
color: 'red.5',
});
}
// Update status on completion only if no errors
else if (parsedEvent.data.progress === 100) {
updateEPG({
...epg,
status: parsedEvent.data.status || 'success',
last_message:
parsedEvent.data.message || epg.last_message,
// Use the timestamp from the backend if provided
...(parsedEvent.data.updated_at && {
updated_at: parsedEvent.data.updated_at,
}),
});
if (hasError) {
const errorMessage =
parsedEvent.data.error ||
parsedEvent.data.message ||
'Unknown error occurred';
// Only show success notification if we've finished parsing programs and had no errors
if (parsedEvent.data.action === 'parsing_programs') {
notifications.show({
title: 'EPG Processing Complete',
message: 'EPG data has been updated successfully',
color: 'green.5',
});
updateEPG({
...epg,
status: 'error',
last_message: errorMessage,
});
fetchEPGData();
}
}
notifications.show({
title: 'EPG Refresh Error',
message: errorMessage,
color: 'red.5',
});
} else if (parsedEvent.data.progress === 100) {
updateEPG({
...epg,
status: parsedEvent.data.status || 'success',
last_message:
parsedEvent.data.message || epg.last_message,
...(parsedEvent.data.updated_at && {
updated_at: parsedEvent.data.updated_at,
}),
});
if (parsedEvent.data.action === 'parsing_channels') {
notifications.show({
message: 'EPG channels updated!',
color: 'green.5',
});
await fetchEPGData();
} else if (parsedEvent.data.action === 'parsing_programs') {
notifications.show({
title: 'EPG Processing Complete',
message: 'EPG data has been updated successfully',
color: 'green.5',
});
await fetchEPGData();
}
}
break;
}
case 'epg_sources_changed':
// A plugin or backend process signaled that the EPG sources changed
@ -965,6 +975,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,21 @@ 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.
@ -1632,11 +1647,11 @@ export default class API {
}
}
static async refreshEPG(id) {
static async refreshEPG(id, force = false) {
try {
const response = await request(`${host}/api/epg/import/`, {
method: 'POST',
body: { id },
body: { id, force },
});
return response;
@ -3526,10 +3541,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 +3584,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) {
@ -3817,4 +3834,83 @@ export default class API {
errorNotification('Failed to fetch connect logs', e);
}
}
static async getSDLineups(sourceId) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve Schedules Direct lineups', e);
}
}
static async addSDLineup(sourceId, lineup) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
{
method: 'POST',
body: { lineup },
}
);
return response;
} catch (e) {
errorNotification(`Failed to add lineup ${lineup}`, e);
}
}
static async deleteSDLineup(sourceId, lineup) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
{
method: 'DELETE',
body: { lineup },
}
);
return response;
} catch (e) {
errorNotification(`Failed to remove lineup ${lineup}`, e);
}
}
static async updateEpgSourceSettings(sourceId, settings) {
try {
// Read current custom_properties from the store to merge, not replace
const epgs = useEPGsStore.getState().epgs;
const source = epgs[sourceId];
const cp = { ...(source?.custom_properties || {}), ...settings };
const response = await request(`${host}/api/epg/sources/${sourceId}/`, {
method: 'PATCH',
body: { custom_properties: cp },
});
useEPGsStore.getState().updateEPG(response);
return response;
} catch (e) {
errorNotification('Failed to update EPG source settings', e);
}
}
static async updateSDSettings(sourceId, settings) {
return API.updateEpgSourceSettings(sourceId, settings);
}
static async searchSDLineups(sourceId, country, postalcode) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/search/`,
{
method: 'POST',
body: { country, postalcode },
}
);
return response;
} catch (e) {
errorNotification('Failed to search Schedules Direct lineups', e);
}
}
}

View file

@ -40,11 +40,19 @@ function formatDurationMinutes(startTime, endTime) {
return `${minutes}m`;
}
function resolveApiUrl(url) {
if (!url || url.startsWith('http')) return url;
const apiHost = import.meta.env.DEV
? `http://${window.location.hostname}:5656`
: '';
return `${apiHost}${url}`;
}
function resolveImageUrl(detail) {
if (detail?.tmdb_poster_url) return detail.tmdb_poster_url;
if (detail?.poster_url) return detail.poster_url;
if (detail?.images?.length > 0) return detail.images[0].url;
if (detail?.icon) return detail.icon;
if (detail?.poster_url) return resolveApiUrl(detail.poster_url);
if (detail?.images?.length > 0) return resolveApiUrl(detail.images[0].url);
if (detail?.icon) return resolveApiUrl(detail.icon);
return null;
}
@ -298,6 +306,37 @@ export default function ProgramDetailModal({
</>
)}
{d.content_advisory?.length > 0 && (
<Text size="xs" c="orange" fs="italic">
{d.content_advisory.join(', ')}
</Text>
)}
{d.event_details && (
<>
<Divider color="#333" />
<Stack gap={4}>
{d.event_details.venue100 && (
<Text size="sm" c="dimmed">
<Text span fw={600}>Venue: </Text>
{d.event_details.venue100}
</Text>
)}
{d.event_details.teams?.length > 0 && (
<Text size="sm" c="dimmed">
<Text span fw={600}>Teams: </Text>
{d.event_details.teams.map((t, i) => (
<Text span key={i}>
{i > 0 ? ' vs ' : ''}
{t.name}{t.isHome ? ' (Home)' : ''}
</Text>
))}
</Text>
)}
</Stack>
</>
)}
{hasCredits && (
<>
<Divider color="#333" />
@ -330,21 +369,13 @@ export default function ProgramDetailModal({
</>
)}
{(d.country ||
d.language ||
{(d.language ||
d.original_air_date ||
(d.production_date && d.is_previously_shown) ||
starRatings.length > 0) && (
<>
<Divider color="#333" />
<Group gap="md" wrap="wrap">
{d.country && (
<Text size="sm" c="dimmed">
<Text span fw={600}>
Country:{' '}
</Text>
{d.country}
</Text>
)}
{d.language && (
<Text size="sm" c="dimmed">
<Text span fw={600}>
@ -353,6 +384,14 @@ export default function ProgramDetailModal({
{d.language}
</Text>
)}
{d.production_date && d.is_previously_shown && (
<Text size="sm" c="dimmed">
<Text span fw={600}>
First aired:{' '}
</Text>
{d.production_date}
</Text>
)}
{d.original_air_date && (
<Text size="sm" c="dimmed">
<Text span fw={600}>

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,4 +1,4 @@
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 {
@ -18,10 +18,10 @@ import {
Box,
Text,
UnstyledButton,
TextInput,
ActionIcon,
AppShellNavbar,
ScrollArea,
Skeleton,
Tooltip,
} from '@mantine/core';
import logo from '../images/logo.png';
@ -161,10 +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);
@ -289,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
@ -336,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>
)}

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

@ -96,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}
@ -122,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}</>,
};
});
@ -290,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');
@ -492,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();
@ -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>

File diff suppressed because it is too large Load diff

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

@ -2,6 +2,20 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Utility mocks
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({}),
});
vi.mock('../../../api.js', () => ({
default: {
getSDLineups: vi.fn().mockResolvedValue([]),
addSDLineup: vi.fn().mockResolvedValue({ success: true }),
deleteSDLineup: vi.fn().mockResolvedValue({ success: true }),
searchSDLineups: vi.fn().mockResolvedValue([]),
updateSDSettings: vi.fn().mockResolvedValue({}),
},
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
@ -184,6 +198,62 @@ vi.mock('@mantine/core', async () => ({
{error && <span data-testid="input-error">{error}</span>}
</div>
),
Alert: ({ children, title, color, icon }) => (
<div data-testid="alert" data-color={color}>
{title && <div data-testid="alert-title">{title}</div>}
{children}
</div>
),
Select: ({ label, value, onChange, data, placeholder }) => (
<select
aria-label={label}
data-testid={`select-${label?.toString().toLowerCase().replace(/\s+/g, '-')}`}
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
>
<option value="">{placeholder}</option>
{(data ?? []).map((opt) => {
const val = typeof opt === 'string' ? opt : opt.value;
const lbl = typeof opt === 'string' ? opt : opt.label;
return <option key={val} value={val}>{lbl}</option>;
})}
</select>
),
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
Badge: ({ children, color }) => <span data-testid="badge" data-color={color}>{children}</span>,
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Table: ({ children }) => <table>{children}</table>,
Tooltip: ({ children }) => <div>{children}</div>,
Switch: ({ label, checked, onChange, disabled, description }) => (
<label>
<input
type="checkbox"
data-testid={`switch-${label?.toString().toLowerCase().replace(/\s+/g, '-')}`}
checked={checked ?? false}
onChange={(e) => onChange?.(e)}
disabled={disabled}
/>
{label}
</label>
),
UnstyledButton: ({ children, onClick, ...props }) => (
<button type="button" onClick={onClick} {...props}>{children}</button>
),
Alert: ({ children, title, color, icon }) => (
<div data-testid="alert" data-color={color}><strong>{title}</strong>{children}</div>
),
Stack: ({ children, gap }) => <div data-testid="stack">{children}</div>,
Text: ({ children, ...props }) => <span>{children}</span>,
TextInput: ({ label, value, onChange, placeholder, ...props }) => (
<input
type="text"
aria-label={label}
data-testid={`input-${label?.toString().toLowerCase().replace(/\s+/g, '-')}`}
value={value || ''}
onChange={onChange}
placeholder={placeholder}
/>
),
}));
// Imports after mocks
@ -373,10 +443,11 @@ describe('EPG', () => {
expect(screen.queryByTestId('input-api-key')).not.toBeInTheDocument();
});
it('shows API key input when source type requires it', () => {
it('shows username and password inputs when source type is schedules_direct', () => {
const epg = makeEPG({ source_type: 'schedules_direct' });
render(<EPG {...defaultProps({ epg })} />);
expect(screen.getByTestId('input-api-key')).toBeInTheDocument();
expect(screen.getByTestId('input-username')).toBeInTheDocument();
expect(screen.getByTestId('input-password')).toBeInTheDocument();
});
});

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

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

@ -99,52 +99,23 @@ const RowActions = ({ tableSize, row, editEPG, deleteEPG, refreshEPG }) => {
);
};
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [dummyEpgModalOpen, setDummyEpgModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [epgToDelete, setEpgToDelete] = useState(null);
const [data, setData] = useState([]);
const [deleting, setDeleting] = useState(false);
const epgs = useEPGsStore((s) => s.epgs);
const refreshProgress = useEPGsStore((s) => s.refreshProgress);
const EPGStatusCell = ({ epg }) => {
// Direct Zustand subscription scoped to this source only.
// This component re-renders whenever its source's progress changes,
// independent of the parent table's render cycle.
const progress = useEPGsStore((s) => s.refreshProgress[epg.id]);
const theme = useMantineTheme();
const { fullDateTimeFormat } = useDateTimeFormat();
const [tableSize] = useLocalStorage('table-size', 'default');
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const toggleActive = async (epg) => {
try {
// Validate that epg is a valid object with an id
if (!epg || typeof epg !== 'object' || !epg.id) {
console.error('toggleActive called with invalid epg:', epg);
return;
}
// Send only the is_active field to trigger our special handling
await API.updateEPG(
{
id: epg.id,
is_active: !epg.is_active,
},
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
console.error('Error toggling active state:', error);
}
};
const buildProgressDisplay = (data) => {
const progress = refreshProgress[data.id] || null;
if (!progress) return null;
const isDummyEPG = epg.source_type === 'dummy';
if (isDummyEPG) return null;
// Show progress bar if an active fetch is in progress
if (
progress &&
(progress.progress < 100 ||
progress.status === 'in_progress' ||
(progress.action === 'parsing_channels' && epg.status === 'parsing'))
) {
let label = '';
switch (progress.action) {
case 'downloading':
@ -163,7 +134,6 @@ const EPGsTable = () => {
return null;
}
// Build additional info string from progress data
let additionalInfo = '';
if (progress.message) {
additionalInfo = progress.message;
@ -201,6 +171,96 @@ const EPGsTable = () => {
)}
</Stack>
);
}
// Show error message
if (epg.status === 'error' && epg.last_message) {
return (
<Tooltip label={epg.last_message} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.red[6], lineHeight: 1.3 }}
>
{epg.last_message}
</Text>
</Tooltip>
);
}
// Show success message
if (epg.status === 'success') {
const successMessage =
epg.last_message || 'EPG data refreshed successfully';
return (
<Tooltip label={successMessage} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.green[6], lineHeight: 1.3 }}
>
{successMessage}
</Text>
</Tooltip>
);
}
// Show idle message
if (epg.status === 'idle' && epg.last_message) {
return (
<Tooltip label={epg.last_message} multiline width={300}>
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.3 }}>
{epg.last_message}
</Text>
</Tooltip>
);
}
return null;
};
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [dummyEpgModalOpen, setDummyEpgModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [epgToDelete, setEpgToDelete] = useState(null);
const [data, setData] = useState([]);
const [deleting, setDeleting] = useState(false);
const [confirmSDRefreshOpen, setConfirmSDRefreshOpen] = useState(false);
const [sdRefreshTarget, setSDRefreshTarget] = useState(null);
const epgs = useEPGsStore((s) => s.epgs);
const theme = useMantineTheme();
const { fullDateTimeFormat } = useDateTimeFormat();
const [tableSize] = useLocalStorage('table-size', 'default');
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const toggleActive = async (epg) => {
try {
// Validate that epg is a valid object with an id
if (!epg || typeof epg !== 'object' || !epg.id) {
console.error('toggleActive called with invalid epg:', epg);
return;
}
// Send only the is_active field to trigger our special handling
await API.updateEPG(
{
id: epg.id,
is_active: !epg.is_active,
},
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
console.error('Error toggling active state:', error);
}
};
const columns = useMemo(
@ -214,21 +274,42 @@ const EPGsTable = () => {
{
header: 'Type',
accessorKey: 'source_type',
size: 100,
size: 130,
cell: ({ cell }) => {
const typeMap = {
xmltv: 'XMLTV',
schedules_direct: 'Schedules Direct',
dummy: 'Custom Dummy',
};
return typeMap[cell.getValue()] || cell.getValue();
},
},
{
header: 'URL / API Key / File Path',
header: 'Source / Credentials / File Path',
accessorKey: 'url',
enableSorting: false,
minSize: 250,
cell: ({ cell, row }) => {
const value =
cell.getValue() ||
row.original.api_key ||
row.original.file_path ||
'';
const sourceType = row.original.source_type;
let value = '';
let tooltip = '';
if (sourceType === 'schedules_direct') {
// Never expose credentials show username only
const username = row.original.username || '';
value = username ? `User: ${username}` : '(credentials set)';
tooltip = value;
} else {
value =
cell.getValue() ||
row.original.password ||
row.original.file_path ||
'';
tooltip = value;
}
return (
<Tooltip label={value} disabled={!value}>
<Tooltip label={tooltip} disabled={!tooltip}>
<div
style={{
whiteSpace: 'nowrap',
@ -267,76 +348,7 @@ const EPGsTable = () => {
enableSorting: false,
minSize: 250,
grow: true,
cell: ({ row }) => {
const data = row.original;
const isDummyEPG = data.source_type === 'dummy';
// Dummy EPGs don't have status messages
if (isDummyEPG) {
return null;
}
// Check if there's an active progress for this EPG - show progress first if active
if (
refreshProgress[data.id] &&
refreshProgress[data.id].progress < 100
) {
return buildProgressDisplay(data);
}
// Show error message when status is error
if (data.status === 'error' && data.last_message) {
return (
<Tooltip label={data.last_message} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.red[6], lineHeight: 1.3 }}
>
{data.last_message}
</Text>
</Tooltip>
);
}
// Show success message for successful sources
if (data.status === 'success') {
const successMessage =
data.last_message || 'EPG data refreshed successfully';
return (
<Tooltip label={successMessage} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.green[6], lineHeight: 1.3 }}
>
{successMessage}
</Text>
</Tooltip>
);
}
// Show last_message for idle sources (from previous refresh)
if (data.status === 'idle' && data.last_message) {
return (
<Tooltip label={data.last_message} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ lineHeight: 1.3 }}
>
{data.last_message}
</Text>
</Tooltip>
);
}
// Otherwise return empty cell
return null;
},
cell: ({ row }) => <EPGStatusCell epg={row.original} />,
},
{
header: 'Updated',
@ -380,14 +392,15 @@ const EPGsTable = () => {
size: tableSize == 'compact' ? 75 : 100,
},
],
[refreshProgress, fullDateTimeFormat]
[fullDateTimeFormat]
);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editEPG = async (epg = null) => {
setEPG(epg);
const freshEpg = epg?.id ? epgs[epg.id] || epg : epg;
setEPG(freshEpg);
// Open the appropriate modal based on source type
if (epg?.source_type === 'dummy') {
setDummyEpgModalOpen(true);
@ -430,13 +443,27 @@ const EPGsTable = () => {
}
};
const refreshEPG = async (id) => {
await API.refreshEPG(id);
const refreshEPG = async (id, force = false) => {
await API.refreshEPG(id, force);
notifications.show({
title: 'EPG refresh initiated',
});
};
const handleRefreshEPG = (id) => {
const epgObj = epgs[id];
if (
epgObj?.source_type === 'schedules_direct' &&
epgObj?.updated_at &&
Date.now() - new Date(epgObj.updated_at).getTime() < 2 * 60 * 60 * 1000
) {
setSDRefreshTarget(id);
setConfirmSDRefreshOpen(true);
return;
}
refreshEPG(id);
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
@ -469,7 +496,7 @@ const EPGsTable = () => {
row={row}
editEPG={editEPG}
deleteEPG={deleteEPG}
refreshEPG={refreshEPG}
refreshEPG={handleRefreshEPG}
/>
);
}
@ -677,6 +704,28 @@ const EPGsTable = () => {
onClose={closeDummyEPGForm}
/>
<ConfirmationDialog
opened={confirmSDRefreshOpen}
onClose={() => setConfirmSDRefreshOpen(false)}
onConfirm={() => {
setConfirmSDRefreshOpen(false);
refreshEPG(sdRefreshTarget, true);
}}
title="Refresh Schedules Direct Early?"
message={
<div>
<p>This source was refreshed less than 2 hours ago.</p>
<p>
Schedules Direct rate-limits requests per account. Refreshing too
frequently may cause your account to be temporarily blocked.
</p>
<p>Are you sure you want to force a refresh now?</p>
</div>
}
confirmLabel="Refresh Anyway"
cancelLabel="Cancel"
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
@ -691,10 +740,12 @@ const EPGsTable = () => {
Name: ${epgToDelete.name}
Source Type: ${epgToDelete.source_type}
${
epgToDelete.url
? `URL: ${epgToDelete.url}`
: epgToDelete.api_key
? `API Key: ${epgToDelete.api_key}`
epgToDelete.source_type === 'schedules_direct'
? epgToDelete.username
? `Username: ${epgToDelete.username}`
: '(credentials set)'
: epgToDelete.url
? `URL: ${epgToDelete.url}`
: epgToDelete.file_path
? `File Path: ${epgToDelete.file_path}`
: ''

View file

@ -71,7 +71,7 @@ const SizedInstallButton = ({
: 'var(--mantine-primary-color-filled-hover)'
: colorVar,
filter: isDisabled
? 'brightness(0.65) saturate(0.7)'
? 'grayscale(1) brightness(0.55)'
: hovered
? 'brightness(0.86)'
: 'brightness(0.82)',

View file

@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import API from '../api';
export const useEpgPreview = (epgDataId) => {
const [currentProgram, setCurrentProgram] = useState(null);
const [isLoadingProgram, setIsLoadingProgram] = useState(false);
const [hasFetchedProgram, setHasFetchedProgram] = useState(false);
useEffect(() => {
if (!epgDataId || epgDataId === '0' || epgDataId === '') {
setCurrentProgram(null);
setIsLoadingProgram(false);
setHasFetchedProgram(false);
return;
}
let cancelled = false;
setIsLoadingProgram(true);
setHasFetchedProgram(false);
const fetchWithRetry = async () => {
const maxRetries = 20;
const deadlineMs = 3 * 60 * 1000;
const startTime = Date.now();
let delay = 3000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (cancelled || Date.now() - startTime > deadlineMs) break;
try {
const program = await API.getCurrentProgramForEpg(epgDataId);
if (cancelled) return;
if (program && program.parsing && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 1.5, 15000);
continue;
}
if (!cancelled) {
setCurrentProgram(program && !program.parsing ? program : null);
setIsLoadingProgram(false);
setHasFetchedProgram(true);
}
return;
} catch (error) {
if (!cancelled) {
console.error('Failed to fetch current program:', error);
}
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 1.5, 15000);
}
}
}
if (!cancelled) {
setCurrentProgram(null);
setIsLoadingProgram(false);
setHasFetchedProgram(true);
}
};
fetchWithRetry();
return () => {
cancelled = true;
};
}, [epgDataId]);
return { currentProgram, isLoadingProgram, hasFetchedProgram };
};

View file

@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks';
import { VariableSizeList } from 'react-window';
import {
buildChannelIdMap,
calculateDesiredScrollPosition,
calculateEarliestProgramStart,
calculateEnd,
calculateHourTimeline,
calculateLatestProgramEnd,
calculateLeftScrollPosition,
calculateNowPosition,
calculateScrollPosition,
calculateScrollPositionByTimeClick,
@ -47,7 +45,6 @@ import {
computeRowHeights,
createRecording,
createSeriesRule,
evaluateSeriesRule,
fetchPrograms,
fetchRules,
filterGuideChannels,
@ -66,6 +63,7 @@ import {
PX_PER_MS,
calcProgressPct,
sortChannels,
evaluateSeriesRulesByTvgId,
} from '../utils/guideUtils';
import API from '../api';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
await createRecording(channel, program);
await createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: { program },
});
showNotification({ title: 'Recording scheduled' });
}, []);
const saveSeriesRule = useCallback(async (program, mode) => {
await createSeriesRule(program, mode);
await evaluateSeriesRule(program);
await createSeriesRule({
tvg_id: program.tvg_id,
mode,
title: program.title,
});
await evaluateSeriesRulesByTvgId(program.tvg_id);
// recordings_refreshed WS event triggers the debounced fetchRecordings()
showNotification({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',

View file

@ -8,6 +8,7 @@ import {
Group,
Loader,
Modal,
NativeSelect,
NumberInput,
Pagination,
Select,
@ -68,6 +69,7 @@ export default function PluginBrowsePage() {
const saveIntervalTimer = useRef(null);
const recentlyInstalledSlugs = useRef(new Set());
const recentlyUpdatedSlugs = useRef(new Set());
const recentlyUninstalledSlugs = useRef(new Set());
const [searchQuery, setSearchQuery] = useState('');
@ -75,7 +77,14 @@ export default function PluginBrowsePage() {
const [filterRepo, setFilterRepo] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [page, setPage] = useState(1);
const perPage = 9;
const [perPage, setPerPage] = useState(() => {
const stored = localStorage.getItem('pluginBrowsePerPage');
return stored && !isNaN(Number(stored)) ? Number(stored) : 9;
});
const handlePerPageChange = (value) => {
setPerPage(Number(value));
localStorage.setItem('pluginBrowsePerPage', value);
};
const hasFetched = useRef(false);
@ -294,6 +303,8 @@ export default function PluginBrowsePage() {
// Pre-sort weights: deprecated installed incompatible sink to bottom (in that order)
// Recently installed plugins are exempt so they don't jump away after install
const weight = (p) => {
if (p.install_status === 'update_available') return -1;
if (recentlyUpdatedSlugs.current.has(p.slug)) return -1;
if (recentlyInstalledSlugs.current.has(p.slug)) return 0;
if (recentlyUninstalledSlugs.current.has(p.slug)) return 2;
const meetsMin =
@ -335,10 +346,10 @@ export default function PluginBrowsePage() {
appVersion,
]);
// Reset to page 1 when filters/search change
// Reset to page 1 when filters/search/page-size change
React.useEffect(() => {
setPage(1);
}, [searchQuery, filterRepo, filterStatus, sortBy]);
}, [searchQuery, filterRepo, filterStatus, sortBy, perPage]);
const totalPages = Math.ceil(filteredPlugins.length / perPage);
const paginatedPlugins = filteredPlugins.slice(
@ -347,7 +358,15 @@ export default function PluginBrowsePage() {
);
return (
<AppShellMain p={16}>
<AppShellMain
style={{
padding: 0,
display: 'flex',
flexDirection: 'column',
minHeight: '100%',
}}
>
<Box p={16} pb={60} style={{ flex: 1 }}>
<Group justify="space-between" mb="md">
<Group gap="xs" align="center">
<Text fw={700} size="lg">
@ -467,38 +486,69 @@ export default function PluginBrowsePage() {
)}
{!loading && filteredPlugins.length > 0 && (
<>
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 1}
onBeforeInstall={(slug) => {
if (slug) recentlyInstalledSlugs.current.add(slug);
}}
onInstalled={(slug) => {
if (slug) recentlyInstalledSlugs.current.add(slug);
fetchAvailablePlugins();
}}
onUninstalled={(slug) => {
if (slug) recentlyUninstalledSlugs.current.add(slug);
}}
/>
))}
</SimpleGrid>
{totalPages > 1 && (
<Group justify="center" mt="lg">
<Pagination
value={page}
onChange={setPage}
total={totalPages}
size="sm"
/>
</Group>
)}
</>
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 1}
onBeforeInstall={(slug) => {
if (slug) {
if (p.install_status === 'update_available') {
recentlyUpdatedSlugs.current.add(slug);
} else {
recentlyInstalledSlugs.current.add(slug);
}
}
}}
onInstalled={(slug) => {
if (slug) recentlyInstalledSlugs.current.add(slug);
fetchAvailablePlugins();
}}
onUninstalled={(slug) => {
if (slug) recentlyUninstalledSlugs.current.add(slug);
}}
/>
))}
</SimpleGrid>
)}
</Box>
{!loading && filteredPlugins.length > 0 && (
<Box
style={{
position: 'fixed',
bottom: 0,
left: 'var(--app-shell-navbar-offset, 0rem)',
right: 0,
zIndex: 100,
background: '#1A1A1E',
borderTop: '1px solid #2A2A2E',
}}
>
<Group gap={5} justify="center" style={{ padding: 8 }}>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={String(perPage)}
data={['9', '18', '27', '36']}
onChange={(e) => handlePerPageChange(e.target.value)}
styles={{ input: { textAlignLast: 'center' } }}
/>
<Pagination
total={totalPages}
value={page}
onChange={setPage}
size="xs"
withEdges
/>
<Text size="xs">
{`${(page - 1) * perPage + 1} to ${Math.min(page * perPage, filteredPlugins.length)} of ${filteredPlugins.length}`}
</Text>
</Group>
</Box>
)}
{/* Manage Repos Modal */}

View file

@ -212,19 +212,19 @@ const StatsPage = () => {
}
}, [setVodStats]);
// Always fetch once on mount, regardless of polling interval setting
useEffect(() => {
fetchChannelStats();
fetchVODStats();
}, [fetchChannelStats, fetchVODStats]);
// Set up polling for stats when on stats page
useEffect(() => {
const location = window.location;
const isOnStatsPage = location.pathname === '/stats';
const isOnStatsPage = window.location.pathname === '/stats';
if (isOnStatsPage && refreshInterval > 0) {
setIsPollingActive(true);
// Initial fetch
fetchChannelStats();
fetchVODStats();
// Set up interval
const interval = setInterval(() => {
fetchChannelStats();
fetchVODStats();
@ -239,12 +239,6 @@ const StatsPage = () => {
}
}, [refreshInterval, fetchChannelStats, fetchVODStats]);
// Fetch initial stats on component mount (for immediate data when navigating to page)
useEffect(() => {
fetchChannelStats();
fetchVODStats();
}, [fetchChannelStats, fetchVODStats]);
useEffect(() => {
console.log('Processing channel stats:', channelStats);
if (

View file

@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useLocalStorage from '../../hooks/useLocalStorage';
import { showNotification } from '../../utils/notificationUtils.js';
import * as guideUtils from '../../utils/guideUtils';
import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
import userEvent from '@testing-library/user-event';
// Mock dependencies
vi.mock('../../store/channels');
vi.mock('../../store/logos');
vi.mock('../../store/epgs');
vi.mock('../../store/settings');
vi.mock('../../store/useVideoStore');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../api');
vi.mock('../../store/channels', () => ({
default: vi.fn(),
}));
vi.mock('../../store/logos', () => ({
default: vi.fn(),
}));
vi.mock('../../store/epgs', () => ({
default: vi.fn(),
}));
vi.mock('../../store/settings', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVideoStore', () => ({
default: vi.fn(),
}));
vi.mock('../../hooks/useLocalStorage', () => ({
default: vi.fn(),
}));
vi.mock('../../api', () => ({
default: {
getAllChannelIds: vi.fn(),
getChannelsSummary: vi.fn(),
},
}));
vi.mock('@mantine/hooks', () => ({
useElementSize: () => ({
@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({
}));
vi.mock('../../components/ProgramDetailModal', () => ({
__esModule: true,
default: ({ program, channel, opened, onClose, onRecord }) =>
default: ({ program, opened, onClose, onRecord }) =>
opened ? (
<div data-testid="program-detail-modal">
<div>{program?.title}</div>
@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => {
fetchPrograms: vi.fn(),
createRecording: vi.fn(),
createSeriesRule: vi.fn(),
evaluateSeriesRule: vi.fn(),
fetchRules: vi.fn(),
filterGuideChannels: vi.fn(),
getGroupOptions: vi.fn(),
getProfileOptions: vi.fn(),
sortChannels: vi.fn((ch) => ch),
};
});
vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
@ -403,7 +419,6 @@ describe('Guide', () => {
);
guideUtils.createRecording.mockResolvedValue(undefined);
guideUtils.createSeriesRule.mockResolvedValue(undefined);
guideUtils.evaluateSeriesRule.mockResolvedValue(undefined);
guideUtils.getGroupOptions.mockReturnValue([
{ value: 'all', label: 'All Groups' },
{ value: 'group-1', label: 'News' },
@ -666,8 +681,7 @@ describe('Guide', () => {
await waitFor(() => {
expect(guideUtils.createRecording).toHaveBeenCalledWith(
expect.objectContaining({ id: 'channel-1' }),
expect.objectContaining({ id: 'prog-1' })
expect.objectContaining({ channel: 'channel-1' }),
);
});
});

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