diff --git a/.changes/README.md b/.changes/README.md index 2e529ab0f..a2a8454b6 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -30,7 +30,7 @@ of the current season, watch progress, and click-to-play inline. | `type` | yes | `breaking`, `feature`, `fix`, `perf`, or `internal` | | `area` | yes | lowercase slug, same as the conventional-commit scope | | `issues` | no | issue numbers this closes — `[1187]` or `1187` | -| `screenshot` | no | slug from the release screenshot manifest | +| `screenshot` | no | slug from `tools/release/screenshots.manifest.json` | There is **no version field**. The release version is chosen deliberately at release time, not derived from these files. @@ -84,3 +84,26 @@ The website publishes **one post per minor version** (`v0-18` … `v0-22`), and release screenshots live under the matching `blog/v0-24/` directory. A patch release therefore edits the existing post rather than generating a new one, so `--format blog` refuses to overwrite unless you pass `--force`. + +## Screenshots + +`pnpm run release:screenshots` captures every manifest shot in dark and light +against the built app plus the Xtream mock server — never a real account. +The run is fail-closed: it proves the real `~/.iptvnator/databases` directory +(including the SQLite WAL sidecars, checked after Electron exits) was not +touched, launches the app with an allowlisted environment, records and blocks +all non-localhost traffic, scans every frame for external resources and +credential-shaped text, and asserts TMDB enrichment stays disabled. Frames are +staged outside the repository and published only once every shot and every +guard has passed. + +Adding a shot for a new feature = one entry in +`tools/release/screenshots.manifest.json` (plus, if navigation is new, one +named action in `tools/release/capture-navigation.ts`). + +```bash +pnpm nx run electron-backend:build-e2e # once, before capturing +pnpm run release:screenshots # all shots, both themes +pnpm run release:screenshots -- --only dashboard --theme dark +pnpm run release:screenshots -- --release v0-24 +``` diff --git a/.changes/deps-transitive-cve-overrides.md b/.changes/deps-transitive-cve-overrides.md new file mode 100644 index 000000000..8be3e46d5 --- /dev/null +++ b/.changes/deps-transitive-cve-overrides.md @@ -0,0 +1,8 @@ +--- +type: internal +area: deps +--- + +Patched five vulnerable transitive dependencies that ship with the app — +including the YAML parser `electron-updater` uses to read update manifests, and +the HTTP form encoder behind portal requests. No behaviour change. diff --git a/.changes/m3u-favorites-hydration.md b/.changes/m3u-favorites-hydration.md new file mode 100644 index 000000000..97d9deb64 --- /dev/null +++ b/.changes/m3u-favorites-hydration.md @@ -0,0 +1,7 @@ +--- +type: perf +area: m3u +--- + +Importing large M3U playlists is faster because IPTVnator no longer rewrites +the entire playlist when loading saved favorites. diff --git a/.changes/m3u-refresh-cancellation.md b/.changes/m3u-refresh-cancellation.md new file mode 100644 index 000000000..6029dad22 --- /dev/null +++ b/.changes/m3u-refresh-cancellation.md @@ -0,0 +1,8 @@ +--- +type: perf +area: m3u +--- + +Cancelling a large M3U refresh now stops its background worker before parsed +channels can be copied or saved, keeping the interface responsive and leaving +the existing playlist unchanged. diff --git a/.changes/playback-subtitle-preference.md b/.changes/playback-subtitle-preference.md new file mode 100644 index 000000000..8c320a9d1 --- /dev/null +++ b/.changes/playback-subtitle-preference.md @@ -0,0 +1,11 @@ +--- +type: fix +area: playback +issues: [1155] +--- + +The "Show subtitles" setting now works in the built-in players: turning it off +hides subtitles a stream switched on by itself, and the preference finally +applies on Xtream and Stalker pages too. Previously it only reached the M3U +player, and even there Video.js and ArtPlayer ignored it. The player's own +subtitle menu still overrides the setting for the current stream. diff --git a/.changes/tmdb-broken-provider-id.md b/.changes/tmdb-broken-provider-id.md new file mode 100644 index 000000000..cbc5b6cf4 --- /dev/null +++ b/.changes/tmdb-broken-provider-id.md @@ -0,0 +1,10 @@ +--- +type: fix +area: tmdb +--- + +Movies whose provider ships a dead or wrong TMDB id are enriched again. The +id is weighed against the title and release year: a dead one falls back to +the title search, a stale one that clearly points at another film loses to +it, and a working id is no longer thrown away just because the provider +spells the title differently. diff --git a/.changes/tmdb-cache-panel.md b/.changes/tmdb-cache-panel.md new file mode 100644 index 000000000..0156ea055 --- /dev/null +++ b/.changes/tmdb-cache-panel.md @@ -0,0 +1,8 @@ +--- +type: feature +area: tmdb +--- + +Settings → Metadata (TMDB) now shows how many entries the metadata cache +holds and how much space they take, with a button to empty it. Clearing +costs nothing but the next few lookups — enrichment refetches on demand. diff --git a/.changes/tmdb-series-cast.md b/.changes/tmdb-series-cast.md new file mode 100644 index 000000000..037ad2f8a --- /dev/null +++ b/.changes/tmdb-series-cast.md @@ -0,0 +1,9 @@ +--- +type: fix +area: tmdb +--- + +Series detail pages now list the cast of the whole show instead of only its +newest season, so actors who left partway through stop disappearing from +long-running shows — while people who joined for the current season still +show up. diff --git a/.changes/window-controls-fullscreen-exit.md b/.changes/window-controls-fullscreen-exit.md new file mode 100644 index 000000000..65607da9b --- /dev/null +++ b/.changes/window-controls-fullscreen-exit.md @@ -0,0 +1,9 @@ +--- +type: fix +area: window-controls +--- + +On Windows, the minimize, maximize and close buttons disappeared for good after +leaving fullscreen video playback — the only way to get them back was to restart +the app. They now reappear as soon as you exit fullscreen, and the maximize +button no longer gets stuck on the wrong icon afterwards. diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 2007a7795..62e07edd5 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -42,11 +42,21 @@ is not optional. for a patch release, edit the existing post (the scaffold refuses to overwrite without `--force`). -5. **Screenshots** — only from the release capture script against the mock - servers (`tools/release/`), never from a real playlist or account: real - streams, logos, and TMDB artwork are copyrighted, and credentials must - never reach a published image. Output goes to - `apps/website/public/blog/v0-XX/screenshots/`. +5. **Screenshots** — only from the fail-closed capture script against the + mock servers, never from a real playlist or account: real streams, logos, + and TMDB artwork are copyrighted, and credentials must never reach a + published image. + + ```bash + pnpm nx run electron-backend:build-e2e # once + pnpm run release:screenshots # all manifest shots, dark+light + ``` + + Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature + to showcase = new entry in `tools/release/screenshots.manifest.json` + (slug must match the note's `screenshot:` field). The run aborts and + deletes its frames on any guard violation (real-DB touch, external + request, credential-shaped text in frame, TMDB active). 6. **Consume the notes** (the only destructive step): diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 2007a7795..62e07edd5 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -42,11 +42,21 @@ is not optional. for a patch release, edit the existing post (the scaffold refuses to overwrite without `--force`). -5. **Screenshots** — only from the release capture script against the mock - servers (`tools/release/`), never from a real playlist or account: real - streams, logos, and TMDB artwork are copyrighted, and credentials must - never reach a published image. Output goes to - `apps/website/public/blog/v0-XX/screenshots/`. +5. **Screenshots** — only from the fail-closed capture script against the + mock servers, never from a real playlist or account: real streams, logos, + and TMDB artwork are copyrighted, and credentials must never reach a + published image. + + ```bash + pnpm nx run electron-backend:build-e2e # once + pnpm run release:screenshots # all manifest shots, dark+light + ``` + + Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature + to showcase = new entry in `tools/release/screenshots.manifest.json` + (slug must match the note's `screenshot:` field). The run aborts and + deletes its frames on any guard violation (real-DB touch, external + request, credential-shaped text in frame, TMDB active). 6. **Consume the notes** (the only destructive step): diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f8b1031d..bc86e2950 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,7 +176,7 @@ jobs: - name: Upload unit coverage to Codecov if: always() - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml flags: unit diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index b7c0b8bac..01a5143c7 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -42,7 +42,7 @@ jobs: run: pnpm nx build website - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: dist/apps/website diff --git a/AGENTS.md b/AGENTS.md index aa1215c03..014efff5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching @@ -159,6 +160,26 @@ Key files: links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. +- `Settings.showCaptions` is deliberately outside this rollout gate: it is + engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both + modes — shared controls through their controls bridge, the preference-off + paths through the same helpers without an adapter (`WebVideoSourceTracks` for + HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference + as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it + from `SettingsStore` rather than a host input, so the M3U player, the + Xtream/Stalker live layouts, and the portal detail inline player all inherit + it (#1155). +- The modes differ in how long the preference is enforced. Shared controls are + authoritative for the session; user intent arrives through `setSubtitleTrack` + and wins until the source changes. Vendor chrome is source-default: the + preference seeds each new source and is released once the media element + reports `playing`, so the engine's own caption menu keeps working. The mode is + selected by the optional `playbackStarted` probe the legacy owners pass to all + three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper + deselects the track (`subtitleTrack = -1`) instead of hiding it, because + `subtitleDisplay` would silently override whatever the vendor menu picks. For + DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads, + so the helper only stops re-suppressing afterwards. - Embedded MPV ignores the web-player preference. Frame-copy always uses shared DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while native-view retains the legacy compositor-safe dock and external MPV/VLC diff --git a/CLAUDE.md b/CLAUDE.md index ae30b0305..a29a20585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching @@ -252,6 +253,7 @@ This is an Nx monorepo with the following structure: - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) - **shared/m3u-utils** - M3U playlist utilities + - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks - **shared/testing** - Shared test helpers - **ui/components** - Reusable UI components (incl. channel list) - **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs) @@ -430,7 +432,7 @@ State management via NgRx (`libs/m3u-state/`): - `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist - `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive - `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag -- `FavoritesActions`: updateFavorites, setFavorites +- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites See `docs/architecture/m3u-playlist-module.md` for complete documentation. @@ -615,7 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts` - Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`) -- Playlist refresh: `playlist-refresh.worker.ts` +- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`) ### Key Features @@ -772,7 +774,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command @@ -833,12 +835,12 @@ engine` (restart required) or - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint - Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday` -- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) +- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` -- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) -- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment +- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) +- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) -- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`, plus `DB_GET_TMDB_CACHE_STATS` / `DB_CLEAR_TMDB_METADATA` behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` diff --git a/apps/electron-backend-e2e/playwright.performance.config.ts b/apps/electron-backend-e2e/playwright.performance.config.ts new file mode 100644 index 000000000..58a6ba03b --- /dev/null +++ b/apps/electron-backend-e2e/playwright.performance.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + fullyParallel: false, + reporter: [['list']], + testDir: './src', + testMatch: '**/*.performance.ts', + timeout: 30 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index cdaa4ec01..1918e34fa 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,16 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "benchmark-m3u-refresh-cancellation": { + "dependsOn": ["electron-backend:build-e2e"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 8b4198ab4..df1f1f025 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -75,6 +75,7 @@ type ElectronFixtures = { }; export type LaunchElectronAppOptions = { + args?: readonly string[]; env?: Record; }; @@ -142,7 +143,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = [electronMainPath]; + const args = [...(options.args ?? []), electronMainPath]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); diff --git a/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts b/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts new file mode 100644 index 000000000..6ff9b5f6c --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; + +import { runM3uRefreshCancellationBenchmark } from './performance/m3u-refresh-cancellation.benchmark'; + +// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle. +test('profiles cancellation of a 100k M3U refresh', async () => { + await runM3uRefreshCancellationBenchmark(); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts new file mode 100644 index 000000000..f0abcd22f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -0,0 +1,219 @@ +export const PERFORMANCE_ITERATION_KIND = { + DIAGNOSTIC: 'diagnostic', + MEASURED: 'measured', + WARMUP: 'warmup', +} as const; + +export type PerformanceIterationKind = + (typeof PERFORMANCE_ITERATION_KIND)[keyof typeof PERFORMANCE_ITERATION_KIND]; + +export const PERFORMANCE_WORKER_KIND = { + DATABASE: 'database.worker', + PLAYLIST_REFRESH: 'playlist-refresh.worker', +} as const; + +export type PerformanceWorkerKind = + (typeof PERFORMANCE_WORKER_KIND)[keyof typeof PERFORMANCE_WORKER_KIND]; + +export interface NumericDistribution { + readonly count: number; + readonly max: number | null; + readonly mean: number | null; + readonly median: number | null; + readonly min: number | null; + readonly p95: number | null; + readonly p99: number | null; +} + +export interface EventLoopDelayMetrics { + readonly maxMs: number; + readonly p95Ms: number; + readonly p99Ms: number; +} + +export interface ProcessMemoryMetrics { + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number | null; + readonly postGcRssBytes: number | null; +} + +export interface MainTimelineRecord { + readonly epochMs: number; + readonly operation?: string; + readonly operationId?: string; + readonly playlistId?: string; + readonly requestId?: string; + readonly success?: boolean; + readonly type: string; +} + +export interface WorkerCaptureMetrics { + readonly cancelPostedEpochMs: number | null; + readonly cpuSystemMicros: number | null; + readonly cpuUserMicros: number | null; + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly kind: PerformanceWorkerKind; + readonly operationId: string | null; + readonly peakExternalBytes: number; + readonly peakHeapUsedBytes: number; + readonly playlistId: string | null; + readonly postGcHeapUsedBytes: number | null; + readonly profilePath: string | null; + readonly responseEpochMs: number | null; + readonly snapshotPath: string | null; + readonly terminatedEpochMs: number | null; +} + +export interface MainCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly eventLoopDelay: EventLoopDelayMetrics; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly heapSnapshotPath: string | null; + readonly memory: ProcessMemoryMetrics; + readonly rendererPeakRssBytes: number; + readonly rssScope: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly timeline: readonly MainTimelineRecord[]; + readonly unresponsiveEvents: number; + readonly responsiveEvents: number; + readonly workers: readonly WorkerCaptureMetrics[]; +} + +export interface PlaylistRefreshProbeEvent { + readonly operationId: string; + readonly phase: string | null; + readonly receivedEpochMs: number; + readonly status: string; +} + +export interface RendererProbeMetrics { + readonly cancelButtonFound: boolean; + readonly cancelClickEpochMs: number | null; + readonly events: readonly PlaylistRefreshProbeEvent[]; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; + readonly uiPhaseEpochMs: Readonly>; + readonly uiSettledEpochMs: number | null; +} + +export interface RendererCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number | null; + readonly probe: RendererProbeMetrics; + readonly tracePath: string | null; +} + +export interface CancellationPhaseMetrics { + readonly cancelAcknowledgementLatencyMs: number | null; + readonly cancelToDurableTerminalMs: number | null; + readonly cancelToWorkerTerminatedMs: number | null; + readonly cancelTransportLatencyMs: number | null; + readonly dataFetchMs: number | null; + readonly dbCommitProxyMs: number | null; + readonly ipcStoreDispatchProxyMs: number | null; + readonly parseNormalizeCloneProxyMs: number | null; + readonly persistencePreparationProxyMs: number | null; + readonly totalMs: number | null; + readonly uiSettlementToPaintMs: number | null; + readonly visibleTotalMs: number | null; +} + +export interface CancellationIterationResult { + readonly cancellationEffectObserved: boolean; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly phases: CancellationPhaseMetrics; + readonly renderer: RendererCaptureMetrics; + readonly runId: string; +} + +export interface CancellationBenchmarkManifest { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly fixtureSha256: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly memoryAccounting: { + readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly rendererRss: 'separate-renderer-process'; + readonly workerJsHeap: 'separate-v8-isolate'; + readonly workerRss: 'unavailable-per-thread'; + }; + readonly scenario: 'm3u-refresh-cancel'; + readonly syntheticSeed: number; + readonly runtime: { + readonly arch: string; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; + }; + readonly variant: string; + readonly warmupRuns: number; +} + +export interface CancellationBenchmarkSummary { + readonly cancellationEffectRate: number; + readonly iterations: readonly CancellationIterationResult[]; + readonly manifest: CancellationBenchmarkManifest; + readonly measured: { + readonly cancelAcknowledgementLatencyMs: NumericDistribution; + readonly cancelToDurableTerminalMs: NumericDistribution; + readonly cancelToWorkerTerminatedMs: NumericDistribution; + readonly cancelTransportLatencyMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerExternalPeakBytes: NumericDistribution; + readonly databaseWorkerHeapPeakBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly dataFetchMs: NumericDistribution; + readonly dbCommitProxyMs: NumericDistribution; + readonly ipcStoreDispatchProxyMs: NumericDistribution; + readonly mainEventLoopDelayMaxMs: NumericDistribution; + readonly mainEventLoopDelayP95Ms: NumericDistribution; + readonly mainEventLoopDelayP99Ms: NumericDistribution; + readonly mainEventLoopUtilization: NumericDistribution; + readonly mainHeapPeakBytes: NumericDistribution; + readonly mainHeapPostGcBytes: NumericDistribution; + readonly mainRssPeakBytes: NumericDistribution; + readonly mainRssPostGcBytes: NumericDistribution; + readonly parseNormalizeCloneProxyMs: NumericDistribution; + readonly persistencePreparationProxyMs: NumericDistribution; + readonly playlistWorkerEventLoopUtilization: NumericDistribution; + readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerExternalPeakBytes: NumericDistribution; + readonly playlistWorkerHeapPeakBytes: NumericDistribution; + readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly rendererFrameGapMs: NumericDistribution; + readonly rendererHeartbeatDelayMs: NumericDistribution; + readonly rendererHeapPeakBytes: NumericDistribution; + readonly rendererHeapPostGcBytes: NumericDistribution; + readonly rendererLongTaskCount: number; + readonly rendererLongTaskMs: NumericDistribution; + readonly rendererRssPeakBytes: NumericDistribution; + readonly responsiveEvents: number; + readonly totalMs: NumericDistribution; + readonly uiSettlementToPaintMs: NumericDistribution; + readonly unresponsiveEvents: number; + readonly visibleTotalMs: NumericDistribution; + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts new file mode 100644 index 000000000..2f063cd0a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -0,0 +1,414 @@ +/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */ +import type { + CancellationBenchmarkManifest, + CancellationBenchmarkSummary, + CancellationIterationResult, + CancellationPhaseMetrics, + MainCaptureMetrics, + MainTimelineRecord, + NumericDistribution, + PerformanceWorkerKind, + RendererCaptureMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; +import { + nonNegativeDifference, + summarizeNumbers, +} from './performance-statistics'; + +export function createCancellationIterationResult(input: { + readonly kind: CancellationIterationResult['kind']; + readonly main: MainCaptureMetrics; + readonly renderer: RendererCaptureMetrics; + readonly runId: string; +}): CancellationIterationResult { + const { main, renderer } = input; + const phases = derivePhases(main, renderer); + const cancellationEffectObserved = renderer.probe.events.some( + (event) => event.status === 'cancelled' + ); + + return Object.freeze({ + cancellationEffectObserved, + kind: input.kind, + main, + phases, + renderer, + runId: input.runId, + }); +} + +export function createCancellationBenchmarkSummary( + manifest: CancellationBenchmarkManifest, + iterations: readonly CancellationIterationResult[] +): CancellationBenchmarkSummary { + const measured = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + const flattenRenderer = ( + select: (iteration: CancellationIterationResult) => readonly number[] + ): number[] => measured.flatMap((iteration) => [...select(iteration)]); + const workerMetric = ( + kind: PerformanceWorkerKind, + select: (worker: MainCaptureMetrics['workers'][number]) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.map((iteration) => { + const worker = iteration.main.workers.find( + (candidate) => candidate.kind === kind + ); + return worker ? select(worker) : null; + }) + ); + const workerEventLoopDelayMetric = ( + kind: PerformanceWorkerKind, + percentile: keyof NonNullable< + MainCaptureMetrics['workers'][number]['eventLoopDelay'] + > + ): NumericDistribution => + workerMetric( + kind, + (worker) => worker.eventLoopDelay?.[percentile] ?? null + ); + + return Object.freeze({ + cancellationEffectRate: + measured.length === 0 + ? 0 + : measured.filter( + (iteration) => iteration.cancellationEffectObserved + ).length / measured.length, + iterations: Object.freeze([...iterations]), + manifest, + measured: Object.freeze({ + cancelAcknowledgementLatencyMs: summarizeNumbers( + measured.map( + (iteration) => + iteration.phases.cancelAcknowledgementLatencyMs + ) + ), + cancelToDurableTerminalMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelToDurableTerminalMs + ) + ), + cancelToWorkerTerminatedMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelToWorkerTerminatedMs + ) + ), + cancelTransportLatencyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelTransportLatencyMs + ) + ), + databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerEventLoopUtilization: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.eventLoopUtilization + ), + databaseWorkerExternalPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.peakExternalBytes + ), + databaseWorkerHeapPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.peakHeapUsedBytes + ), + databaseWorkerPostGcHeapBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.postGcHeapUsedBytes + ), + dataFetchMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.dataFetchMs) + ), + dbCommitProxyMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.dbCommitProxyMs) + ), + ipcStoreDispatchProxyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.ipcStoreDispatchProxyMs + ) + ), + mainEventLoopDelayMaxMs: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.maxMs) + ), + mainEventLoopDelayP95Ms: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.p95Ms) + ), + mainEventLoopDelayP99Ms: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.p99Ms) + ), + mainEventLoopUtilization: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopUtilization) + ), + mainHeapPeakBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.peakHeapUsedBytes + ) + ), + mainHeapPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.postGcHeapUsedBytes + ) + ), + mainRssPeakBytes: summarizeNumbers( + measured.map((iteration) => iteration.main.memory.peakRssBytes) + ), + mainRssPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.postGcRssBytes + ) + ), + parseNormalizeCloneProxyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.parseNormalizeCloneProxyMs + ) + ), + persistencePreparationProxyMs: summarizeNumbers( + measured.map( + (iteration) => + iteration.phases.persistencePreparationProxyMs + ) + ), + playlistWorkerEventLoopUtilization: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.eventLoopUtilization + ), + playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerExternalPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.peakExternalBytes + ), + playlistWorkerHeapPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.peakHeapUsedBytes + ), + playlistWorkerPostGcHeapBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.postGcHeapUsedBytes + ), + rendererFrameGapMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.frameGapsMs + ) + ), + rendererHeartbeatDelayMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.heartbeatDelaysMs + ) + ), + rendererHeapPeakBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.renderer.peakHeapUsedBytes + ) + ), + rendererHeapPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.renderer.postGcHeapUsedBytes + ) + ), + rendererLongTaskCount: measured.reduce( + (sum, iteration) => + sum + iteration.renderer.probe.longTasksMs.length, + 0 + ), + rendererLongTaskMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.longTasksMs + ) + ), + rendererRssPeakBytes: summarizeNumbers( + measured.map((iteration) => iteration.main.rendererPeakRssBytes) + ), + responsiveEvents: measured.reduce( + (sum, iteration) => sum + iteration.main.responsiveEvents, + 0 + ), + totalMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.totalMs) + ), + uiSettlementToPaintMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.uiSettlementToPaintMs + ) + ), + unresponsiveEvents: measured.reduce( + (sum, iteration) => sum + iteration.main.unresponsiveEvents, + 0 + ), + visibleTotalMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.visibleTotalMs) + ), + }), + }); +} + +function derivePhases( + main: MainCaptureMetrics, + renderer: RendererCaptureMetrics +): CancellationPhaseMetrics { + const started = findTimeline( + main.timeline, + 'playlist-event:started:fetching' + ); + const parsing = findTimeline( + main.timeline, + 'playlist-event:progress:parsing' + ); + const response = findTimeline(main.timeline, 'playlist-response'); + const playlistRequest = findTimeline(main.timeline, 'playlist-request'); + const playlistId = playlistRequest?.playlistId; + const dbGetRequest = findPlaylistDbRecord( + main.timeline, + 'db-request', + response?.epochMs, + 'DB_GET_APP_PLAYLIST', + playlistId + ); + const dbGetResponse = findResponse(main.timeline, dbGetRequest); + const dbUpsertRequest = findPlaylistDbRecord( + main.timeline, + 'db-request', + dbGetResponse?.epochMs ?? dbGetRequest?.epochMs ?? response?.epochMs, + 'DB_UPSERT_APP_PLAYLIST', + playlistId + ); + const dbUpsertResponse = findResponse(main.timeline, dbUpsertRequest); + const playlistWorker = main.workers.find( + (worker) => worker.kind === 'playlist-refresh.worker' + ); + const cancelClick = renderer.probe.cancelClickEpochMs; + const uiPainted = + renderer.probe.uiPaintedEpochMs ?? renderer.probe.terminalEpochMs; + const cancelledEvent = renderer.probe.events.find( + (event) => event.status === 'cancelled' + ); + const terminal = maxEpoch( + uiPainted, + dbUpsertResponse?.epochMs, + cancelledEvent?.receivedEpochMs, + response?.epochMs, + playlistWorker?.terminatedEpochMs + ); + + return Object.freeze({ + cancelAcknowledgementLatencyMs: nonNegativeDifference( + cancelledEvent?.receivedEpochMs, + cancelClick + ), + cancelToDurableTerminalMs: nonNegativeDifference(terminal, cancelClick), + cancelToWorkerTerminatedMs: nonNegativeDifference( + playlistWorker?.terminatedEpochMs, + cancelClick + ), + cancelTransportLatencyMs: nonNegativeDifference( + playlistWorker?.cancelPostedEpochMs, + cancelClick + ), + dataFetchMs: nonNegativeDifference(parsing?.epochMs, started?.epochMs), + dbCommitProxyMs: nonNegativeDifference( + dbUpsertResponse?.epochMs, + dbUpsertRequest?.epochMs + ), + ipcStoreDispatchProxyMs: nonNegativeDifference( + dbGetRequest?.epochMs, + response?.epochMs + ), + parseNormalizeCloneProxyMs: nonNegativeDifference( + response?.epochMs, + parsing?.epochMs + ), + persistencePreparationProxyMs: nonNegativeDifference( + dbUpsertRequest?.epochMs, + dbGetResponse?.epochMs + ), + totalMs: nonNegativeDifference( + terminal, + renderer.probe.operationStartEpochMs + ), + uiSettlementToPaintMs: nonNegativeDifference( + uiPainted, + renderer.probe.uiSettledEpochMs + ), + visibleTotalMs: nonNegativeDifference( + uiPainted, + renderer.probe.operationStartEpochMs + ), + }); +} + +function findTimeline( + timeline: readonly MainTimelineRecord[], + type: string +): MainTimelineRecord | undefined { + return timeline.find((record) => record.type === type); +} + +function findPlaylistDbRecord( + timeline: readonly MainTimelineRecord[], + type: string, + afterEpochMs: number | undefined, + operation: string, + playlistId: string | undefined +): MainTimelineRecord | undefined { + if (afterEpochMs === undefined) { + return undefined; + } + return timeline.find( + (record) => + record.type === type && + record.epochMs >= afterEpochMs && + record.operation === operation && + (playlistId === undefined || record.playlistId === playlistId) + ); +} + +function findResponse( + timeline: readonly MainTimelineRecord[], + request: MainTimelineRecord | undefined +): MainTimelineRecord | undefined { + if (request?.requestId === undefined) { + return undefined; + } + return timeline.find( + (record) => + record.type === 'db-response' && + record.requestId === request.requestId + ); +} + +function maxEpoch( + ...values: readonly (number | null | undefined)[] +): number | null { + const finite = values.filter( + (value): value is number => + typeof value === 'number' && Number.isFinite(value) + ); + return finite.length === 0 ? null : Math.max(...finite); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts new file mode 100644 index 000000000..fcfd83d1f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -0,0 +1,543 @@ +/* eslint-disable max-lines -- Benchmark lifecycle, local fixture server, and artifact safeguards stay auditable in one entry point. */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { createServer as createTcpServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { expect, type ConsoleMessage, type Page } from '@playwright/test'; + +import { + closeElectronApp, + launchElectronApp, + openSources, + sourceRowByTitle, + waitForM3uCatalog, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import type { + CancellationBenchmarkManifest, + CancellationIterationResult, + PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + createCancellationBenchmarkSummary, + createCancellationIterationResult, +} from './m3u-refresh-cancellation-report'; +import { + installMainCapture, + readMainCaptureStatus, + startMainCapture, + stopMainCapture, +} from './m3u-refresh-main-capture'; +import { startRendererCapture } from './m3u-refresh-renderer-capture'; +import { + createSyntheticM3uFixture, + SYNTHETIC_M3U_CHANNEL_COUNT, + SYNTHETIC_M3U_SEED, + type SyntheticM3uFixture, +} from './synthetic-m3u'; + +const SCENARIO = 'm3u-refresh-cancel'; +const SOURCE_TITLE = 'Synthetic M3U cancellation 100k'; +const RESOURCE_PATH = '/synthetic-performance.m3u'; +const DEFAULT_MEASURED_RUNS = 5; +const DEFAULT_WARMUP_RUNS = 1; +const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +const RENDERER_CDP_PORT = 9222; + +interface SyntheticServer { + readonly resourceUrl: string; + close(): Promise; + serveLargeFixture(): void; + serveSeedFixture(): void; +} + +interface BenchmarkConfiguration { + readonly electronVersion: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly outputDirectory: string; + readonly variant: string; + readonly warmupRuns: number; + readonly workspaceRoot: string; +} + +interface IterationDefinition { + readonly kind: PerformanceIterationKind; + readonly runId: string; +} + +export async function runM3uRefreshCancellationBenchmark(): Promise { + const config = await resolveConfiguration(); + const fixture = createSyntheticM3uFixture(); + const server = await startSyntheticServer(fixture); + const iterations: CancellationIterationResult[] = []; + + try { + for (const definition of createIterationDefinitions(config)) { + console.log( + `[performance] ${config.variant}/${definition.runId} starting` + ); + const result = await runIteration(config, definition, server); + iterations.push(result); + console.log( + `[performance] ${definition.runId} total=${ + result.phases.totalMs?.toFixed(3) ?? 'n/a' + }ms cancelEffect=${result.cancellationEffectObserved}` + ); + } + } finally { + await server.close(); + } + + const manifest: CancellationBenchmarkManifest = Object.freeze({ + channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + fixtureBytes: fixture.bytes, + fixtureSha256: fixture.sha256, + gitCommit: config.gitCommit, + gitDiffSha256: config.gitDiffSha256, + gitDirty: config.gitDirty, + measuredRuns: config.measuredRuns, + memoryAccounting: Object.freeze({ + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + workerRss: 'unavailable-per-thread', + }), + scenario: SCENARIO, + syntheticSeed: SYNTHETIC_M3U_SEED, + runtime: Object.freeze({ + arch: process.arch, + electronVersion: config.electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }), + variant: config.variant, + warmupRuns: config.warmupRuns, + }); + const summary = createCancellationBenchmarkSummary(manifest, iterations); + await writeJson(join(config.outputDirectory, 'manifest.json'), manifest); + await writeJson(join(config.outputDirectory, 'summary.json'), summary); + console.log( + `[performance] completed: ${join( + config.outputDirectory, + 'summary.json' + )}` + ); +} + +async function runIteration( + config: BenchmarkConfiguration, + definition: IterationDefinition, + server: SyntheticServer +): Promise { + const iterationDirectory = join(config.outputDirectory, definition.runId); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-performance-') + ); + await mkdir(iterationDirectory, { recursive: false }); + let app: LaunchedElectronApp | null = null; + let rendererConsoleListener: ((message: ConsoleMessage) => void) | null = + null; + + try { + await assertTcpPortAvailable(RENDERER_CDP_PORT); + server.serveSeedFixture(); + app = await launchElectronApp(dataDirectory, { + args: [ + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${RENDERER_CDP_PORT}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: + process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', + }, + }); + await assertRendererCdpTarget(app.mainWindow); + app.mainWindow.setDefaultTimeout(120_000); + await installMainCapture(app.electronApp); + await seedPlaylist(app.mainWindow, server.resourceUrl); + await waitForSeedSettlement(app); + server.serveLargeFixture(); + const rendererRefreshErrors: string[] = []; + rendererConsoleListener = (message) => { + if ( + message.type() === 'error' && + message.text().includes('Error refreshing playlist:') + ) { + rendererRefreshErrors.push(message.text()); + } + }; + app.mainWindow.on('console', rendererConsoleListener); + + const diagnostic = + definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + await startMainCapture(app.electronApp, { + diagnostic, + outputDirectory: iterationDirectory, + }); + const renderer = await startRendererCapture(app.mainWindow, { + diagnostic, + outputDirectory: iterationDirectory, + sourceTitle: SOURCE_TITLE, + }); + + await renderer.markOperationStart(); + const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); + await row.locator('.refresh-btn').click(); + await renderer.waitForCancelClick(); + await Promise.all([ + renderer.waitForTerminal(), + waitForOperationSettlement(app), + ]); + expect(rendererRefreshErrors).toEqual([]); + + const [rendererMetrics, mainMetrics] = await Promise.all([ + renderer.stop(), + stopMainCapture(app.electronApp), + ]); + const result = createCancellationIterationResult({ + kind: definition.kind, + main: mainMetrics, + renderer: rendererMetrics, + runId: definition.runId, + }); + await writeJson(join(iterationDirectory, 'result.json'), result); + return result; + } finally { + if (app) { + if (rendererConsoleListener) { + app.mainWindow.off('console', rendererConsoleListener); + } + await closeElectronApp(app).catch((error: unknown) => { + console.error('[performance] Electron cleanup failed', error); + }); + } + await rm(dataDirectory, { force: true, recursive: true }); + } +} + +async function seedPlaylist(page: Page, playlistUrl: string): Promise { + await page.getByRole('button', { name: 'Add playlist' }).first().click(); + const dialog = page.locator('mat-dialog-container').last(); + await expect( + dialog.getByRole('heading', { name: 'Add playlist' }) + ).toBeVisible(); + await dialog + .getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' }) + .fill(playlistUrl); + await dialog + .getByRole('textbox', { name: 'Playlist title' }) + .fill(SOURCE_TITLE); + await dialog.getByRole('button', { name: 'Add playlist' }).last().click(); + await expect(dialog).toBeHidden(); + await waitForM3uCatalog(page); + await openSources(page); + await expect(sourceRowByTitle(page, SOURCE_TITLE).first()).toBeVisible(); + await waitForTwoAnimationFrames(page); +} + +async function waitForSeedSettlement(app: LaunchedElectronApp): Promise { + await expect + .poll( + async () => + (await readMainCaptureStatus(app.electronApp)).databasePending, + { timeout: 120_000 } + ) + .toBe(0); + await waitForTwoAnimationFrames(app.mainWindow); +} + +async function waitForOperationSettlement( + app: LaunchedElectronApp +): Promise { + await expect + .poll( + async () => { + const status = await readMainCaptureStatus(app.electronApp); + if (status.playlistResponsesSucceeded > 0) { + return ( + status.databaseUpsertsCompleted > 0 && + status.databasePending === 0 + ); + } + return ( + status.playlistTerminated > 0 && + (status.playlistResponses === 0 || + status.playlistResponsesFailed > 0) + ); + }, + { timeout: 120_000 } + ) + .toBe(true); +} + +async function waitForTwoAnimationFrames(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolvePromise) => { + requestAnimationFrame(() => + requestAnimationFrame(() => resolvePromise()) + ); + }) + ); +} + +function createIterationDefinitions( + config: BenchmarkConfiguration +): readonly IterationDefinition[] { + const definitions: IterationDefinition[] = []; + for (let index = 0; index < config.warmupRuns; index += 1) { + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: `warmup-${String(index + 1).padStart(2, '0')}`, + }); + } + for (let index = 0; index < config.measuredRuns; index += 1) { + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + }); + } + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }); + return Object.freeze(definitions); +} + +async function resolveConfiguration(): Promise { + const workspaceRoot = resolve(__dirname, '../../../..'); + const performanceRoot = join(workspaceRoot, 'dist', 'performance'); + const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(requestedRoot) || + !isStrictDescendant(performanceRoot, outputRoot) + ) { + throw new Error( + 'Performance output must be an absolute path below dist/performance' + ); + } + const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + if (!OUTPUT_VARIANT_PATTERN.test(variant)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1'; + const sourceState = readGitSourceState(workspaceRoot); + if (!smoke && sourceState.dirty) { + throw new Error( + 'Formal performance runs require a clean Git worktree; commit or stash changes first' + ); + } + const electronPackage = JSON.parse( + await readFile( + join(workspaceRoot, 'node_modules', 'electron', 'package.json'), + 'utf8' + ) + ) as { version?: unknown }; + if (typeof electronPackage.version !== 'string') { + throw new Error('Unable to resolve the Electron runtime version'); + } + const outputDirectory = join(outputRoot, variant); + await assertMissing(outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + return Object.freeze({ + electronVersion: electronPackage.version, + gitCommit: sourceState.commit, + gitDiffSha256: sourceState.diffSha256, + gitDirty: sourceState.dirty, + measuredRuns: smoke ? 1 : DEFAULT_MEASURED_RUNS, + outputDirectory, + variant, + warmupRuns: DEFAULT_WARMUP_RUNS, + workspaceRoot, + }); +} + +function readGitSourceState(workspaceRoot: string): { + readonly commit: string; + readonly diffSha256: string; + readonly dirty: boolean; +} { + const git = (args: readonly string[]): string => + execFileSync('git', [...args], { + cwd: workspaceRoot, + encoding: 'utf8', + }); + const commit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const trackedDiff = git(['diff', '--binary', 'HEAD']); + const stagedDiff = git(['diff', '--binary', '--cached', 'HEAD']); + const diffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(trackedDiff) + .update('\0') + .update(stagedDiff) + .digest('hex'); + return Object.freeze({ + commit, + diffSha256, + dirty: status.trim().length > 0, + }); +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(resolve(parent), resolve(candidate)); + return ( + pathFromParent.length > 0 && + pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +async function assertTcpPortAvailable(port: number): Promise { + const server = createTcpServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen( + { exclusive: true, host: '127.0.0.1', port }, + resolvePromise + ); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +async function assertRendererCdpTarget(page: Page): Promise { + await expect + .poll( + async () => { + try { + const response = await fetch( + `http://127.0.0.1:${RENDERER_CDP_PORT}/json/list` + ); + if (!response.ok) { + return false; + } + const targets = (await response.json()) as unknown; + return ( + Array.isArray(targets) && + targets.some( + (target) => + typeof target === 'object' && + target !== null && + (target as Record)['type'] === + 'page' && + (target as Record)['url'] === + page.url() + ) + ); + } catch { + return false; + } + }, + { timeout: 10_000 } + ) + .toBe(true); +} + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch { + return; + } + throw new Error(`Performance output already exists: ${path}`); +} + +async function startSyntheticServer( + fixture: SyntheticM3uFixture +): Promise { + const seedBody = createSeedPlaylistBody(); + let body = seedBody; + const server = createServer((request, response) => { + if ( + request.method !== 'GET' || + new URL(request.url ?? '/', 'http://127.0.0.1').pathname !== + RESOURCE_PATH + ) { + response.writeHead(404, { 'Content-Length': '0' }); + response.end(); + return; + } + const bytes = Buffer.byteLength(body, 'utf8'); + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': String(bytes), + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + }); + response.end(body); + }); + server.listen({ exclusive: true, host: '127.0.0.1', port: 0 }); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Synthetic M3U server did not bind IPv4 loopback'); + } + return Object.freeze({ + close: () => closeServer(server), + resourceUrl: `http://127.0.0.1:${address.port}${RESOURCE_PATH}`, + serveLargeFixture: () => { + body = fixture.body; + }, + serveSeedFixture: () => { + body = seedBody; + }, + }); +} + +function createSeedPlaylistBody(): string { + const lines = ['#EXTM3U']; + for (let index = 1; index <= 100; index += 1) { + lines.push( + `#EXTINF:-1 group-title="Synthetic Seed",Synthetic Seed ${String( + index + ).padStart(3, '0')}` + ); + lines.push(`http://127.0.0.1/seed/${index}`); + } + return `${lines.join('\n')}\n`; +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts new file mode 100644 index 000000000..fd9435445 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -0,0 +1,871 @@ +/* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ +import type { ElectronApplication } from '@playwright/test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; + +const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; + +export interface MainCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; +} + +export interface MainCaptureStatus { + readonly databasePending: number; + readonly databaseRequests: number; + readonly databaseUpsertsCompleted: number; + readonly playlistTerminated: number; + readonly playlistResponsesFailed: number; + readonly playlistResponses: number; + readonly playlistResponsesSucceeded: number; +} + +export async function installMainCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + type JsonRecord = Record; + type WorkerTransferable = import('node:worker_threads').Transferable; + interface CpuProfileHandle { + stop(): Promise; + } + interface WorkerHeapStatistics { + external_memory?: number; + used_heap_size?: number; + } + interface WorkerCpuUsage { + system: number; + user: number; + } + interface WorkerElu { + active: number; + idle: number; + utilization: number; + } + interface InstrumentedWorker { + cpuUsage?(): Promise; + getHeapSnapshot?(): Promise; + getHeapStatistics?(): Promise; + on(event: 'message', listener: (message: unknown) => void): this; + performance?: { + eventLoopUtilization( + utilization1?: WorkerElu, + utilization2?: WorkerElu + ): WorkerElu; + }; + postMessage( + message: unknown, + transferList?: readonly WorkerTransferable[] + ): void; + startCpuProfile?(): Promise; + terminate(): Promise; + } + interface WorkerRecord { + cancelPostedEpochMs: number | null; + cpuFirst: WorkerCpuUsage | null; + cpuLast: WorkerCpuUsage | null; + elu: number | null; + eluStart: WorkerElu | null; + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + } | null; + externalPeak: number; + finalized: boolean; + finalizing: Promise | null; + heapPeak: number; + kind: 'database.worker' | 'playlist-refresh.worker'; + operationId: string | null; + playlistId: string | null; + postGcHeapUsed: number | null; + profileHandle: Promise | null; + profilePath: string | null; + responseEpochMs: number | null; + sampleBusy: boolean; + sampleTimer: NodeJS.Timeout | null; + snapshotPath: string | null; + terminatedEpochMs: number | null; + worker: InstrumentedWorker; + } + interface TimelineRecord { + readonly epochMs: number; + readonly operation?: string; + readonly operationId?: string; + readonly playlistId?: string; + readonly requestId?: string; + readonly success?: boolean; + readonly type: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const runtimeProcess = process as typeof process & { + getBuiltinModule(id: string): unknown; + }; + const fs = runtimeProcess.getBuiltinModule( + 'node:fs' + ) as typeof import('node:fs'); + const inspector = runtimeProcess.getBuiltinModule( + 'node:inspector' + ) as typeof import('node:inspector'); + const path = runtimeProcess.getBuiltinModule( + 'node:path' + ) as typeof import('node:path'); + const perfHooks = runtimeProcess.getBuiltinModule( + 'node:perf_hooks' + ) as typeof import('node:perf_hooks'); + const streamPromises = runtimeProcess.getBuiltinModule( + 'node:stream/promises' + ) as typeof import('node:stream/promises'); + const workerThreads = runtimeProcess.getBuiltinModule( + 'node:worker_threads' + ) as typeof import('node:worker_threads'); + const WorkerClass = workerThreads.Worker; + const originalPostMessage = WorkerClass.prototype.postMessage; + const originalTerminate = WorkerClass.prototype.terminate; + const records = new Map(); + const operationWorkers = new Map(); + const dbRequests = new Map< + string, + { + operation: string; + playlistId: string | null; + record: WorkerRecord; + } + >(); + + const state = { + active: false, + cpuStart: null as NodeJS.CpuUsage | null, + diagnostic: false, + eventLoopDelay: null as ReturnType< + typeof perfHooks.monitorEventLoopDelay + > | null, + eventLoopStart: null as ReturnType< + typeof perfHooks.performance.eventLoopUtilization + > | null, + inspectorSession: null as import('node:inspector').Session | null, + mainPeakHeap: 0, + mainPeakRss: 0, + mainProfilePath: null as string | null, + mainSnapshotPath: null as string | null, + outputDirectory: '', + postGcHeap: null as number | null, + postGcRss: null as number | null, + rendererPeakRss: 0, + responsiveEvents: 0, + sampleTimer: null as NodeJS.Timeout | null, + timeline: [] as TimelineRecord[], + unresponsiveEvents: 0, + windowListeners: [] as { + responsive: () => void; + unresponsive: () => void; + window: Electron.BrowserWindow; + }[], + }; + + const nowEpochMs = (): number => + perfHooks.performance.timeOrigin + perfHooks.performance.now(); + const recordTimeline = ( + record: Omit + ): void => { + if (state.active) { + state.timeline.push({ epochMs: nowEpochMs(), ...record }); + } + }; + const classifyRequest = ( + message: JsonRecord + ): 'database.worker' | 'playlist-refresh.worker' | null => { + if (typeof message['operation'] === 'string') { + return 'database.worker'; + } + const payload = message['payload']; + return typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)['operationId'] === 'string' && + typeof (payload as JsonRecord)['playlistId'] === 'string' + ? 'playlist-refresh.worker' + : null; + }; + const readDatabasePlaylistId = (message: JsonRecord): string | null => { + const payload = message['payload']; + if (typeof payload !== 'object' || payload === null) { + return null; + } + const value = payload as JsonRecord; + const playlistId = value['playlistId'] ?? value['_id']; + return typeof playlistId === 'string' ? playlistId : null; + }; + const createWorkerRecord = ( + worker: InstrumentedWorker, + kind: WorkerRecord['kind'] + ): WorkerRecord => { + const existing = records.get(worker); + if (existing) { + return existing; + } + const record: WorkerRecord = { + cancelPostedEpochMs: null, + cpuFirst: null, + cpuLast: null, + elu: null, + eluStart: null, + eventLoopDelay: null, + externalPeak: 0, + finalized: false, + finalizing: null, + heapPeak: 0, + kind, + operationId: null, + playlistId: null, + postGcHeapUsed: null, + profileHandle: null, + profilePath: null, + responseEpochMs: null, + sampleBusy: false, + sampleTimer: null, + snapshotPath: null, + terminatedEpochMs: null, + worker, + }; + records.set(worker, record); + worker.on('message', (incoming) => { + if (typeof incoming !== 'object' || incoming === null) { + return; + } + const message = incoming as JsonRecord; + const performanceCapture = readWorkerPerformance(message); + if (performanceCapture) { + record.eventLoopDelay = record.eventLoopDelay + ? { + maxMs: Math.max( + record.eventLoopDelay.maxMs, + performanceCapture.eventLoopDelay.maxMs + ), + p95Ms: Math.max( + record.eventLoopDelay.p95Ms, + performanceCapture.eventLoopDelay.p95Ms + ), + p99Ms: Math.max( + record.eventLoopDelay.p99Ms, + performanceCapture.eventLoopDelay.p99Ms + ), + } + : performanceCapture.eventLoopDelay; + } + if (record.kind === 'playlist-refresh.worker') { + if (message['type'] === 'event') { + const event = message['event'] as + | JsonRecord + | undefined; + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `playlist-event:${String( + event?.['status'] ?? 'unknown' + )}:${String(event?.['phase'] ?? 'none')}`, + }); + } else if (message['type'] === 'response') { + record.responseEpochMs = nowEpochMs(); + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + success: message['success'] === true, + type: 'playlist-response', + }); + } + return; + } + if ( + message['type'] === 'response' && + typeof message['requestId'] === 'string' + ) { + const request = dbRequests.get(message['requestId']); + if (request) { + dbRequests.delete(message['requestId']); + recordTimeline({ + operation: request.operation, + playlistId: request.playlistId ?? undefined, + requestId: message['requestId'], + success: message['success'] === true, + type: 'db-response', + }); + } + } + }); + return record; + }; + const readWorkerPerformance = ( + message: JsonRecord + ): { + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + }; + } | null => { + const performanceCapture = message['performance']; + if ( + typeof performanceCapture !== 'object' || + performanceCapture === null + ) { + return null; + } + const eventLoopDelay = (performanceCapture as JsonRecord)[ + 'eventLoopDelay' + ]; + if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { + return null; + } + const value = eventLoopDelay as JsonRecord; + const maxMs = value['maxMs']; + const p95Ms = value['p95Ms']; + const p99Ms = value['p99Ms']; + if ( + typeof maxMs !== 'number' || + !Number.isFinite(maxMs) || + typeof p95Ms !== 'number' || + !Number.isFinite(p95Ms) || + typeof p99Ms !== 'number' || + !Number.isFinite(p99Ms) + ) { + return null; + } + return { + eventLoopDelay: { maxMs, p95Ms, p99Ms }, + }; + }; + const sampleWorker = async (record: WorkerRecord): Promise => { + if (record.sampleBusy || record.finalized) { + return; + } + record.sampleBusy = true; + try { + const stats = await record.worker.getHeapStatistics?.(); + if (stats) { + record.heapPeak = Math.max( + record.heapPeak, + Number(stats.used_heap_size ?? 0) + ); + record.externalPeak = Math.max( + record.externalPeak, + Number(stats.external_memory ?? 0) + ); + } + const cpu = await record.worker.cpuUsage?.(); + if (cpu) { + record.cpuFirst ??= cpu; + record.cpuLast = cpu; + } + const elu = record.worker.performance?.eventLoopUtilization( + record.eluStart ?? undefined + ); + if (elu) { + record.elu = elu.utilization; + } + } catch { + // A one-shot worker may terminate between sampling calls. + } finally { + record.sampleBusy = false; + } + }; + const startWorker = (record: WorkerRecord): void => { + if (!state.active || record.sampleTimer !== null) { + return; + } + record.elu = null; + record.eluStart = + record.worker.performance?.eventLoopUtilization() ?? null; + record.eventLoopDelay = null; + void sampleWorker(record); + record.sampleTimer = setInterval( + () => void sampleWorker(record), + 20 + ); + if ( + state.diagnostic && + typeof record.worker.startCpuProfile === 'function' + ) { + record.profilePath = path.join( + state.outputDirectory, + `${record.kind}.cpuprofile` + ); + record.profileHandle = record.worker.startCpuProfile(); + } + }; + const finalizeWorker = (record: WorkerRecord): Promise => { + record.finalizing ??= (async () => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + await sampleWorker(record); + if (record.profileHandle && record.profilePath) { + const handle = await record.profileHandle; + const profile = await handle.stop(); + fs.writeFileSync( + record.profilePath, + JSON.stringify(normalizeWorkerCpuProfile(profile)) + ); + } + if ( + state.diagnostic && + typeof record.worker.getHeapSnapshot === 'function' + ) { + record.snapshotPath = path.join( + state.outputDirectory, + `${record.kind}.heapsnapshot` + ); + const snapshot = await record.worker.getHeapSnapshot(); + await streamPromises.pipeline( + snapshot, + fs.createWriteStream(record.snapshotPath) + ); + const postSnapshot = + await record.worker.getHeapStatistics?.(); + record.postGcHeapUsed = Number( + postSnapshot?.used_heap_size ?? 0 + ); + } + record.finalized = true; + })().catch((error: unknown) => { + recordTimeline({ + type: `worker-profile-error:${ + error instanceof Error + ? error.message.slice(0, 160) + : String(error).slice(0, 160) + }`, + }); + record.finalized = true; + }); + return record.finalizing; + }; + + WorkerClass.prototype.postMessage = function ( + this: InstrumentedWorker, + message: unknown, + transferList?: readonly WorkerTransferable[] + ): void { + if (typeof message === 'object' && message !== null) { + const value = message as JsonRecord; + if (value['type'] === 'request') { + const kind = classifyRequest(value); + if (kind) { + const record = createWorkerRecord(this, kind); + if (kind === 'playlist-refresh.worker') { + const payload = value['payload'] as JsonRecord; + record.operationId = String(payload['operationId']); + record.playlistId = String(payload['playlistId']); + operationWorkers.set(record.operationId, record); + recordTimeline({ + operationId: record.operationId, + playlistId: record.playlistId, + type: 'playlist-request', + }); + } else if ( + typeof value['requestId'] === 'string' && + typeof value['operation'] === 'string' + ) { + dbRequests.set(value['requestId'], { + operation: value['operation'], + playlistId: readDatabasePlaylistId(value), + record, + }); + recordTimeline({ + operation: value['operation'], + playlistId: + readDatabasePlaylistId(value) ?? undefined, + requestId: value['requestId'], + type: 'db-request', + }); + } + startWorker(record); + } + } else if ( + value['type'] === 'cancel' && + typeof value['operationId'] === 'string' + ) { + const record = operationWorkers.get(value['operationId']); + if (record) { + record.cancelPostedEpochMs = nowEpochMs(); + recordTimeline({ + operationId: value['operationId'], + type: 'playlist-cancel-posted', + }); + } + } + } + originalPostMessage.call(this, message, transferList); + }; + WorkerClass.prototype.terminate = function ( + this: InstrumentedWorker + ): Promise { + const record = records.get(this); + if (!record || !state.active) { + return originalTerminate.call(this); + } + const markTerminated = (code: number): number => { + record.terminatedEpochMs = nowEpochMs(); + record.finalized = true; + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `${record.kind}-terminated`, + }); + return code; + }; + if (!state.diagnostic) { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + return originalTerminate.call(this).then(markTerminated); + } + return finalizeWorker(record) + .then(() => originalTerminate.call(this)) + .then(markTerminated); + }; + + const inspectorPost = ( + session: import('node:inspector').Session, + method: string, + params: JsonRecord = {} + ): Promise => + new Promise((resolve, reject) => { + session.post(method, params, (error, result) => { + if (error) { + reject(error); + } else { + resolve((result ?? {}) as JsonRecord); + } + }); + }); + const normalizeWorkerCpuProfile = (profile: unknown): unknown => { + if (typeof profile !== 'string') { + return profile; + } + try { + return JSON.parse(profile) as unknown; + } catch { + // Node 24's Worker CPU profiler can leave quotes inside + // RegExp pseudo-frame names unescaped. Repair only those + // generated frame names, then require the full JSON to parse. + const fieldPrefix = '"functionName":"RegExp: '; + const fieldSuffix = '","lineNumber":'; + const valuePrefixLength = '"functionName":"'.length; + let cursor = 0; + let repaired = ''; + while (true) { + const fieldStart = profile.indexOf(fieldPrefix, cursor); + if (fieldStart < 0) { + repaired += profile.slice(cursor); + break; + } + const valueStart = fieldStart + valuePrefixLength; + const valueEnd = profile.indexOf(fieldSuffix, valueStart); + if (valueEnd < 0) { + throw new Error( + 'Worker CPU profile has an unterminated RegExp frame' + ); + } + repaired += + profile.slice(cursor, fieldStart) + + '"functionName":' + + JSON.stringify(profile.slice(valueStart, valueEnd)); + cursor = valueEnd + 1; + } + return JSON.parse(repaired) as unknown; + } + }; + const sampleMain = (): void => { + const memory = process.memoryUsage(); + state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); + state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); + for (const metric of app.getAppMetrics()) { + const type = String(metric.type).toLowerCase(); + if (type.includes('tab') || type.includes('renderer')) { + state.rendererPeakRss = Math.max( + state.rendererPeakRss, + Number(metric.memory?.workingSetSize ?? 0) * 1024 + ); + } + } + }; + const attachWindowListeners = (): void => { + for (const window of BrowserWindow.getAllWindows()) { + const unresponsive = () => { + state.unresponsiveEvents += 1; + }; + const responsive = () => { + state.responsiveEvents += 1; + }; + window.on('unresponsive', unresponsive); + window.on('responsive', responsive); + state.windowListeners.push({ + responsive, + unresponsive, + window, + }); + } + }; + + const api = { + status: (): MainCaptureStatus => ({ + databasePending: dbRequests.size, + databaseRequests: state.timeline.filter( + (entry) => entry['type'] === 'db-request' + ).length, + databaseUpsertsCompleted: state.timeline.filter( + (entry) => + entry['type'] === 'db-response' && + entry['operation'] === 'DB_UPSERT_APP_PLAYLIST' && + entry['success'] === true + ).length, + playlistTerminated: [...records.values()].filter( + (record) => + record.kind === 'playlist-refresh.worker' && + record.terminatedEpochMs !== null + ).length, + playlistResponsesFailed: state.timeline.filter( + (entry) => + entry['type'] === 'playlist-response' && + entry['success'] === false + ).length, + playlistResponses: state.timeline.filter( + (entry) => entry['type'] === 'playlist-response' + ).length, + playlistResponsesSucceeded: state.timeline.filter( + (entry) => + entry['type'] === 'playlist-response' && + entry['success'] === true + ).length, + }), + start: async (options: MainCaptureStartOptions): Promise => { + state.active = true; + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.rendererPeakRss = 0; + state.postGcHeap = null; + state.postGcRss = null; + state.unresponsiveEvents = 0; + state.responsiveEvents = 0; + state.cpuStart = process.cpuUsage(); + state.eventLoopStart = + perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + attachWindowListeners(); + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + if (state.diagnostic) { + const session = new inspector.Session(); + session.connect(); + state.inspectorSession = session; + await inspectorPost(session, 'Profiler.enable'); + await inspectorPost(session, 'Profiler.start'); + state.mainProfilePath = path.join( + state.outputDirectory, + 'main.cpuprofile' + ); + state.mainSnapshotPath = path.join( + state.outputDirectory, + 'main.heapsnapshot' + ); + } + }, + stop: async (): Promise => { + if (state.sampleTimer) { + clearInterval(state.sampleTimer); + state.sampleTimer = null; + } + state.eventLoopDelay?.disable(); + sampleMain(); + const cpu = process.cpuUsage(state.cpuStart ?? undefined); + const eluEnd = perfHooks.performance.eventLoopUtilization(); + const elu = perfHooks.performance.eventLoopUtilization( + state.eventLoopStart ?? undefined + ); + const eventLoopUtilization = + perfHooks.performance.nodeTiming.loopStart < 0 || + (elu.active === 0 && + elu.idle === 0 && + eluEnd.active === 0 && + eluEnd.idle === 0) + ? null + : elu.utilization; + const delay = state.eventLoopDelay; + const databaseRecords = [...records.values()].filter( + (record) => + record.kind === 'database.worker' && + record.sampleTimer !== null + ); + await Promise.all( + databaseRecords.map((record) => finalizeWorker(record)) + ); + const session = + state.inspectorSession ?? new inspector.Session(); + if (!state.inspectorSession) { + session.connect(); + } + if (state.diagnostic && state.mainProfilePath) { + const result = await inspectorPost( + session, + 'Profiler.stop' + ); + fs.writeFileSync( + state.mainProfilePath, + JSON.stringify(result['profile']) + ); + } + await inspectorPost(session, 'HeapProfiler.enable'); + await inspectorPost(session, 'HeapProfiler.collectGarbage'); + const postGc = process.memoryUsage(); + state.postGcHeap = postGc.heapUsed; + state.postGcRss = postGc.rss; + if (state.diagnostic && state.mainSnapshotPath) { + const output = fs.createWriteStream(state.mainSnapshotPath); + const onChunk = (message: { params: { chunk: string } }) => + output.write(message.params.chunk); + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + await inspectorPost( + session, + 'HeapProfiler.takeHeapSnapshot', + { reportProgress: false } + ); + session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + output.end(); + await new Promise((resolve, reject) => { + output.once('finish', resolve); + output.once('error', reject); + }); + } + session.disconnect(); + state.inspectorSession = null; + for (const listener of state.windowListeners) { + listener.window.off('unresponsive', listener.unresponsive); + listener.window.off('responsive', listener.responsive); + } + state.windowListeners = []; + state.active = false; + + const workers = [...records.values()] + .filter( + (record) => + record.kind === 'playlist-refresh.worker' || + record.heapPeak > 0 + ) + .map((record) => ({ + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopDelay: record.eventLoopDelay, + eventLoopDelayUnavailableReason: + record.eventLoopDelay === null + ? record.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable' + : null, + eventLoopUtilization: record.elu, + kind: record.kind, + operationId: record.operationId, + peakExternalBytes: record.externalPeak, + peakHeapUsedBytes: record.heapPeak, + playlistId: record.playlistId, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + })); + return { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererPeakRssBytes: state.rendererPeakRss, + responsiveEvents: state.responsiveEvents, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, + unresponsiveEvents: state.unresponsiveEvents, + workers, + }; + }, + }; + target[stateKey] = api; + }, MAIN_CAPTURE_STATE_KEY); +} + +export async function startMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + start(options: MainCaptureStartOptions): Promise; + }; + await api.start(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); +} + +export async function readMainCaptureStatus( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + status(): MainCaptureStatus; + }; + return api.status(); + }, MAIN_CAPTURE_STATE_KEY); +} + +export async function stopMainCapture( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, MAIN_CAPTURE_STATE_KEY); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts new file mode 100644 index 000000000..a759db490 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -0,0 +1,508 @@ +/* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */ +import { once } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CDPSession, Page } from '@playwright/test'; + +import type { + RendererCaptureMetrics, + RendererProbeMetrics, +} from './m3u-refresh-cancellation-contract'; +import { summarizeNumbers } from './performance-statistics'; + +const RENDERER_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshRendererCapture'; +const HEARTBEAT_INTERVAL_MS = 50; + +export interface RendererCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly sourceTitle: string; +} + +export interface RunningRendererCapture { + readonly session: CDPSession; + markOperationStart(): Promise; + stop(): Promise; + waitForCancelClick(): Promise; + waitForTerminal(): Promise; +} + +interface HeapUsageResult { + readonly usedSize: number; +} + +interface TraceData { + readonly value: readonly unknown[]; +} + +export async function startRendererCapture( + page: Page, + options: RendererCaptureStartOptions +): Promise { + const session = await page.context().newCDPSession(page); + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installRendererProbe(page, options.sourceTitle); + + const heapSamples: number[] = []; + let heapSampleBusy = false; + let heapSampleError: unknown = null; + const sampleHeap = async (): Promise => { + if (heapSampleBusy) { + return; + } + heapSampleBusy = true; + try { + const result = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (Number.isFinite(result.usedSize)) { + heapSamples.push(result.usedSize); + } + } catch (error) { + heapSampleError ??= error; + } finally { + heapSampleBusy = false; + } + }; + await sampleHeap(); + throwHeapSampleError(heapSampleError); + const heapSampleTimer = setInterval(() => void sampleHeap(), 20); + + const cpuProfilePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.cpuprofile') + : null; + const heapSnapshotPath = options.diagnostic + ? join(options.outputDirectory, 'renderer.heapsnapshot') + : null; + const tracePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.trace.json') + : null; + let traceOutput: ReturnType | null = null; + let traceFirstEvent = true; + let resolveTraceComplete: (() => void) | null = null; + const traceComplete = new Promise((resolve) => { + resolveTraceComplete = resolve; + }); + const onTraceData = (data: TraceData): void => { + if (!traceOutput) { + return; + } + for (const event of data.value) { + traceOutput.write( + `${traceFirstEvent ? '' : ','}${JSON.stringify(event)}` + ); + traceFirstEvent = false; + } + }; + const onTraceComplete = (): void => { + resolveTraceComplete?.(); + resolveTraceComplete = null; + }; + + if (options.diagnostic) { + await session.send('Profiler.enable'); + await session.send('Profiler.start'); + traceOutput = createWriteStream( + requireArtifactPath(tracePath, 'renderer trace') + ); + traceOutput.write('{"traceEvents":['); + session.on('Tracing.dataCollected', onTraceData); + session.on('Tracing.tracingComplete', onTraceComplete); + await session.send('Tracing.start', { + categories: [ + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-v8.cpu_profiler', + 'v8', + ].join(','), + options: 'sampling-frequency=1000', + transferMode: 'ReportEvents', + }); + } + + let stopped = false; + return Object.freeze({ + session, + markOperationStart: () => markRendererOperationStart(page), + stop: async () => { + if (stopped) { + throw new Error('Renderer capture was already stopped'); + } + stopped = true; + const probe = await stopRendererProbe(page); + clearInterval(heapSampleTimer); + await sampleHeap(); + throwHeapSampleError(heapSampleError); + + if (options.diagnostic) { + const cpuProfile = await session.send('Profiler.stop'); + await writeFile( + requireArtifactPath(cpuProfilePath, 'renderer CPU profile'), + JSON.stringify(cpuProfile['profile']), + 'utf8' + ); + await session.send('Tracing.end'); + await traceComplete; + session.off('Tracing.dataCollected', onTraceData); + session.off('Tracing.tracingComplete', onTraceComplete); + traceOutput?.write(']}'); + traceOutput?.end(); + if (traceOutput) { + await once(traceOutput, 'finish'); + } + } + + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + + if (options.diagnostic) { + const output = createWriteStream( + requireArtifactPath( + heapSnapshotPath, + 'renderer heap snapshot' + ) + ); + const onSnapshotChunk = (message: { + readonly chunk: string; + }): void => { + output.write(message.chunk); + }; + session.on( + 'HeapProfiler.addHeapSnapshotChunk', + onSnapshotChunk + ); + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + }); + session.off( + 'HeapProfiler.addHeapSnapshotChunk', + onSnapshotChunk + ); + output.end(); + await once(output, 'finish'); + } + + await session.detach(); + return Object.freeze({ + cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath, + heartbeatDelay: summarizeNumbers(probe.heartbeatDelaysMs), + longTask: summarizeNumbers(probe.longTasksMs), + peakHeapUsedBytes: Math.max(0, ...heapSamples), + postGcHeapUsedBytes: postGc.usedSize, + probe, + tracePath, + }); + }, + waitForCancelClick: () => + page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { cancelClickEpochMs: number | null } + | undefined; + return typeof state?.cancelClickEpochMs === 'number'; + }, + RENDERER_CAPTURE_STATE_KEY, + { timeout: 60_000 } + ) + .then(() => undefined), + waitForTerminal: () => + page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { uiPaintedEpochMs: number | null } + | undefined; + return typeof state?.uiPaintedEpochMs === 'number'; + }, + RENDERER_CAPTURE_STATE_KEY, + { timeout: 120_000 } + ) + .then(() => undefined), + }); +} + +function throwHeapSampleError(error: unknown): void { + if (error instanceof Error) { + throw error; + } + if (error !== null) { + throw new Error(`Renderer heap sampling failed: ${String(error)}`); + } +} + +function requireArtifactPath(value: string | null, label: string): string { + if (value === null) { + throw new Error(`${label} path is unavailable`); + } + return value; +} + +async function installRendererProbe( + page: Page, + sourceTitle: string +): Promise { + await page.evaluate( + ({ heartbeatIntervalMs, sourceTitle: title, stateKey }) => { + interface PlaylistRefreshEvent { + readonly operationId: string; + readonly phase?: string; + readonly status: string; + } + interface ElectronApi { + onPlaylistRefreshEvent( + callback: (event: PlaylistRefreshEvent) => void + ): (() => void) | undefined; + } + interface ProbeState { + cancelButtonFound: boolean; + cancelClickEpochMs: number | null; + events: { + operationId: string; + phase: string | null; + receivedEpochMs: number; + status: string; + }[]; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + heartbeatTimer: number; + lastFrameAt: number | null; + lastHeartbeatAt: number; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + mutationObserver: MutationObserver; + operationId: string | null; + operationStartEpochMs: number; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + uiPhaseEpochMs: Record; + uiSettledEpochMs: number | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + const epoch = (): number => + performance.timeOrigin + performance.now(); + const sourceRow = (): Element | undefined => + Array.from(document.querySelectorAll('app-playlist-item')).find( + (element) => element.textContent?.includes(title) + ); + const state = { + cancelButtonFound: false, + cancelClickEpochMs: null, + events: [], + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + heartbeatTimer: 0, + lastFrameAt: null, + lastHeartbeatAt: performance.now(), + longTaskObserver: null, + longTasksMs: [], + mutationObserver: null, + operationId: null, + operationStartEpochMs: 0, + terminalEpochMs: null, + uiPaintFrameRequestId: null, + uiPaintedEpochMs: null, + uiPhaseEpochMs: Object.create(null) as Record, + uiSettledEpochMs: null, + unsubscribe: () => undefined, + } as unknown as ProbeState; + const inspectUi = (): void => { + const row = sourceRow(); + const message = + row + ?.querySelector('.busy-state__message') + ?.textContent?.trim() + .toLowerCase() ?? ''; + for (const phase of ['fetch', 'pars', 'sav']) { + if ( + message.includes(phase) && + state.uiPhaseEpochMs[phase] === undefined + ) { + state.uiPhaseEpochMs[phase] = epoch(); + } + } + if ( + state.cancelClickEpochMs !== null && + state.uiSettledEpochMs === null && + row !== undefined && + row.querySelector('.busy-state__message') === null && + row.querySelector('.action-spinner') === null && + row.querySelector('.cancel-btn') === null + ) { + state.uiSettledEpochMs = epoch(); + state.uiPaintFrameRequestId = requestAnimationFrame(() => { + state.uiPaintFrameRequestId = requestAnimationFrame( + () => { + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + state.uiPaintFrameRequestId = null; + } + ); + }); + } + }; + const frame = (timestamp: number): void => { + if (state.lastFrameAt !== null) { + state.frameGapsMs.push(timestamp - state.lastFrameAt); + } + state.lastFrameAt = timestamp; + state.frameRequestId = requestAnimationFrame(frame); + }; + state.frameRequestId = requestAnimationFrame(frame); + state.heartbeatTimer = window.setInterval(() => { + const now = performance.now(); + state.heartbeatDelaysMs.push( + Math.max( + 0, + now - state.lastHeartbeatAt - heartbeatIntervalMs + ) + ); + state.lastHeartbeatAt = now; + }, heartbeatIntervalMs); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.longTasksMs.push(entry.duration); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + state.mutationObserver = new MutationObserver(inspectUi); + state.mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + const electron = ( + globalThis as unknown as { electron?: ElectronApi } + ).electron; + if (!electron?.onPlaylistRefreshEvent) { + throw new Error('Playlist refresh event bridge is unavailable'); + } + const unsubscribe = electron.onPlaylistRefreshEvent((event) => { + const receivedEpochMs = epoch(); + if (state.operationId === null && event.status === 'started') { + state.operationId = event.operationId; + } + if (state.operationId !== event.operationId) { + return; + } + state.events.push({ + operationId: event.operationId, + phase: event.phase ?? null, + receivedEpochMs, + status: event.status, + }); + if ( + event.status === 'progress' && + event.phase === 'parsing' && + state.cancelClickEpochMs === null + ) { + const button = sourceRow()?.querySelector('.cancel-btn'); + state.cancelButtonFound = + button instanceof HTMLButtonElement; + if (button instanceof HTMLButtonElement) { + state.cancelClickEpochMs = epoch(); + button.click(); + } + } + }); + state.unsubscribe = + typeof unsubscribe === 'function' + ? unsubscribe + : () => undefined; + target[stateKey] = state; + inspectUi(); + }, + { + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + sourceTitle, + stateKey: RENDERER_CAPTURE_STATE_KEY, + } + ); +} + +async function markRendererOperationStart(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as { operationStartEpochMs: number }; + state.operationStartEpochMs = + performance.timeOrigin + performance.now(); + return state.operationStartEpochMs; + }, RENDERER_CAPTURE_STATE_KEY); +} + +async function stopRendererProbe(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as { + cancelButtonFound: boolean; + cancelClickEpochMs: number | null; + events: RendererProbeMetrics['events']; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + heartbeatTimer: number; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + mutationObserver: MutationObserver; + operationStartEpochMs: number; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + uiPhaseEpochMs: Record; + uiSettledEpochMs: number | null; + unsubscribe: () => void; + }; + cancelAnimationFrame(state.frameRequestId); + if (state.uiPaintFrameRequestId !== null) { + cancelAnimationFrame(state.uiPaintFrameRequestId); + } + clearInterval(state.heartbeatTimer); + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.longTasksMs.push(entry.duration); + } + state.longTaskObserver?.disconnect(); + state.mutationObserver.disconnect(); + state.unsubscribe(); + const result: RendererProbeMetrics = { + cancelButtonFound: state.cancelButtonFound, + cancelClickEpochMs: state.cancelClickEpochMs, + events: [...state.events], + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + uiPhaseEpochMs: { ...state.uiPhaseEpochMs }, + uiSettledEpochMs: state.uiSettledEpochMs, + }; + delete target[stateKey]; + return result; + }, RENDERER_CAPTURE_STATE_KEY); +} diff --git a/apps/electron-backend-e2e/src/performance/performance-statistics.ts b/apps/electron-backend-e2e/src/performance/performance-statistics.ts new file mode 100644 index 000000000..ab7d53140 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-statistics.ts @@ -0,0 +1,64 @@ +import type { NumericDistribution } from './m3u-refresh-cancellation-contract'; + +export function summarizeNumbers( + values: readonly (number | null | undefined)[] +): NumericDistribution { + const samples = values + .filter( + (value): value is number => value !== null && value !== undefined + ) + .filter((value) => Number.isFinite(value) && value >= 0) + .sort((left, right) => left - right); + + if (samples.length === 0) { + return Object.freeze({ + count: 0, + max: null, + mean: null, + median: null, + min: null, + p95: null, + p99: null, + }); + } + + const total = samples.reduce((sum, value) => sum + value, 0); + return Object.freeze({ + count: samples.length, + max: samples.at(-1) ?? null, + mean: total / samples.length, + median: percentile(samples, 50), + min: samples[0] ?? null, + p95: percentile(samples, 95), + p99: percentile(samples, 99), + }); +} + +export function nonNegativeDifference( + endEpochMs: number | null | undefined, + startEpochMs: number | null | undefined +): number | null { + if ( + endEpochMs === null || + endEpochMs === undefined || + startEpochMs === null || + startEpochMs === undefined + ) { + return null; + } + const difference = endEpochMs - startEpochMs; + return Number.isFinite(difference) && difference >= 0 ? difference : null; +} + +function percentile(sortedValues: readonly number[], rank: number): number { + if (sortedValues.length === 1) { + return sortedValues[0] ?? 0; + } + + const position = ((sortedValues.length - 1) * rank) / 100; + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const lower = sortedValues[lowerIndex] ?? 0; + const upper = sortedValues[upperIndex] ?? lower; + return lower + (upper - lower) * (position - lowerIndex); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts new file mode 100644 index 000000000..14e65387d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; + +export const SYNTHETIC_M3U_SEED = 240_724; +export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; + +export interface SyntheticM3uFixture { + readonly body: string; + readonly bytes: number; + readonly channelCount: number; + readonly sha256: string; +} + +export function createSyntheticM3uFixture(): SyntheticM3uFixture { + const lines = new Array(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2); + lines[0] = '#EXTM3U'; + + for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) { + const index = offset + 1; + const group = (offset % 100) + 1; + const lineOffset = 1 + offset * 2; + const stableIndex = String(index).padStart(6, '0'); + + lines[lineOffset] = `#EXTINF:-1 group-title="Synthetic Group ${String( + group + ).padStart( + 3, + '0' + )}",Synthetic Channel ${SYNTHETIC_M3U_SEED}-${stableIndex}`; + lines[lineOffset + 1] = + `http://127.0.0.1/stream/${SYNTHETIC_M3U_SEED}/${index}`; + } + + const body = `${lines.join('\n')}\n`; + return Object.freeze({ + body, + bytes: Buffer.byteLength(body, 'utf8'), + channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + sha256: createHash('sha256').update(body, 'utf8').digest('hex'), + }); +} diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 03ccc34b3..c803ab8ed 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -604,6 +604,57 @@ test.describe('Electron Settings', () => { await closeElectronApp(app); } }); + + test('@settings @electron @persistence sizes and clears the TMDB metadata cache', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + // Seeded through the same preload bridge the enrichment service + // uses, so this exercises real IPC -> DB worker -> SQLite. + // Enrichment itself cannot run here: it needs a TMDB API key, + // which builds outside the release pipeline do not carry. + await app.mainWindow.evaluate(async () => { + await window.electron.dbSetTmdbMetadata({ + mediaType: 'movie', + lookupKey: 'id:603|v2', + language: 'en-US', + tmdbId: 603, + payload: JSON.stringify({ id: 603, title: 'The Matrix' }), + fetchedAt: new Date().toISOString(), + }); + }); + + await openSettings(app.mainWindow); + await app.mainWindow.getByTestId('settings-section-tmdb').click(); + + // Sizing is deferred until this section is the active one + await expect( + app.mainWindow.getByTestId('tmdb-cache-size') + ).toHaveText(/\b1 entries/); + + const clearButton = + app.mainWindow.getByTestId('tmdb-clear-cache'); + await expect(clearButton).toBeEnabled(); + await clearButton.click(); + + // Re-read reports an empty cache, so there is nothing to clear + await expect(clearButton).toBeDisabled(); + await expect( + app.mainWindow.getByTestId('tmdb-cache-size') + ).toHaveText(/\b0 entries/); + + // ...and the row is gone from the database, not just the panel + const remaining = await app.mainWindow.evaluate(() => + window.electron.dbGetTmdbMetadata('movie', 'id:603|v2', 'en-US') + ); + expect(remaining).toBeNull(); + } finally { + await closeElectronApp(app); + } + }); + }); async function selectSettingsOption( diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts index 5d4f36c99..d65b463bd 100644 --- a/apps/electron-backend-e2e/src/window-controls.e2e.ts +++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts @@ -103,6 +103,67 @@ test.describe('Custom window controls', () => { } }); + test('@electron controls reappear after leaving HTML element fullscreen', async ({ + dataDir, + }) => { + test.skip( + isWindowManagerlessCi, + 'xvfb on Linux CI has no window manager' + ); + const app = await launchElectronApp(dataDir); + + try { + const controls = app.mainWindow.locator('app-window-controls'); + await expect(controls).not.toHaveClass(/is-hidden/); + + // The video players fullscreen their player root through the + // HTML element-fullscreen API. That API needs transient user + // activation, so trigger it from a real click on a temporary + // overlay instead of calling it directly from evaluate(). + await app.mainWindow.evaluate(() => { + const overlay = document.createElement('div'); + overlay.id = 'e2e-fullscreen-trigger'; + overlay.style.cssText = + 'position: fixed; inset: 0; z-index: 2147483647;'; + overlay.addEventListener('click', () => { + overlay.remove(); + void document.documentElement.requestFullscreen(); + }); + document.body.append(overlay); + }); + await app.mainWindow.locator('#e2e-fullscreen-trigger').click(); + + const isHtmlFullScreen = () => + app.mainWindow.evaluate( + () => document.fullscreenElement !== null + ); + + await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe( + true + ); + await expect(controls).toHaveClass(/is-hidden/, { + timeout: 10_000, + }); + + await app.mainWindow.evaluate(() => document.exitFullscreen()); + await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe( + false + ); + + // Regression: the exit push must not carry a stale + // isFullScreen=true read mid-transition — the controls have to + // come back once fullscreen is left. + await expect(controls).not.toHaveClass(/is-hidden/, { + timeout: 10_000, + }); + await expect( + app.mainWindow.getByTestId('window-minimize') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + test('@electron minimize button minimizes the window', async ({ dataDir, }) => { diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 3b8b5cfab..effaa9c1d 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -370,6 +370,18 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_SET_TMDB_METADATA', forwardedArgs: [tmdbCacheEntry], }, + { + method: 'dbGetTmdbCacheStats', + args: [], + channel: 'DB_GET_TMDB_CACHE_STATS', + forwardedArgs: [], + }, + { + method: 'dbClearTmdbMetadata', + args: [], + channel: 'DB_CLEAR_TMDB_METADATA', + forwardedArgs: [], + }, { method: 'dbMatchTitles', args: [[['The Matrix', 'Inception']][0]], diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index aaf54a94e..54d2ddc9f 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -220,6 +220,29 @@ describe('main preload DB IPC contract', () => { ); }); + it('preserves a structured playlist cancellation result across the context bridge', async () => { + const api = getExposedApi(); + const payload = { + operationId: 'playlist-refresh-cancelled', + playlistId: 'playlist-1', + title: 'Large playlist', + url: 'http://127.0.0.1/large.m3u', + }; + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + mockIpcRenderer.invoke.mockResolvedValueOnce(cancelledResult); + + await expect(api.refreshPlaylist(payload)).resolves.toEqual( + cancelledResult + ); + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'PLAYLIST:REFRESH', + payload + ); + }); + it('keeps the legacy save-content progress bridge scoped to progress events', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 771a48359..bd078b223 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -788,6 +788,8 @@ const electronApi: ElectronBridgeApi = { ), dbSetTmdbMetadata: (entry: TmdbCacheEntry) => ipcRenderer.invoke('DB_SET_TMDB_METADATA', entry), + dbGetTmdbCacheStats: () => ipcRenderer.invoke('DB_GET_TMDB_CACHE_STATS'), + dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'), dbMatchTitles: (titles: string[]) => ipcRenderer.invoke('DB_MATCH_TITLES', titles), // Playback Positions diff --git a/apps/electron-backend/src/app/app-window-state.spec.ts b/apps/electron-backend/src/app/app-window-state.spec.ts new file mode 100644 index 000000000..f550142df --- /dev/null +++ b/apps/electron-backend/src/app/app-window-state.spec.ts @@ -0,0 +1,242 @@ +/** + * Regression coverage for the WINDOW:STATE_CHANGED pushes that drive the + * renderer-drawn window controls on Windows/Linux. + * + * The handlers must never re-read window state at event time: on Windows, + * isFullScreen() can still report true while 'leave-full-screen' fires for + * HTML-element fullscreen (video player) exits, and isMaximized() reports + * false while the window is fullscreen. Polling pushed a stale + * `isFullScreen: true` on exit — leaving the custom window controls hidden + * forever — and cleared the companion `isMaximized`, sticking the + * maximize/restore glyph on the wrong icon. Instead the state is seeded once + * at attach time and each event patches only the flag it names. + */ + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp'), + isPackaged: false, + isReady: jest.fn(() => false), + on: jest.fn(), + }, + BrowserWindow: jest.fn(), + Menu: { + buildFromTemplate: jest.fn(), + }, + screen: { + getPrimaryDisplay: jest.fn(), + }, + session: { + defaultSession: { + clearStorageData: jest.fn(), + }, + }, + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('./services/store.service', () => ({ + store: { + get: jest.fn(), + set: jest.fn(), + }, + WINDOW_BOUNDS: 'windowBounds', +})); + +import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces'; +import App from './app'; + +type MockStateWindow = { + isDestroyed: jest.Mock; + isFullScreen: jest.Mock; + isMaximized: jest.Mock; + on: jest.Mock void]>; + webContents: { + send: jest.Mock; + }; +}; + +function createMockStateWindow(): MockStateWindow { + return { + isDestroyed: jest.fn().mockReturnValue(false), + isFullScreen: jest.fn().mockReturnValue(false), + isMaximized: jest.fn().mockReturnValue(false), + on: jest.fn void]>(), + webContents: { + send: jest.fn(), + }, + }; +} + +function attachWindowStateEvents(win: MockStateWindow): void { + ( + App as unknown as { + attachWindowStateEvents: (win: MockStateWindow) => void; + } + ).attachWindowStateEvents(win); +} + +function fireWindowEvent(win: MockStateWindow, eventName: string): void { + const handlers = win.on.mock.calls + .filter(([name]) => name === eventName) + .map(([, handler]) => handler); + + expect(handlers).toHaveLength(1); + handlers[0](); +} + +describe('window state change pushes', () => { + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); + + function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + } + + beforeEach(() => { + setProcessPlatform('win32'); + }); + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + }); + + it('registers no window-state listeners on macOS', () => { + setProcessPlatform('darwin'); + const win = createMockStateWindow(); + + attachWindowStateEvents(win); + + expect(win.on).not.toHaveBeenCalled(); + }); + + it('pushes isFullScreen=false on leave-full-screen even while the window still reports fullscreen', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + // Windows still reports the old state while the transition runs. + win.isFullScreen.mockReturnValue(true); + + fireWindowEvent(win, 'leave-full-screen'); + + expect(win.webContents.send).toHaveBeenCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: false } + ); + }); + + it('pushes isFullScreen=false on leave-html-full-screen even while the window still reports fullscreen', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + win.isFullScreen.mockReturnValue(true); + + fireWindowEvent(win, 'leave-html-full-screen'); + + expect(win.webContents.send).toHaveBeenCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: false } + ); + }); + + it('pushes isFullScreen=true on enter events before the window reports fullscreen', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + win.isFullScreen.mockReturnValue(false); + + fireWindowEvent(win, 'enter-full-screen'); + fireWindowEvent(win, 'enter-html-full-screen'); + + expect(win.webContents.send).toHaveBeenCalledTimes(2); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: true } + ); + }); + + it('derives isMaximized from the maximize/unmaximize events instead of polling', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + + win.isMaximized.mockReturnValue(false); + fireWindowEvent(win, 'maximize'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: false } + ); + + win.isMaximized.mockReturnValue(true); + fireWindowEvent(win, 'unmaximize'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: false } + ); + }); + + it('keeps the maximized flag across a fullscreen round-trip while the window misreports it', () => { + const win = createMockStateWindow(); + win.isMaximized.mockReturnValue(true); + attachWindowStateEvents(win); + // Windows reports a fullscreen window as not maximized, so polling + // the companion flag here would clear it and leave the glyph on + // "maximize" after the controls come back. + win.isMaximized.mockReturnValue(false); + + fireWindowEvent(win, 'enter-html-full-screen'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: true } + ); + + fireWindowEvent(win, 'leave-html-full-screen'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: false } + ); + }); + + it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => { + const win = createMockStateWindow(); + win.isFullScreen.mockReturnValue(true); + attachWindowStateEvents(win); + win.isFullScreen.mockReturnValue(false); + + fireWindowEvent(win, 'maximize'); + + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: true } + ); + }); + + it('sends an independent payload per push', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + + fireWindowEvent(win, 'enter-full-screen'); + const firstPayload = win.webContents.send.mock.calls[0][1]; + fireWindowEvent(win, 'leave-full-screen'); + + // The renderer must not see the earlier push mutate under it. + expect(firstPayload).toEqual({ + isMaximized: false, + isFullScreen: true, + }); + }); + + it('skips pushes for destroyed windows', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + win.isDestroyed.mockReturnValue(true); + + fireWindowEvent(win, 'leave-full-screen'); + + expect(win.webContents.send).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 639d86d48..bfd5a24a2 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -55,6 +55,10 @@ import { store } from './services/store.service'; type MockMainWindow = { center: jest.Mock; getNormalBounds: jest.Mock; + // Read once by attachWindowStateEvents to seed the tracked window + // state; only reached off macOS, where the custom controls exist. + isFullScreen: jest.Mock; + isMaximized: jest.Mock; loadFile: jest.Mock, [string]>; loadURL: jest.Mock, [string]>; on: jest.Mock void]>; @@ -72,6 +76,8 @@ function createMockMainWindow(): MockMainWindow { return { center: jest.fn(), getNormalBounds: jest.fn().mockReturnValue({}), + isFullScreen: jest.fn().mockReturnValue(false), + isMaximized: jest.fn().mockReturnValue(false), loadFile: jest.fn, [string]>().mockResolvedValue(), loadURL: jest.fn, [string]>().mockResolvedValue(), on: jest.fn void]>(), diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 00d7d940b..7aef4a9ae 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,5 +1,8 @@ import { app, BrowserWindow, Menu, screen, session, shell } from 'electron'; -import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces'; +import { + ElectronBridgeWindowState, + WINDOW_STATE_CHANGED, +} from '@iptvnator/shared/interfaces'; import { join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { rendererAppName, rendererAppPort } from './constants'; @@ -318,21 +321,44 @@ export default class App { return; } - const sendWindowState = () => { + // Window state is never re-read at event time: on Windows both + // isFullScreen() and isMaximized() can still report the + // pre-transition value while the matching event fires (notably for + // HTML-element fullscreen, i.e. the video player). Since the + // renderer replaces both flags on every push and no later event + // corrects a stale one, polling left the controls hidden forever + // after leaving fullscreen — and, for the companion flag, the + // maximize/restore glyph stuck on the wrong icon. + // + // Instead the state is seeded once here (window creation, so no + // transition is in flight) and each event patches only the flag it + // names. + const state: ElectronBridgeWindowState = { + isMaximized: win.isMaximized(), + isFullScreen: win.isFullScreen(), + }; + + const push = (patch: Partial) => { + Object.assign(state, patch); + if (win.isDestroyed()) { return; } - win.webContents.send(WINDOW_STATE_CHANGED, { - isMaximized: win.isMaximized(), - isFullScreen: win.isFullScreen(), - }); + // A copy per push: the renderer must not observe later + // mutations of the tracked state. + win.webContents.send(WINDOW_STATE_CHANGED, { ...state }); }; - win.on('maximize', sendWindowState); - win.on('unmaximize', sendWindowState); - win.on('enter-full-screen', sendWindowState); - win.on('leave-full-screen', sendWindowState); + win.on('maximize', () => push({ isMaximized: true })); + win.on('unmaximize', () => push({ isMaximized: false })); + // The html variants cover HTML-element fullscreen; not every + // platform/trigger emits both pairs, and duplicate pushes with the + // same payload are harmless. + win.on('enter-full-screen', () => push({ isFullScreen: true })); + win.on('enter-html-full-screen', () => push({ isFullScreen: true })); + win.on('leave-full-screen', () => push({ isFullScreen: false })); + win.on('leave-html-full-screen', () => push({ isFullScreen: false })); } private static initMainWindow() { diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts index 8b1e6a3d2..94ded9e3e 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts @@ -1,6 +1,11 @@ import type { TmdbCacheEntry } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; -import { getTmdbMetadata, setTmdbMetadata } from './tmdb.operations'; +import { + clearTmdbMetadata, + getTmdbCacheStats, + getTmdbMetadata, + setTmdbMetadata, +} from './tmdb.operations'; function createSelectMock(rows: unknown[]) { const limit = jest.fn().mockResolvedValue(rows); @@ -105,3 +110,49 @@ describe('tmdb.operations', () => { ); }); }); + +describe('tmdb cache maintenance', () => { + function createStatsDb(rows: unknown[]) { + const all = jest.fn().mockResolvedValue(rows); + const run = jest.fn().mockResolvedValue(undefined); + return { db: { all, run } as unknown as AppDatabase, all, run }; + } + + it('reports entry count and payload bytes', async () => { + const { db } = createStatsDb([{ entries: 42, bytes: 123456 }]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 42, + bytes: 123456, + }); + }); + + it('reports zeroes for an empty table', async () => { + // COALESCE keeps SUM(NULL) from surfacing as NaN + const { db } = createStatsDb([{ entries: 0, bytes: 0 }]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('survives a driver returning no stats row at all', async () => { + const { db } = createStatsDb([]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('deletes every row and reports how many there were', async () => { + const { db, run } = createStatsDb([{ entries: 7, bytes: 900 }]); + + await expect(clearTmdbMetadata(db)).resolves.toEqual({ + success: true, + deleted: 7, + }); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts index 75fc67865..ae0a37c47 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts @@ -1,6 +1,10 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; -import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import type { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; export async function getTmdbMetadata( @@ -67,3 +71,37 @@ export async function setTmdbMetadata( return { success: true }; } + +/** + * Row count and payload size for the settings panel. `LENGTH()` on TEXT + * counts CHARACTERS, so the cast to BLOB is what makes this bytes. + * Negative-match rows have a NULL payload; SUM skips them. + */ +export async function getTmdbCacheStats( + db: AppDatabase +): Promise { + const rows = (await db.all(sql` + SELECT + COUNT(*) AS entries, + COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS bytes + FROM tmdb_metadata + `)) as { entries: number; bytes: number }[]; + + return { + entries: Number(rows[0]?.entries ?? 0), + bytes: Number(rows[0]?.bytes ?? 0), + }; +} + +/** + * Drops the whole cache. Enrichment simply refetches on demand, so this + * is always safe. Also the escape hatch when a lookup-key version bump + * orphans rows: a bump makes them unreachable, not deleted. + */ +export async function clearTmdbMetadata( + db: AppDatabase +): Promise<{ success: boolean; deleted: number }> { + const { entries } = await getTmdbCacheStats(db); + await db.run(sql`DELETE FROM tmdb_metadata`); + return { success: true, deleted: entries }; +} diff --git a/apps/electron-backend/src/app/events/database/tmdb.events.ts b/apps/electron-backend/src/app/events/database/tmdb.events.ts index 19b6b84da..796ab78bc 100644 --- a/apps/electron-backend/src/app/events/database/tmdb.events.ts +++ b/apps/electron-backend/src/app/events/database/tmdb.events.ts @@ -19,4 +19,8 @@ handleWorkerRequest('DB_SET_TMDB_METADATA', (entry: TmdbCacheEntry) => ({ entry, })); +handleWorkerRequest('DB_GET_TMDB_CACHE_STATS', () => ({})); + +handleWorkerRequest('DB_CLEAR_TMDB_METADATA', () => ({})); + handleWorkerRequest('DB_MATCH_TITLES', (titles: string[]) => ({ titles })); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 6fb9e60f8..f03e24b3f 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -323,6 +323,16 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [tmdbCacheEntry], payload: { entry: tmdbCacheEntry }, }, + { + operation: 'DB_GET_TMDB_CACHE_STATS', + args: [], + payload: {}, + }, + { + operation: 'DB_CLEAR_TMDB_METADATA', + args: [], + payload: {}, + }, { operation: 'DB_MATCH_TITLES', args: [['The Matrix', 'Inception']], diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index d25d11f9b..f1300f5a6 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -527,7 +527,94 @@ describe('playlist IPC events', () => { expect(worker.terminate).toHaveBeenCalled(); }); - it('routes refresh cancellation to the active worker and converts worker error responses to Error instances', async () => { + it('settles cancellation without waiting for a CPU-bound refresh worker', async () => { + const ipcEvent = createIpcEvent(); + const payload: PlaylistRefreshPayload = { + operationId: 'refresh-busy', + playlistId: 'playlist-busy', + title: 'Busy playlist', + filePath: '/playlists/busy.m3u', + }; + const refreshPromise = getHandler(PLAYLIST_REFRESH)(ipcEvent, payload); + const worker = mockWorkerInstances[0]; + let outcome: + | { error: unknown; status: 'rejected' } + | { status: 'resolved'; value: unknown } + | undefined; + void refreshPromise.then( + (value) => { + outcome = { status: 'resolved', value }; + }, + (error: unknown) => { + outcome = { error, status: 'rejected' }; + } + ); + + worker.emit('message', { type: 'ready' }); + worker.emit('message', { + event: { + operationId: payload.operationId, + phase: 'parsing', + playlistId: payload.playlistId, + status: 'progress', + } satisfies PlaylistRefreshEvent, + type: 'event', + }); + + let finishTermination: ((exitCode: number) => void) | undefined; + worker.terminate.mockImplementationOnce( + () => + new Promise((resolveTermination) => { + finishTermination = resolveTermination; + }) + ); + const cancelPromise = getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + payload.operationId + ); + await Promise.resolve(); + + expect(worker.removeAllListeners).toHaveBeenCalledTimes(1); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(worker.postMessage).toHaveBeenLastCalledWith({ + operationId: payload.operationId, + type: 'cancel', + }); + expect(outcome).toBeUndefined(); + expect(ipcEvent.sender.send).not.toHaveBeenCalledWith( + PLAYLIST_REFRESH_EVENT, + expect.objectContaining({ status: 'cancelled' }) + ); + + finishTermination?.(1); + await expect(cancelPromise).resolves.toEqual({ success: true }); + await Promise.resolve(); + + expect(outcome).toEqual({ + status: 'resolved', + value: { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + }, + }); + expect(ipcEvent.sender.send).toHaveBeenLastCalledWith( + PLAYLIST_REFRESH_EVENT, + { + operationId: payload.operationId, + phase: 'parsing', + playlistId: payload.playlistId, + status: 'cancelled', + } + ); + await expect( + getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + payload.operationId + ) + ).resolves.toEqual({ success: false }); + }); + + it('converts playlist refresh worker error responses to Error instances', async () => { const payload: PlaylistRefreshPayload = { operationId: 'refresh-error', playlistId: 'playlist-error', @@ -540,17 +627,6 @@ describe('playlist IPC events', () => { ); const worker = mockWorkerInstances[0]; - expect( - await getHandler(PLAYLIST_CANCEL_REFRESH)( - createIpcEvent(), - 'refresh-error' - ) - ).toEqual({ success: true }); - expect(worker.postMessage).toHaveBeenCalledWith({ - operationId: 'refresh-error', - type: 'cancel', - }); - const rejectedRefresh = expect(refreshPromise).rejects.toMatchObject({ message: 'Refresh failed', name: 'PlaylistRefreshFailure', @@ -568,13 +644,6 @@ describe('playlist IPC events', () => { }); await rejectedRefresh; - - expect( - await getHandler(PLAYLIST_CANCEL_REFRESH)( - createIpcEvent(), - 'refresh-error' - ) - ).toEqual({ success: false }); }); it('returns save dialog paths and writes files through the filesystem handler', async () => { diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index d177c7987..ddd273d5a 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -11,9 +11,11 @@ import { AUTO_UPDATE_PLAYLISTS, PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, + PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE, PLAYLIST_REFRESH_EVENT, ElectronBridgeTrustOptions, Playlist, + PlaylistRefreshCancelledResult, PlaylistRefreshEvent, PlaylistRefreshPayload, summarizeAutoUpdateOutcomes, @@ -40,10 +42,7 @@ export default class PlaylistEvents { const playlistWriteAuthorizer = new PlaylistWriteAuthorizer(); type ActivePlaylistRefresh = { - reject: (reason?: unknown) => void; - resolve: (value: Playlist) => void; - sender: WebContents; - worker: Worker; + cancel: () => Promise; }; const activePlaylistRefreshes = new Map(); @@ -170,76 +169,126 @@ ipcMain.handle( async (event, payload: PlaylistRefreshPayload) => { const worker = resolvePlaylistRefreshWorker(); - return await new Promise((resolve, reject) => { - const cleanup = async (): Promise => { - activePlaylistRefreshes.delete(payload.operationId); - worker.removeAllListeners(); - await worker.terminate().catch(() => undefined); - }; + return await new Promise( + (resolve, reject) => { + let cleanupPromise: Promise | null = null; + let lastPhase: PlaylistRefreshEvent['phase'] = payload.url + ? 'fetching' + : 'reading-file'; + let settled = false; - activePlaylistRefreshes.set(payload.operationId, { - worker, - sender: event.sender, - resolve, - reject, - }); + const cleanup = (): Promise => { + cleanupPromise ??= (async () => { + activePlaylistRefreshes.delete(payload.operationId); + worker.removeAllListeners(); + await worker.terminate().catch(() => undefined); + })(); + return cleanupPromise; + }; - worker.on( - 'message', - async (message: PlaylistRefreshWorkerMessage) => { - if (message.type === 'ready') { - worker.postMessage({ - type: 'request', - payload, - }); + const cancel = async (): Promise => { + if (settled) { return; } + settled = true; - if (message.type === 'event') { - emitPlaylistRefreshEvent(event.sender, message.event); - return; + try { + worker.postMessage({ + type: 'cancel', + operationId: payload.operationId, + }); + } catch { + // Termination below is authoritative even if cooperative + // cancellation cannot be delivered. } await cleanup(); + emitPlaylistRefreshEvent(event.sender, { + operationId: payload.operationId, + playlistId: payload.playlistId, + phase: lastPhase, + status: 'cancelled', + }); + resolve({ + operationId: payload.operationId, + type: PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE, + }); + }; - const response = - message as PlaylistRefreshWorkerResponseMessage; - if (response.success && response.result) { - resolve(response.result); + const activeRefresh: ActivePlaylistRefresh = { cancel }; + activePlaylistRefreshes.set(payload.operationId, activeRefresh); + + worker.on( + 'message', + async (message: PlaylistRefreshWorkerMessage) => { + if (settled) { + return; + } + + if (message.type === 'ready') { + worker.postMessage({ + type: 'request', + payload, + }); + return; + } + + if (message.type === 'event') { + lastPhase = message.event.phase ?? lastPhase; + emitPlaylistRefreshEvent( + event.sender, + message.event + ); + return; + } + + settled = true; + await cleanup(); + + const response = + message as PlaylistRefreshWorkerResponseMessage; + if (response.success && response.result) { + resolve(response.result); + return; + } + + reject( + createPlaylistRefreshError( + response.error ?? { + message: + 'Playlist refresh worker request failed', + } + ) + ); + } + ); + + worker.on('error', async (error) => { + if (settled) { + return; + } + settled = true; + await cleanup(); + reject(error); + }); + + worker.on('exit', async (code) => { + if (settled) { return; } + settled = true; + await cleanup(); reject( - createPlaylistRefreshError( - response.error ?? { - message: - 'Playlist refresh worker request failed', - } + new Error( + code === 0 + ? 'Playlist refresh worker exited unexpectedly' + : `Playlist refresh worker stopped with exit code ${code}` ) ); - } - ); - - worker.on('error', async (error) => { - await cleanup(); - reject(error); - }); - - worker.on('exit', async (code) => { - if (!activePlaylistRefreshes.has(payload.operationId)) { - return; - } - - await cleanup(); - reject( - new Error( - code === 0 - ? 'Playlist refresh worker exited unexpectedly' - : `Playlist refresh worker stopped with exit code ${code}` - ) - ); - }); - }); + }); + } + ); } ); @@ -251,10 +300,7 @@ ipcMain.handle( return { success: false }; } - activeRefresh.worker.postMessage({ - type: 'cancel', - operationId, - }); + await activeRefresh.cancel(); return { success: true }; } diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 7b1f21df6..d312ae21a 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -1,3 +1,5 @@ +import type { WorkerPerformanceCaptureResult } from './worker-performance-capture'; + export const DB_WORKER_OPERATIONS = [ 'DB_HAS_CATEGORIES', 'DB_GET_CATEGORIES', @@ -51,6 +53,8 @@ export const DB_WORKER_OPERATIONS = [ 'DB_CLEAR_PLAYBACK_POSITION', 'DB_GET_TMDB_METADATA', 'DB_SET_TMDB_METADATA', + 'DB_GET_TMDB_CACHE_STATS', + 'DB_CLEAR_TMDB_METADATA', 'DB_MATCH_TITLES', ] as const; @@ -136,6 +140,7 @@ export interface DbWorkerResponseMessage { success: boolean; result?: TResult; error?: SerializedWorkerError; + performance?: WorkerPerformanceCaptureResult; } export type DbWorkerIncomingMessage = diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 3cfd67144..b3b4d968a 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -79,6 +79,8 @@ import { } from '../database/operations/recently-viewed.operations'; import { matchTitles } from '../database/operations/title-match.operations'; import { + clearTmdbMetadata, + getTmdbCacheStats, getTmdbMetadata, setTmdbMetadata, } from '../database/operations/tmdb.operations'; @@ -86,6 +88,11 @@ import { deleteXtreamContent, restoreXtreamUserData, } from '../database/operations/xtream.operations'; +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; const loggerLabel = '[DB Worker]'; const batchDelayMs = Number.parseInt( @@ -614,6 +621,12 @@ async function executeRequest(message: DbWorkerRequestMessage) { return setTmdbMetadata(db, payload.entry); } + case 'DB_GET_TMDB_CACHE_STATS': + return getTmdbCacheStats(db); + + case 'DB_CLEAR_TMDB_METADATA': + return clearTmdbMetadata(db); + case 'DB_MATCH_TITLES': { const payload = message.payload as { titles: string[] }; return matchTitles(db, payload.titles); @@ -770,7 +783,11 @@ async function executeRequest(message: DbWorkerRequestMessage) { case 'DB_REORDER_GLOBAL_FAVORITES': { const payload = message.payload as { - updates: { content_id: number; playlist_id: string; position: number }[]; + updates: { + content_id: number; + playlist_id: string; + position: number; + }[]; }; return reorderGlobalFavorites(db, payload.updates); } @@ -912,13 +929,18 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); try { const result = await executeRequest(message); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: true, result, + performance, }); } catch (error) { console.error( @@ -926,11 +948,14 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { `Error handling ${message.operation}:`, error ); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, error: serializeError(error), + performance, }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index 37f9f0588..790d6ac55 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -22,6 +22,11 @@ import { } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; type ActiveRefreshState = { cancelled: boolean; @@ -186,12 +191,17 @@ parentPort.on( return; } + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); try { const result = await executeRefresh(message.payload); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', success: true, result, + performance, }); } catch (error) { const payload = message.payload; @@ -206,10 +216,13 @@ parentPort.on( }); } + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', success: false, error: serializeError(error), + performance, }); } } diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts index 05c7e2a80..601b9e62c 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts @@ -1,4 +1,8 @@ -import type { PlaylistRefreshEvent, PlaylistRefreshPayload } from '@iptvnator/shared/interfaces'; +import type { + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; +import type { WorkerPerformanceCaptureResult } from './worker-performance-capture'; export interface PlaylistRefreshWorkerRequestMessage { type: 'request'; @@ -28,6 +32,7 @@ export interface PlaylistRefreshWorkerResponseMessage { message: string; stack?: string; }; + performance?: WorkerPerformanceCaptureResult; } export type PlaylistRefreshWorkerIncomingMessage = diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts new file mode 100644 index 000000000..2801935c3 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -0,0 +1,48 @@ +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('is disabled unless explicitly opted in', () => { + delete process.env[PROFILING_ENV]; + + expect(startWorkerPerformanceCapture()).toBeNull(); + }); + + it('reports finite worker event-loop metrics when opted in', async () => { + process.env[PROFILING_ENV] = '1'; + + const capture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(capture); + const blockStartedAt = performance.now(); + while (performance.now() - blockStartedAt < 20) { + // Deliberately block the loop so the histogram contract is tested. + } + const result = await finishWorkerPerformanceCapture(capture); + + expect(result).toEqual({ + eventLoopDelay: { + maxMs: expect.any(Number), + p95Ms: expect.any(Number), + p99Ms: expect.any(Number), + }, + eventLoopUtilization: expect.any(Number), + }); + expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); + expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts new file mode 100644 index 000000000..db86f234d --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -0,0 +1,68 @@ +import { + monitorEventLoopDelay, + performance, + type IntervalHistogram, +} from 'node:perf_hooks'; + +const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + }; + eventLoopUtilization: number; +} + +interface WorkerPerformanceCapture { + eventLoopDelay: IntervalHistogram; + eventLoopUtilizationStart: ReturnType< + typeof performance.eventLoopUtilization + >; +} + +export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { + if (process.env[WORKER_PROFILING_ENV] !== '1') { + return null; + } + + const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); + eventLoopDelay.enable(); + return { + eventLoopDelay, + eventLoopUtilizationStart: performance.eventLoopUtilization(), + }; +} + +export async function armWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): Promise { + if (capture) { + await new Promise((resolvePromise) => + setTimeout(resolvePromise, 2) + ); + } +} + +export async function finishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): Promise { + if (!capture) { + return undefined; + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); + capture.eventLoopDelay.disable(); + const eventLoopUtilization = performance.eventLoopUtilization( + capture.eventLoopUtilizationStart + ); + return { + eventLoopDelay: { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }, + eventLoopUtilization: eventLoopUtilization.utilization, + }; +} diff --git a/apps/stalker-mock-server/jest.config.ts b/apps/stalker-mock-server/jest.config.ts new file mode 100644 index 000000000..0f7df053c --- /dev/null +++ b/apps/stalker-mock-server/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'stalker-mock-server', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/stalker-mock-server', +}; diff --git a/apps/stalker-mock-server/project.json b/apps/stalker-mock-server/project.json index 137477115..97c88ee44 100644 --- a/apps/stalker-mock-server/project.json +++ b/apps/stalker-mock-server/project.json @@ -33,6 +33,14 @@ }, "lint": { "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/stalker-mock-server/jest.config.ts", + "tsConfig": "apps/stalker-mock-server/tsconfig.spec.json" + } } } } diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 72efdd1b4..d877aca81 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -118,12 +118,12 @@ export interface GeneratedPortalData { radioCategories: RawCategory[]; channels: Map; // categoryId -> channels radio: Map; // categoryId -> radio stations - vod: Map; // categoryId -> items + vod: Map; // categoryId -> items /** Preserves the provider-neutral fixture order for the "*" VOD listing. */ vodOrder?: RawVodItem[]; series: Map; // categoryId -> items - seasons: Map; // seriesItemId -> seasons - epg: Map; // channelId -> programs + seasons: Map; // seriesItemId -> seasons + epg: Map; // channelId -> programs } // --------------------------------------------------------------------------- @@ -174,7 +174,7 @@ const STALKER_MARKETING_VOD_CATEGORY_IDS: Record< }; function generateMarketingVodItems(): RawVodItem[] { - const assetBaseUrl = `http://localhost:${process.env['PORT'] ?? '3210'}/assets/marketing/poster`; + const assetBaseUrl = '/assets/marketing/poster'; return POSTER_SHOWCASE_MOVIES.map((movie, index) => { const id = String(29_000 + index); @@ -208,7 +208,9 @@ function generateMarketingVodItems(): RawVodItem[] { // Generator // --------------------------------------------------------------------------- -export function generatePortalData(config: ScenarioConfig): GeneratedPortalData { +export function generatePortalData( + config: ScenarioConfig +): GeneratedPortalData { faker.seed(config.seed); const data: GeneratedPortalData = { @@ -238,7 +240,11 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData }); let channelIndex = 0; for (const cat of data.itvCategories) { - const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex); + const channels = generateChannels( + cat.id, + config.itemsPerCategory, + channelIndex + ); data.channels.set(cat.id, channels); for (const ch of channels) { data.epg.set(ch.id, generateEpg(ch.name)); @@ -299,13 +305,27 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData } // ------ Series categories + items ------ - data.seriesCategories = generateCategories('series', config.categoryCount.series); + data.seriesCategories = generateCategories( + 'series', + config.categoryCount.series + ); let seriesIndex = 0; for (const cat of data.seriesCategories) { - const items = generateSeriesItems(cat.id, config.itemsPerCategory, seriesIndex); + const items = generateSeriesItems( + cat.id, + config.itemsPerCategory, + seriesIndex + ); data.series.set(cat.id, items); for (const item of items) { - data.seasons.set(item.id, generateSeasons(item, config.seasonsPerSeries, config.episodesPerSeason)); + data.seasons.set( + item.id, + generateSeasons( + item, + config.seasonsPerSeries, + config.episodesPerSeason + ) + ); } seriesIndex += config.itemsPerCategory; } @@ -318,32 +338,95 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData // --------------------------------------------------------------------------- const ITV_GENRE_NAMES = [ - 'News', 'Sports', 'Movies', 'Entertainment', 'Kids', - 'Documentary', 'Music', 'Comedy', 'Drama', 'Reality TV', - 'Lifestyle', 'Travel', 'Food', 'Tech', 'Science', - 'History', 'Nature', 'Animation', 'Gaming', 'Shopping', + 'News', + 'Sports', + 'Movies', + 'Entertainment', + 'Kids', + 'Documentary', + 'Music', + 'Comedy', + 'Drama', + 'Reality TV', + 'Lifestyle', + 'Travel', + 'Food', + 'Tech', + 'Science', + 'History', + 'Nature', + 'Animation', + 'Gaming', + 'Shopping', ]; const VOD_GENRE_NAMES = [ - 'Action', 'Comedy', 'Drama', 'Horror', 'Thriller', - 'Romance', 'Sci-Fi', 'Fantasy', 'Animation', 'Documentary', - 'Biography', 'Crime', 'Mystery', 'Adventure', 'Family', - 'War', 'Western', 'Musical', 'Sport', 'History', + 'Action', + 'Comedy', + 'Drama', + 'Horror', + 'Thriller', + 'Romance', + 'Sci-Fi', + 'Fantasy', + 'Animation', + 'Documentary', + 'Biography', + 'Crime', + 'Mystery', + 'Adventure', + 'Family', + 'War', + 'Western', + 'Musical', + 'Sport', + 'History', ]; const SERIES_GENRE_NAMES = [ - 'Drama Series', 'Comedy Series', 'Crime Series', 'Sci-Fi Series', - 'Reality Shows', 'Anime', 'Soap Opera', 'Mini Series', - 'Documentary Series', 'Kids Shows', 'Action Series', 'Fantasy Series', - 'Medical', 'Legal', 'Political', 'Romance Series', 'Historical', - 'Thriller Series', 'Horror Series', 'Western Series', + 'Drama Series', + 'Comedy Series', + 'Crime Series', + 'Sci-Fi Series', + 'Reality Shows', + 'Anime', + 'Soap Opera', + 'Mini Series', + 'Documentary Series', + 'Kids Shows', + 'Action Series', + 'Fantasy Series', + 'Medical', + 'Legal', + 'Political', + 'Romance Series', + 'Historical', + 'Thriller Series', + 'Horror Series', + 'Western Series', ]; const RADIO_GENRE_NAMES = [ - 'News Radio', 'Talk Radio', 'Jazz', 'Classical', 'Rock', - 'Pop Hits', 'Electronic', 'World Music', 'Sports Radio', 'Public Radio', - 'Local Stations', 'Latin', 'Hip Hop', 'Ambient', 'Oldies', - 'Country', 'Reggae', 'Soul', 'Dance', 'Weather', + 'News Radio', + 'Talk Radio', + 'Jazz', + 'Classical', + 'Rock', + 'Pop Hits', + 'Electronic', + 'World Music', + 'Sports Radio', + 'Public Radio', + 'Local Stations', + 'Latin', + 'Hip Hop', + 'Ambient', + 'Oldies', + 'Country', + 'Reggae', + 'Soul', + 'Dance', + 'Weather', ]; function getGenreNames(type: 'itv' | 'vod' | 'series' | 'radio'): string[] { @@ -377,7 +460,11 @@ function generateCategories( // Channel generators // --------------------------------------------------------------------------- -function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] { +function generateChannels( + categoryId: string, + count: number, + startIndex: number +): RawChannel[] { return Array.from({ length: count }, (_, i) => { const globalIndex = startIndex + i; const id = String(10000 + globalIndex); @@ -436,7 +523,8 @@ function generateVodItems( const id = String(20000 + globalIndex); const title = faker.music.songName() + ': ' + faker.lorem.words(2); const isSeries = i / count < isSeriesFraction; - const hasEmbeddedSeries = !isSeries && (i / count < isSeriesFraction + embeddedSeriesFraction); + const hasEmbeddedSeries = + !isSeries && i / count < isSeriesFraction + embeddedSeriesFraction; const item: RawVodItem = { id, @@ -447,7 +535,9 @@ function generateVodItems( screenshot_uri: coverUrl(`vod-${id}`), cover: coverUrl(`vod-cover-${id}`, 300, 450), description: faker.lorem.paragraph(), - actors: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '), + actors: Array.from({ length: 4 }, () => + faker.person.fullName() + ).join(', '), director: faker.person.fullName(), year: String(faker.date.past({ years: 20 }).getFullYear()), genre: faker.music.genre(), @@ -479,7 +569,11 @@ function generateEmbeddedEpisodes(count: number): string[] { // Series generators // --------------------------------------------------------------------------- -function generateSeriesItems(categoryId: string, count: number, startIndex: number): RawSeriesItem[] { +function generateSeriesItems( + categoryId: string, + count: number, + startIndex: number +): RawSeriesItem[] { return Array.from({ length: count }, (_, i) => { const globalIndex = startIndex + i; const id = String(30000 + globalIndex); @@ -493,7 +587,9 @@ function generateSeriesItems(categoryId: string, count: number, startIndex: numb screenshot_uri: coverUrl(`series-${id}`), cover: coverUrl(`series-cover-${id}`, 300, 450), description: faker.lorem.paragraph(), - actors: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '), + actors: Array.from({ length: 4 }, () => + faker.person.fullName() + ).join(', '), director: faker.person.fullName(), year: String(faker.date.past({ years: 10 }).getFullYear()), genres_str: [faker.music.genre(), faker.music.genre()].join(', '), diff --git a/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts b/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts new file mode 100644 index 000000000..e8055efc9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts @@ -0,0 +1,65 @@ +import { + buildRequestOrigin, + resolveMarketingPosterUrls, +} from './marketing-poster-url'; + +type RequestLike = Parameters[0]; + +function request( + protocol: string, + headers: Record +): RequestLike { + return { + protocol, + get: (name: string) => headers[name.toLowerCase()], + }; +} + +describe('marketing poster URLs', () => { + it('uses the forwarded public origin for relative poster paths', () => { + const req = request('http', { + host: 'stalker-mock:3210', + 'x-forwarded-host': 'demo.example:9443', + 'x-forwarded-proto': 'https', + }); + const payload = { + js: { + data: [ + { + cover: '/assets/marketing/poster/black-harbor.png', + screenshot_uri: + '/assets/marketing/poster/black-harbor.png', + }, + ], + }, + }; + + expect( + resolveMarketingPosterUrls(payload, buildRequestOrigin(req)) + ).toEqual({ + js: { + data: [ + { + cover: 'https://demo.example:9443/assets/marketing/poster/black-harbor.png', + screenshot_uri: + 'https://demo.example:9443/assets/marketing/poster/black-harbor.png', + }, + ], + }, + }); + }); + + it('preserves unrelated URLs and values', () => { + const payload = { + cover: 'https://picsum.photos/seed/movie/300/450', + count: 1, + }; + + expect( + resolveMarketingPosterUrls( + payload, + buildRequestOrigin(request('http', { host: 'localhost:3210' })) + ) + ).toEqual(payload); + }); +}); diff --git a/apps/stalker-mock-server/src/app/marketing-poster-url.ts b/apps/stalker-mock-server/src/app/marketing-poster-url.ts new file mode 100644 index 000000000..5ef73e4a9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/marketing-poster-url.ts @@ -0,0 +1,51 @@ +type RequestOriginSource = { + protocol: string; + get(name: string): string | undefined; +}; + +const MARKETING_POSTER_PATH_PREFIX = '/assets/marketing/poster/'; + +export function buildRequestOrigin(request: RequestOriginSource): string { + const protocol = + firstForwardedValue(request.get('x-forwarded-proto')) ?? + request.protocol; + const host = + firstForwardedValue(request.get('x-forwarded-host')) ?? + request.get('host'); + + return host ? `${protocol}://${host}` : ''; +} + +export function resolveMarketingPosterUrls( + payload: unknown, + origin: string +): unknown { + if ( + typeof payload === 'string' && + payload.startsWith(MARKETING_POSTER_PATH_PREFIX) + ) { + return origin ? new URL(payload, origin).href : payload; + } + + if (Array.isArray(payload)) { + return payload.map((item) => resolveMarketingPosterUrls(item, origin)); + } + + if (payload && typeof payload === 'object') { + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => [ + key, + resolveMarketingPosterUrls(value, origin), + ]) + ); + } + + return payload; +} + +function firstForwardedValue(value: string | undefined): string | undefined { + return value + ?.split(',', 1)[0] + ?.trim() + .replace(/^(?:https?):\/\//, ''); +} diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 6d84d369f..dfa665b2a 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -6,6 +6,10 @@ import portalRouter from './app/routes/portal.route.js'; import dispatchPortalAction from './app/routes/dispatch.js'; import { resetAll } from './app/data-store.js'; import { SCENARIOS } from './app/scenarios.js'; +import { + buildRequestOrigin, + resolveMarketingPosterUrls, +} from './app/marketing-poster-url.js'; const PORT = parseInt(process.env['PORT'] ?? '3210', 10); const app = express(); @@ -29,13 +33,35 @@ app.use((req, _res, next) => { .split(';') .find((c) => c.trim().startsWith('mac=')) ?.split('=')[1] - ?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac'; + ?.trim() ?? + (req.query['macAddress'] as string) ?? + 'no-mac'; console.log( `[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}` ); next(); }); +// Marketing fixtures store deployment-neutral asset paths. Resolve them +// against the public origin of each request, including reverse-proxy headers. +app.use((req, res, next) => { + if ( + !['get_ordered_list', 'favorites'].includes(String(req.query['action'])) + ) { + next(); + return; + } + + const sendJson = res.json.bind(res); + const requestOrigin = buildRequestOrigin(req); + + res.json = ((body: unknown) => + sendJson( + resolveMarketingPosterUrls(body, requestOrigin) + )) as Response['json']; + next(); +}); + // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- @@ -63,7 +89,11 @@ app.use('/portal.php', portalRouter); * so no app code changes are required. */ app.get('/stalker', (req: Request, res: Response) => { - const { macAddress, url: _url, ...rest } = req.query as Record; + const { + macAddress, + url: _url, + ...rest + } = req.query as Record; const mac = macAddress ?? '00:1a:79:00:00:01'; // Build a lightweight synthetic request. We need a fresh object with mutable @@ -130,7 +160,9 @@ process.on('unhandledRejection', (reason) => { // When Nx (or any process manager) closes stdin, prevent auto-exit. // The HTTP server handle is what keeps the process alive. process.stdin.resume(); -process.stdin.on('end', () => { /* ignore stdin close */ }); +process.stdin.on('end', () => { + /* ignore stdin close */ +}); server.listen(PORT, () => { const divider = '─'.repeat(62); @@ -150,9 +182,13 @@ server.listen(PORT, () => { ); } console.log(''); - console.log(' Any other MAC generates deterministic unique data from MAC bytes.'); + console.log( + ' Any other MAC generates deterministic unique data from MAC bytes.' + ); console.log(` Utilities:`); console.log(` GET http://localhost:${PORT}/health`); - console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`); + console.log( + ` POST http://localhost:${PORT}/reset (clears favorites + cache)` + ); console.log(`${divider}\n`); }); diff --git a/apps/stalker-mock-server/tsconfig.spec.json b/apps/stalker-mock-server/tsconfig.spec.json new file mode 100644 index 000000000..09849f85f --- /dev/null +++ b/apps/stalker-mock-server/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index 09e3a73e6..99e5fa6d5 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -99,6 +99,47 @@ } +
+
+

{{ 'SETTINGS.TMDB_CACHE' | translate }}

+

+ {{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }} +

+

+ @if (cacheError()) { + + {{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }} + + } @else if (cacheStats(); as stats) { + {{ + 'SETTINGS.TMDB_CACHE_SIZE' + | translate + : { + entries: stats.entries, + size: formatBytes(stats.bytes), + } + }} + } +

+
+
+ +
+
+
{ + let fixture: ComponentFixture; + let getStats: jest.Mock; + let clear: jest.Mock; + + const createForm = () => + new FormGroup({ + tmdb: new FormGroup({ + enabled: new FormControl(true), + apiKey: new FormControl(''), + }), + }); + + const queryByTestId = (testId: string): HTMLElement | null => + fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`); + + const clearButton = () => + queryByTestId('tmdb-clear-cache') as HTMLButtonElement; + + /** The stats effect only fires once the TMDB section is the active one */ + const activate = async (section = 'tmdb') => { + fixture.componentRef.setInput('activeSection', section); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(async () => { + getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 }); + clear = jest.fn().mockResolvedValue(42); + + await TestBed.configureTestingModule({ + imports: [ + SettingsTmdbSectionComponent, + NoopAnimationsModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: TmdbCacheService, + useValue: { getStats, clear }, + }, + { + provide: TmdbApiService, + useValue: { validateApiKey: jest.fn() }, + }, + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation( + 'en', + { + SETTINGS: { + TMDB_CACHE_SIZE: CACHE_SIZE_LABEL, + TMDB_CACHE_ERROR: CACHE_ERROR_LABEL, + }, + }, + true + ); + translate.use('en'); + + fixture = TestBed.createComponent(SettingsTmdbSectionComponent); + fixture.componentRef.setInput('form', createForm()); + fixture.componentRef.setInput('activeSection', 'general'); + fixture.detectChanges(); + }); + + it('defers the full-table scan until the section is opened', async () => { + expect(getStats).not.toHaveBeenCalled(); + + await activate(); + + expect(getStats).toHaveBeenCalledTimes(1); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); + }); + + it('clears the cache and re-reads the size', async () => { + await activate(); + getStats.mockResolvedValue({ entries: 0, bytes: 0 }); + + clearButton().click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(getStats).toHaveBeenCalledTimes(2); + // Nothing left to clear — the button stops offering it + expect(clearButton().disabled).toBe(true); + }); + + it('surfaces a failed clear instead of claiming an empty cache', async () => { + await activate(); + clear.mockResolvedValue(null); + + clearButton().click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + // Still actionable: the rows are there, the user can retry + expect(clearButton().disabled).toBe(false); + }); + + it('surfaces a failed size read the same way', async () => { + getStats.mockResolvedValue(null); + + await activate(); + + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + }); + + it('retries the size read next time the section is opened', async () => { + getStats.mockResolvedValueOnce(null); + await activate(); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + + await activate('general'); + await activate(); + + // Otherwise a single transient failure sticks for the life of the + // page, and only the destructive Clear button can shift it + expect(getStats).toHaveBeenCalledTimes(2); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); + }); +}); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 593364775..e7a6f7e3c 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -1,4 +1,12 @@ -import { Component, inject, input, signal, ViewEncapsulation } from '@angular/core'; +import { + Component, + effect, + inject, + input, + signal, + untracked, + ViewEncapsulation, +} from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -7,7 +15,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslateModule } from '@ngx-translate/core'; -import { TmdbApiService } from '@iptvnator/services'; +import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; +import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @@ -59,16 +68,46 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; .tmdb-key-test__result--error { color: #f44336; } + + .tmdb-cache__size { + font-variant-numeric: tabular-nums; + } + + .tmdb-cache__error { + color: #f44336; + } `, ], }) export class SettingsTmdbSectionComponent { private readonly tmdbApi = inject(TmdbApiService); + private readonly tmdbCache = inject(TmdbCacheService); readonly form = input.required(); readonly activeSection = input.required(); readonly keyTestState = signal('idle'); + readonly cacheStats = signal(null); + readonly cacheError = signal(false); + readonly isClearing = signal(false); + + constructor() { + // Sizing the cache is a full table scan, so it waits until the + // user is actually looking at this section. + effect(() => { + if (this.activeSection() !== 'tmdb') { + return; + } + untracked(() => { + // A failed read leaves both signals in their "unknown" + // state, so reopening the section retries rather than + // showing the error until the page is rebuilt. + if (this.cacheStats() === null) { + void this.refreshCacheStats(); + } + }); + }); + } get enteredApiKey(): string { return (this.form().value.tmdb?.apiKey ?? '').trim(); @@ -84,4 +123,45 @@ export class SettingsTmdbSectionComponent { const isValid = await this.tmdbApi.validateApiKey(apiKey); this.keyTestState.set(isValid ? 'success' : 'error'); } + + async clearCache(): Promise { + if (this.isClearing()) { + return; + } + this.isClearing.set(true); + try { + const deleted = await this.tmdbCache.clear(); + if (deleted === null) { + // Do not claim an empty cache we failed to empty + this.cacheError.set(true); + this.cacheStats.set(null); + return; + } + await this.refreshCacheStats(); + } finally { + this.isClearing.set(false); + } + } + + /** "12.4 MB" — cache payloads are JSON, so decimal units read right */ + formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; + } + + private async refreshCacheStats(): Promise { + const stats = await this.tmdbCache.getStats(); + this.cacheError.set(stats === null); + this.cacheStats.set(stats); + } + } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b7681d351..9c2ef982f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "التحقق من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح يعمل — استجاب TMDB بنجاح", "TMDB_TEST_FAILED": "فشل التحقق — رفض TMDB المفتاح أو تعذر الوصول إليه", + "TMDB_CACHE": "ذاكرة التخزين المؤقت للبيانات الوصفية", + "TMDB_CACHE_DESCRIPTION": "تُحفظ البيانات الوصفية المنزّلة محلياً لتفتح صفحات التفاصيل فوراً. مسحها يجعل التطبيق يجلب بيانات جديدة عند الزيارة التالية.", + "TMDB_CACHE_SIZE": "{{entries}} عنصر · {{size}}", + "TMDB_CACHE_CLEAR": "مسح الذاكرة المؤقتة", + "TMDB_CACHE_ERROR": "تعذّر قراءة الذاكرة المؤقتة — حاول مجدداً", "TMDB_CREDITS": "البيانات الوصفية", "TMDB_ATTRIBUTION": "يستخدم هذا المنتج TMDB API ولكنه غير معتمد أو مصدّق من قِبل TMDB.", "APP_PREFERENCES": "تفضيلات التطبيق", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 1034eafee..6f8a3ca06 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "تأكد من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح خدام — TMDB جاوب مزيان", "TMDB_TEST_FAILED": "التحقق ما نجحش — TMDB رفض المفتاح ولا ما يمكنش الوصول ليه", + "TMDB_CACHE": "كاش ديال البيانات الوصفية", + "TMDB_CACHE_DESCRIPTION": "البيانات الوصفية اللي تحملات كتبقى محفوظة محلياً باش صفحات التفاصيل تتفتح دغيا. إلا مسحتيها، التطبيق غايجيب بيانات جديدة فالمرة الجاية.", + "TMDB_CACHE_SIZE": "{{entries}} عنصر · {{size}}", + "TMDB_CACHE_CLEAR": "مسح الكاش", + "TMDB_CACHE_ERROR": "ماقدرناش نقراو الكاش — عاود جرب", "TMDB_CREDITS": "الميتاداتا", "TMDB_ATTRIBUTION": "هاد المنتج كيستعمل TMDB API ولكن ماشي معتمد ولا مصادق عليه من طرف TMDB.", "APP_PREFERENCES": "تفضيلات التطبيق", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5485a3871..12ef0bfb5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Праверыць ключ", "TMDB_TEST_SUCCESS": "Ключ працуе — TMDB паспяхова адказаў", "TMDB_TEST_FAILED": "Праверка не ўдалася — TMDB адхіліў ключ або недаступны", + "TMDB_CACHE": "Кэш метаданых", + "TMDB_CACHE_DESCRIPTION": "Спампаваныя метаданыя захоўваюцца лакальна, каб старонкі дэталяў адкрываліся імгненна. Пасля ачысткі праграма загрузіць свежыя даныя пры наступным адкрыцці.", + "TMDB_CACHE_SIZE": "{{entries}} запісаў · {{size}}", + "TMDB_CACHE_CLEAR": "Ачысціць кэш", + "TMDB_CACHE_ERROR": "Не ўдалося прачытаць кэш — паспрабуйце зноў", "TMDB_CREDITS": "Метаданыя", "TMDB_ATTRIBUTION": "Гэты прадукт выкарыстоўвае TMDB API, але не ўхвалены і не сертыфікаваны TMDB.", "APP_PREFERENCES": "Параметры праграмы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 6f55673e3..8786a0736 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Schlüssel prüfen", "TMDB_TEST_SUCCESS": "Der Schlüssel funktioniert — TMDB hat erfolgreich geantwortet", "TMDB_TEST_FAILED": "Prüfung fehlgeschlagen — TMDB hat den Schlüssel abgelehnt oder ist nicht erreichbar", + "TMDB_CACHE": "Metadaten-Cache", + "TMDB_CACHE_DESCRIPTION": "Heruntergeladene Metadaten werden lokal gespeichert, damit Detailseiten sofort öffnen. Nach dem Leeren lädt die App beim nächsten Besuch frische Daten.", + "TMDB_CACHE_SIZE": "{{entries}} Einträge · {{size}}", + "TMDB_CACHE_CLEAR": "Cache leeren", + "TMDB_CACHE_ERROR": "Cache konnte nicht gelesen werden — erneut versuchen", "TMDB_CREDITS": "Metadaten", "TMDB_ATTRIBUTION": "Dieses Produkt verwendet die TMDB API, wird jedoch nicht von TMDB unterstützt oder zertifiziert.", "APP_PREFERENCES": "App-Einstellungen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f0ef6ff16..8ca4913ce 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Έλεγχος κλειδιού", "TMDB_TEST_SUCCESS": "Το κλειδί λειτουργεί — το TMDB απάντησε με επιτυχία", "TMDB_TEST_FAILED": "Ο έλεγχος απέτυχε — το TMDB απέρριψε το κλειδί ή δεν είναι προσβάσιμο", + "TMDB_CACHE": "Προσωρινή μνήμη μεταδεδομένων", + "TMDB_CACHE_DESCRIPTION": "Τα ληφθέντα μεταδεδομένα αποθηκεύονται τοπικά ώστε οι σελίδες λεπτομερειών να ανοίγουν αμέσως. Η εκκαθάριση αναγκάζει την εφαρμογή να κατεβάσει νέα δεδομένα.", + "TMDB_CACHE_SIZE": "{{entries}} εγγραφές · {{size}}", + "TMDB_CACHE_CLEAR": "Εκκαθάριση", + "TMDB_CACHE_ERROR": "Αδυναμία ανάγνωσης της μνήμης — δοκιμάστε ξανά", "TMDB_CREDITS": "Μεταδεδομένα", "TMDB_ATTRIBUTION": "Αυτό το προϊόν χρησιμοποιεί το TMDB API, αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB.", "APP_PREFERENCES": "Προτιμήσεις εφαρμογής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ded4da1d5..130b118e9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Check key", "TMDB_TEST_SUCCESS": "The key works — TMDB responded successfully", "TMDB_TEST_FAILED": "Check failed — TMDB rejected the key or is unreachable", + "TMDB_CACHE": "Metadata cache", + "TMDB_CACHE_DESCRIPTION": "Downloaded metadata is stored locally so detail pages open instantly. Clearing it makes the app fetch fresh data on the next visit.", + "TMDB_CACHE_SIZE": "{{entries}} entries · {{size}}", + "TMDB_CACHE_CLEAR": "Clear cache", + "TMDB_CACHE_ERROR": "Could not read the cache — try again", "TMDB_CREDITS": "Metadata", "TMDB_ATTRIBUTION": "This product uses the TMDB API but is not endorsed or certified by TMDB.", "APP_PREFERENCES": "App preferences", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 8dc779349..256f10042 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Comprobar clave", "TMDB_TEST_SUCCESS": "La clave funciona — TMDB respondió correctamente", "TMDB_TEST_FAILED": "La comprobación falló — TMDB rechazó la clave o no está disponible", + "TMDB_CACHE": "Caché de metadatos", + "TMDB_CACHE_DESCRIPTION": "Los metadatos descargados se guardan localmente para que las páginas de detalles se abran al instante. Al vaciarla, la app descargará datos nuevos en la próxima visita.", + "TMDB_CACHE_SIZE": "{{entries}} entradas · {{size}}", + "TMDB_CACHE_CLEAR": "Vaciar caché", + "TMDB_CACHE_ERROR": "No se pudo leer la caché: inténtalo de nuevo", "TMDB_CREDITS": "Metadatos", "TMDB_ATTRIBUTION": "Este producto utiliza la API de TMDB, pero no está avalado ni certificado por TMDB.", "APP_PREFERENCES": "Preferencias de la app", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d1a2cbe55..cb86e2daa 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Vérifier la clé", "TMDB_TEST_SUCCESS": "La clé fonctionne — TMDB a répondu avec succès", "TMDB_TEST_FAILED": "Échec de la vérification — TMDB a rejeté la clé ou est injoignable", + "TMDB_CACHE": "Cache des métadonnées", + "TMDB_CACHE_DESCRIPTION": "Les métadonnées téléchargées sont stockées localement pour que les pages de détails s'ouvrent instantanément. Après le vidage, l'application récupérera des données fraîches.", + "TMDB_CACHE_SIZE": "{{entries}} entrées · {{size}}", + "TMDB_CACHE_CLEAR": "Vider le cache", + "TMDB_CACHE_ERROR": "Impossible de lire le cache — réessayez", "TMDB_CREDITS": "Métadonnées", "TMDB_ATTRIBUTION": "Ce produit utilise l'API TMDB mais n'est ni approuvé ni certifié par TMDB.", "APP_PREFERENCES": "Préférences de l'application", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 929b2936d..83183c4a4 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Kulcs ellenőrzése", "TMDB_TEST_SUCCESS": "A kulcs működik – a TMDB sikeresen válaszolt", "TMDB_TEST_FAILED": "Az ellenőrzés sikertelen – a TMDB elutasította a kulcsot, vagy nem érhető el", + "TMDB_CACHE": "Metaadat-gyorsítótár", + "TMDB_CACHE_DESCRIPTION": "A letöltött metaadatok helyben tárolódnak, így a részletek azonnal megnyílnak. Ürítés után az alkalmazás friss adatokat tölt le.", + "TMDB_CACHE_SIZE": "{{entries}} bejegyzés · {{size}}", + "TMDB_CACHE_CLEAR": "Gyorsítótár ürítése", + "TMDB_CACHE_ERROR": "A gyorsítótár nem olvasható — próbálja újra", "TMDB_CREDITS": "Metaadatok", "TMDB_ATTRIBUTION": "Ez a termék a TMDB API-ját használja, de a TMDB nem hagyta jóvá és nem tanúsította.", "APP_PREFERENCES": "Az alkalmazás beállításai", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 809ac023a..a57ea5146 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Verifica chiave", "TMDB_TEST_SUCCESS": "La chiave funziona — TMDB ha risposto correttamente", "TMDB_TEST_FAILED": "Verifica non riuscita — TMDB ha rifiutato la chiave o non è raggiungibile", + "TMDB_CACHE": "Cache dei metadati", + "TMDB_CACHE_DESCRIPTION": "I metadati scaricati vengono salvati localmente perché le pagine dei dettagli si aprano subito. Svuotandola, l'app scaricherà dati aggiornati alla prossima visita.", + "TMDB_CACHE_SIZE": "{{entries}} voci · {{size}}", + "TMDB_CACHE_CLEAR": "Svuota cache", + "TMDB_CACHE_ERROR": "Impossibile leggere la cache — riprova", "TMDB_CREDITS": "Metadati", "TMDB_ATTRIBUTION": "Questo prodotto utilizza l'API di TMDB ma non è approvato né certificato da TMDB.", "APP_PREFERENCES": "Preferenze app", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 31fbfd08f..ccbdfeddb 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "キーを確認", "TMDB_TEST_SUCCESS": "キーは有効です — TMDB が正常に応答しました", "TMDB_TEST_FAILED": "確認に失敗しました — TMDB がキーを拒否したか、接続できません", + "TMDB_CACHE": "メタデータキャッシュ", + "TMDB_CACHE_DESCRIPTION": "ダウンロードしたメタデータをローカルに保存し、詳細ページを即座に表示します。消去すると次回アクセス時に再取得します。", + "TMDB_CACHE_SIZE": "{{entries}} 件 · {{size}}", + "TMDB_CACHE_CLEAR": "キャッシュを消去", + "TMDB_CACHE_ERROR": "キャッシュを読み取れません — 再試行してください", "TMDB_CREDITS": "メタデータ", "TMDB_ATTRIBUTION": "本製品は TMDB API を使用していますが、TMDB による承認または認証を受けたものではありません。", "APP_PREFERENCES": "アプリ設定", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ba89e55b2..1c7351c29 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "키 확인", "TMDB_TEST_SUCCESS": "키가 작동합니다 — TMDB가 정상적으로 응답했습니다", "TMDB_TEST_FAILED": "확인에 실패했습니다 — TMDB가 키를 거부했거나 연결할 수 없습니다", + "TMDB_CACHE": "메타데이터 캐시", + "TMDB_CACHE_DESCRIPTION": "내려받은 메타데이터를 로컬에 저장해 상세 페이지가 즉시 열립니다. 지우면 다음 방문 시 새로 가져옵니다.", + "TMDB_CACHE_SIZE": "{{entries}}개 · {{size}}", + "TMDB_CACHE_CLEAR": "캐시 지우기", + "TMDB_CACHE_ERROR": "캐시를 읽지 못했습니다 — 다시 시도하세요", "TMDB_CREDITS": "메타데이터", "TMDB_ATTRIBUTION": "이 제품은 TMDB API를 사용하지만 TMDB의 보증이나 인증을 받지 않았습니다.", "APP_PREFERENCES": "앱 환경설정", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8743e9b09..c28d9c7e3 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Sleutel controleren", "TMDB_TEST_SUCCESS": "De sleutel werkt — TMDB heeft succesvol gereageerd", "TMDB_TEST_FAILED": "Controle mislukt — TMDB heeft de sleutel geweigerd of is niet bereikbaar", + "TMDB_CACHE": "Metadata-cache", + "TMDB_CACHE_DESCRIPTION": "Gedownloade metadata wordt lokaal opgeslagen zodat detailpagina's direct openen. Na het wissen haalt de app bij het volgende bezoek verse gegevens op.", + "TMDB_CACHE_SIZE": "{{entries}} items · {{size}}", + "TMDB_CACHE_CLEAR": "Cache wissen", + "TMDB_CACHE_ERROR": "Cache kon niet worden gelezen — probeer opnieuw", "TMDB_CREDITS": "Metadata", "TMDB_ATTRIBUTION": "Dit product maakt gebruik van de TMDB API, maar wordt niet onderschreven of gecertificeerd door TMDB.", "APP_PREFERENCES": "App-voorkeuren", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 9919dc02d..46f5db3c9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Sprawdź klucz", "TMDB_TEST_SUCCESS": "Klucz działa — TMDB odpowiedziało poprawnie", "TMDB_TEST_FAILED": "Sprawdzenie nie powiodło się — TMDB odrzuciło klucz lub jest niedostępne", + "TMDB_CACHE": "Pamięć podręczna metadanych", + "TMDB_CACHE_DESCRIPTION": "Pobrane metadane są zapisywane lokalnie, dzięki czemu strony szczegółów otwierają się natychmiast. Po wyczyszczeniu aplikacja pobierze świeże dane.", + "TMDB_CACHE_SIZE": "{{entries}} wpisów · {{size}}", + "TMDB_CACHE_CLEAR": "Wyczyść pamięć", + "TMDB_CACHE_ERROR": "Nie udało się odczytać pamięci — spróbuj ponownie", "TMDB_CREDITS": "Metadane", "TMDB_ATTRIBUTION": "Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB.", "APP_PREFERENCES": "Preferencje aplikacji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 7f87a7027..da02cce09 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Verificar chave", "TMDB_TEST_SUCCESS": "A chave funciona — o TMDB respondeu com sucesso", "TMDB_TEST_FAILED": "A verificação falhou — o TMDB rejeitou a chave ou está inacessível", + "TMDB_CACHE": "Cache de metadados", + "TMDB_CACHE_DESCRIPTION": "Os metadados transferidos são guardados localmente para que as páginas de detalhes abram de imediato. Ao limpar, a app irá obter dados novos na próxima visita.", + "TMDB_CACHE_SIZE": "{{entries}} entradas · {{size}}", + "TMDB_CACHE_CLEAR": "Limpar cache", + "TMDB_CACHE_ERROR": "Não foi possível ler a cache — tente novamente", "TMDB_CREDITS": "Metadados", "TMDB_ATTRIBUTION": "Este produto usa a API do TMDB, mas não é endossado nem certificado pelo TMDB.", "APP_PREFERENCES": "Preferências do aplicativo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7b43ff48b..c2eb29748 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Проверить ключ", "TMDB_TEST_SUCCESS": "Ключ работает — TMDB успешно ответил", "TMDB_TEST_FAILED": "Проверка не удалась — TMDB отклонил ключ или недоступен", + "TMDB_CACHE": "Кэш метаданных", + "TMDB_CACHE_DESCRIPTION": "Загруженные метаданные хранятся локально, поэтому страницы деталей открываются мгновенно. После очистки приложение загрузит свежие данные при следующем открытии.", + "TMDB_CACHE_SIZE": "{{entries}} записей · {{size}}", + "TMDB_CACHE_CLEAR": "Очистить кэш", + "TMDB_CACHE_ERROR": "Не удалось прочитать кэш — попробуйте ещё раз", "TMDB_CREDITS": "Метаданные", "TMDB_ATTRIBUTION": "Этот продукт использует TMDB API, но не одобрен и не сертифицирован TMDB.", "APP_PREFERENCES": "Параметры приложения", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7f03f8c15..5df212dc2 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Anahtarı kontrol et", "TMDB_TEST_SUCCESS": "Anahtar çalışıyor — TMDB başarıyla yanıt verdi", "TMDB_TEST_FAILED": "Kontrol başarısız — TMDB anahtarı reddetti veya erişilemiyor", + "TMDB_CACHE": "Meta veri önbelleği", + "TMDB_CACHE_DESCRIPTION": "İndirilen meta veriler yerel olarak saklanır, böylece detay sayfaları anında açılır. Temizlediğinizde uygulama bir sonraki ziyarette yeni veri indirir.", + "TMDB_CACHE_SIZE": "{{entries}} kayıt · {{size}}", + "TMDB_CACHE_CLEAR": "Önbelleği temizle", + "TMDB_CACHE_ERROR": "Önbellek okunamadı — tekrar deneyin", "TMDB_CREDITS": "Meta veri", "TMDB_ATTRIBUTION": "Bu ürün TMDB API'sini kullanır ancak TMDB tarafından onaylanmamış veya sertifikalandırılmamıştır.", "APP_PREFERENCES": "Uygulama tercihleri", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 6ecf42801..3f0dc3ac2 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "检查密钥", "TMDB_TEST_SUCCESS": "密钥有效 — TMDB 响应成功", "TMDB_TEST_FAILED": "检查失败 — TMDB 拒绝了该密钥或无法访问", + "TMDB_CACHE": "元数据缓存", + "TMDB_CACHE_DESCRIPTION": "已下载的元数据保存在本地,详情页可立即打开。清除后应用会在下次访问时重新获取。", + "TMDB_CACHE_SIZE": "{{entries}} 条 · {{size}}", + "TMDB_CACHE_CLEAR": "清除缓存", + "TMDB_CACHE_ERROR": "无法读取缓存 — 请重试", "TMDB_CREDITS": "元数据", "TMDB_ATTRIBUTION": "本产品使用 TMDB API,但未获得 TMDB 的认可或认证。", "APP_PREFERENCES": "应用偏好", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 619d60d72..042b7c831 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "檢查金鑰", "TMDB_TEST_SUCCESS": "金鑰有效 — TMDB 回應成功", "TMDB_TEST_FAILED": "檢查失敗 — TMDB 拒絕該金鑰或無法連線", + "TMDB_CACHE": "中繼資料快取", + "TMDB_CACHE_DESCRIPTION": "已下載的中繼資料儲存在本機,詳細頁面可立即開啟。清除後應用程式會在下次瀏覽時重新取得。", + "TMDB_CACHE_SIZE": "{{entries}} 筆 · {{size}}", + "TMDB_CACHE_CLEAR": "清除快取", + "TMDB_CACHE_ERROR": "無法讀取快取 — 請重試", "TMDB_CREDITS": "中繼資料", "TMDB_ATTRIBUTION": "本產品使用 TMDB API,但未經 TMDB 認可或認證。", "APP_PREFERENCES": "應用程式偏好設定", diff --git a/docs/architecture/dependency-security-overrides.md b/docs/architecture/dependency-security-overrides.md new file mode 100644 index 000000000..be525ed63 --- /dev/null +++ b/docs/architecture/dependency-security-overrides.md @@ -0,0 +1,74 @@ +# Dependency Security Overrides + +How transitive CVEs are patched in this repo, and the constraint that makes +"just bump to latest" the wrong move. + +## Why overrides exist + +Dependabot can only bump packages we declare ourselves. When the vulnerable +package is transitive — pulled in by `video.js`, `electron-updater`, +`electron-conf`, `axios` — the bot has no lever: it would have to wait for the +parent to publish a release that widens its own pin. Until then the alert stays +open regardless of how many bot PRs land. + +`pnpm.overrides` in the root `package.json` is that lever. Every entry uses the +pinned-source form so an override only rewrites the exact resolution it was +written for, and goes stale visibly instead of silently re-targeting a future +version: + +```json +"@xmldom/xmldom@0.8.11": "0.8.13" +``` + +## The semver ceiling + +**An override must stay inside the range its parent declares.** pnpm applies +overrides without re-checking the parent's range, so an out-of-range target +installs cleanly and then fails at runtime or under load, not at install time. + +For three of the five current security overrides, the newest published version +is _outside_ the parent's range. Taking "latest" would break them: + +| Override | Pinned to | Parent range | Latest on npm | +| ---------------- | --------- | --------------------------------------- | ------------- | +| `@xmldom/xmldom` | 0.8.13 | `mpd-parser` `^0.8.3`, `plist` `^0.8.8` | 0.9.x ❌ | +| `fast-uri` | 3.1.4 | `ajv` `^3.0.1` | 4.x ❌ | +| `js-yaml` | 4.3.0 | `electron-updater` `^4.1.0` | 5.x ❌ | +| `form-data` | 4.0.6 | `axios` `^4.0.5` | 4.0.6 ✅ | +| `ajv` | 8.18.0 | `electron-conf` `^8.13.0` | 8.20.0 ✅ | + +Before changing any of these, check the parent's declared range first: + +```bash +npm view @ dependencies --json +``` + +## Verifying an override actually applied + +Grepping `pnpm-lock.yaml` for the old version still finds it, but that hit is +not a leftover package block — pnpm removes those once nothing resolves to them. +It is the override's own selector key, echoed in the `overrides:` block at the +top of the lockfile: + +```yaml +overrides: + '@xmldom/xmldom@0.8.11': 0.8.13 +``` + +So a bare grep proves only that the override is declared, never that it took +effect. Resolve the real path on disk instead: + +```bash +node -e "console.log(require('./node_modules/.pnpm/mpd-parser@1.3.1/node_modules/@xmldom/xmldom/package.json').version)" +``` + +## What is deliberately not overridden + +`undici` carries open alerts flagged `runtime` scope, but every path to it is +build tooling — `electron` → `@electron/get`, `@angular/build`, and +`@module-federation/dts-plugin`. It is not in the packaged app. The `runtime` +label is a Dependabot classification artifact, not a shipped-code claim. Bumping +it inside the Angular/Nx toolchain risks the build for no runtime benefit. + +When triaging, confirm scope from the dependency graph rather than trusting the +alert's `scope` field. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 08c5dd84e..48a4a468b 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -62,7 +62,16 @@ Two paths re-download an M3U playlist from its original source: - **Explicit refresh** — `PLAYLIST_REFRESH` runs in `playlist-refresh.worker.ts`, reports progress through `PLAYLIST_REFRESH_EVENT`, and is cancellable via - `PLAYLIST_CANCEL_REFRESH`. + `PLAYLIST_CANCEL_REFRESH`. Cancellation is owned by the main process: it first + sends the cooperative cancel message, then terminates the one-shot worker + without waiting for its event loop. The cancel IPC resolves only after the + worker has stopped, the correlated `cancelled` event has been emitted with the + last known phase, and a structured cancellation result is ready. That result + crosses both Electron IPC and the context bridge unchanged; + `PlaylistRefreshService` converts it into a renderer-local `AbortError`. + Relying on an error created in main or preload would lose its `name` at one of + those serialization boundaries. A cancelled refresh must not update the + renderer store or reach SQLite. - **Startup auto-update** — after `loadPlaylistsSuccess`, `AppComponent` sends `AUTO_UPDATE_PLAYLISTS` for every playlist with `autoRefresh === true`. The main process fulfils it in `playlist-auto-update.ts` on top of `playlist-source.ts`. @@ -87,6 +96,37 @@ dead source must never stall startup (issue #931): so refresh logging goes through `redactSensitiveData()` from `@iptvnator/shared/logging`. +### Refresh Cancellation Performance Regression + +The Electron E2E project includes a deterministic 100,000-channel cancellation +benchmark. It uses only a loopback synthetic M3U server, performs one warm-up, +five measured runs, and one diagnostic run, and writes summaries plus raw +profiles below the gitignored `dist/performance/` directory: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-refresh-cancel" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=after \ +pnpm nx run electron-backend-e2e:benchmark-m3u-refresh-cancellation +``` + +The output path must be an absolute, previously unused descendant of +`dist/performance/`. A formal run fails on a dirty worktree and records the +commit, source-state hash, OS/architecture, Node, Electron, and fixture identity +in its manifest. Commit the harness first and capture `baseline` from that clean +commit; commit the production change separately, rebuild, and capture `after` +with the same harness and machine. Set `IPTVNATOR_PERF_SMOKE=1` for one measured +run during harness development; smoke runs may be dirty and must not support +before/after claims. + +The target reserves and verifies CDP port 9222, freezes renderer long-task, +frame-gap, and heartbeat probes before forced post-GC heap collection, and +enables opt-in worker profiling. Worker event-loop delay is read from a +request-scoped `node:perf_hooks` capture; a worker terminated before it can flush +the capture reports the metric as unavailable rather than zero. Diagnostic CPU +profiles, heap snapshots, and Chromium traces are excluded from the five-run +headline distributions. + ### Reporting The Auto-Update Result Because auto-update isolates failures, it must also report them — otherwise a @@ -152,7 +192,7 @@ used by the groups view to remember which group titles the user has hidden. | **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD | | **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation | | **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state | -| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management | +| **FavoritesActions** | `updateFavorites`, `setFavorites`, `hydrateFavorites` | Favorites management | | **FilterActions** | `setSelectedFilters` | Playlist type filtering | ### Key Selectors @@ -250,6 +290,10 @@ channel-list-container/ - `M3uWorkspaceRouteSession` owns route-driven channel loading for the player/sidebar routes: `all` and `groups`. - The route session sets `channelsLoading` before `getPlaylist()` resolves and clears it when `ChannelActions.setChannels` lands. +- The route session dispatches reducer-only `FavoritesActions.hydrateFavorites` + after that persisted read. Hydration must not use the persistence-bearing + `setFavorites` action: doing so reads and rewrites the complete M3U payload + again just to store favorites that already came from SQLite. - `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true. - `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation. - The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope. @@ -897,4 +941,6 @@ Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts 1. Dispatch `FavoritesActions.updateFavorites` for toggle 2. Dispatch `FavoritesActions.setFavorites` for reordering -3. Effects automatically persist to database +3. Dispatch `FavoritesActions.hydrateFavorites` only when copying values that + were already read from persistence into NgRx +4. Effects persist the two user-mutation actions; hydration is reducer-only diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 19468c508..9a8778b07 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -335,6 +335,44 @@ contract. It uses DOM/media events and accepts optional engine-specific track accessors through `WebVideoControlsOptions`, so the adapter itself stays usable in the PWA and does not import a concrete web engine. +### Caption preference in both modes + +The `Settings.showCaptions` preference is **not** part of the rollout gate. It +is engine state, not controls UI, so HTML5, Video.js, and ArtPlayer apply it +whether or not their host snapshot enables `WEB_PLAYER_SHARED_CONTROLS`. Shared +controls route it through their controls bridge; the preference-off paths use +the same helpers without an adapter — `WebVideoSourceTracks` for HTML5 and +ArtPlayer, `VjsLegacyTracks` for Video.js. Both apply the preference when a +source binds and re-apply it as the engine adds or switches text tracks, which +a one-shot check at playback start could not do (#1155). + +The two modes differ in **how long** the preference stays enforced, because +they differ in who owns the caption UI: + +- **Shared controls: authoritative.** The preference holds for the whole + session; user intent arrives through `setSubtitleTrack`, which records an + explicit override (including `-1` for off) that wins until the source changes. +- **Vendor chrome: source-default.** The engine still renders its own caption + menu, so the preference only seeds each new source and is released once the + media element reports `playing`. Enforcing it for the whole session would + make that menu inert. + +The mode is selected by passing a `playbackStarted` probe to the track helpers; +shared controls omit it. All three helpers take it — HLS, native text tracks, +and Shaka. For DASH the seed happens inside `ShakaVideoSession.start()` once the +manifest is loaded, so the helper only has to stop re-suppressing afterwards. `WebVideoSourceTracks` owns the probe for HTML5 and +ArtPlayer (a `playing` listener on the media element, reset on every +`setSource`); `VjsLegacyTracks` owns it for Video.js (the player's own `playing` +event, reset on every `clear`). In source-default mode the HLS helper also +_deselects_ the track (`subtitleTrack = -1`) instead of hiding it: hls.js +applies `subtitleDisplay` to whatever the vendor menu picks, so suppressing +display would silently override the user, and a `-1` assignment additionally +clears hls.js' own default-track selection so it cannot reselect one later. + +`WebPlayerViewComponent` reads the preference from `SettingsStore` rather than +from a host input, so every host — the M3U player, Xtream and Stalker live +layouts, and the portal detail inline player — gets it without wiring. + ### Standard element picture-in-picture Picture-in-picture is part of the existing default-off shared web-controls @@ -602,12 +640,16 @@ The neutral web-video source support shared by HTML5 and ArtPlayer lives in: libs/ui/playback/src/lib/web-video-support/ ├── web-video-hls-controls.ts ├── web-video-native-text-tracks.ts +├── web-video-source-tracks.ts └── web-video-source-controls.bridge.ts ``` -The bridge owns source-local HLS/native track projection, caption preference -and explicit subtitle-off state, MPEG-TS VOD duration correction, adapter -refresh, and exact track-list listener cleanup. +`WebVideoSourceTracks` owns source-local HLS/Shaka/native track projection, +caption preference and explicit subtitle-off state, and exact track-list +listener cleanup. It has no controls dependency, so the preference-off players +use it directly. `WebVideoSourceControlsBridge` wraps it for shared controls +and adds adapter attach/detach, adapter refresh, and MPEG-TS VOD duration +correction. The guarded HTML5 integration lives in: @@ -625,14 +667,16 @@ libs/ui/playback/src/lib/html-video-player/ `WebVideoControlsAdapter`. Its bridge/helper filenames re-export the neutral web-video support so existing imports and focused specs remain stable. `HtmlVideoElementSession` separately owns native video-event attachment, -persisted volume, start-time/time/ended propagation, and the preference-off -post-play caption behavior. +persisted volume, and start-time/time/ended propagation. Captions are not its +concern in either mode: the component binds `WebVideoSourceTracks` alongside +its controls bridge and feeds both the same active source. The guarded Video.js integration lives in: ```text libs/ui/playback/src/lib/vjs-player/ ├── vjs-audio-tracks.ts +├── vjs-legacy-tracks.ts ├── vjs-mpegts-session.ts ├── vjs-player-controls.bridge.ts ├── vjs-player-reset-coordinator.ts diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 652e6eef6..7de3399bf 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -90,29 +90,29 @@ All responses follow the Stalker `portal.php` envelope: ```json { - "js": { - "data": [ - { - "id": "20001", - "name": "...", - "cmd": "ffrt4://vod/20001/index.m3u8", - "screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200", - "cover": "https://picsum.photos/seed/vod-cover-20001/300/450", - "description": "...", - "actors": "...", - "director": "...", - "year": "2019", - "rating_imdb": "7.3", - "category_id": "2001", - "is_series": 0, - "has_files": 1 - } - ], - "total_items": 40, - "max_page_items": 14, - "cur_page": 1, - "total_pages": 3 - } + "js": { + "data": [ + { + "id": "20001", + "name": "...", + "cmd": "ffrt4://vod/20001/index.m3u8", + "screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200", + "cover": "https://picsum.photos/seed/vod-cover-20001/300/450", + "description": "...", + "actors": "...", + "director": "...", + "year": "2019", + "rating_imdb": "7.3", + "category_id": "2001", + "is_series": 0, + "has_files": 1 + } + ], + "total_items": 40, + "max_page_items": 14, + "cur_page": 1, + "total_pages": 3 + } } ``` @@ -144,12 +144,12 @@ marker and an `ffrt4://radio/...` command. ```json { - "js": { - "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", - "streamer_id": "1", - "load": "", - "error": "" - } + "js": { + "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", + "streamer_id": "1", + "load": "", + "error": "" + } } ``` @@ -159,20 +159,20 @@ The stream URL is selected from a pool of 4 real public HLS test streams. The ch ```json { - "js": { - "data": [ - { - "id": "1", - "name": "Channel Name: Program Title", - "start": "2026-02-21T10:00:00.000Z", - "stop": "2026-02-21T12:00:00.000Z", - "start_timestamp": 1740128400, - "stop_timestamp": 1740135600, - "descr": "...", - "category": "News" - } - ] - } + "js": { + "data": [ + { + "id": "1", + "name": "Channel Name: Program Title", + "start": "2026-02-21T10:00:00.000Z", + "stop": "2026-02-21T12:00:00.000Z", + "start_timestamp": 1740128400, + "stop_timestamp": 1740135600, + "descr": "...", + "category": "News" + } + ] + } } ``` @@ -183,22 +183,22 @@ generated schedule, limited by the requested `size`. ```json { - "js": { - "data": { - "10000": [ - { - "id": "1", - "name": "Channel Name: Program Title", - "start": "2026-02-21T10:00:00.000Z", - "stop": "2026-02-21T12:00:00.000Z", - "start_timestamp": 1740128400, - "stop_timestamp": 1740135600, - "descr": "...", - "category": "News" + "js": { + "data": { + "10000": [ + { + "id": "1", + "name": "Channel Name: Program Title", + "start": "2026-02-21T10:00:00.000Z", + "stop": "2026-02-21T12:00:00.000Z", + "start_timestamp": 1740128400, + "stop_timestamp": 1740135600, + "descr": "...", + "category": "News" + } + ] } - ] } - } } ``` @@ -214,18 +214,18 @@ Scenarios are defined in `src/app/scenarios.ts`. Each scenario is a `ScenarioCon ```typescript interface ScenarioConfig { - name: string; - description: string; - seed: number; - categoryCount: { itv: number; radio: number; vod: number; series: number }; - itemsPerCategory: number; - seasonsPerSeries: number; - episodesPerSeason: number; - isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1 - embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[] - supportsGetAllChannels?: boolean; // default true; false mimics legacy portals - // without the ITV get_all_channels action - marketingFixture?: true; // replace generated VOD with shared posters + name: string; + description: string; + seed: number; + categoryCount: { itv: number; radio: number; vod: number; series: number }; + itemsPerCategory: number; + seasonsPerSeries: number; + episodesPerSeason: number; + isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1 + embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[] + supportsGetAllChannels?: boolean; // default true; false mimics legacy portals + // without the ITV get_all_channels action + marketingFixture?: true; // replace generated VOD with shared posters } ``` @@ -240,9 +240,11 @@ supporting scenarios, `get_all_channels` (`get-all-channels.handler.ts`, The `marketing-demo` scenario (`00:1A:79:00:00:07`) replaces faker-generated VOD with the 35-movie provider-neutral showcase catalog from `@iptvnator/shared/marketing-fixtures`. Its newest 20 movies are returned first -for the wildcard VOD listing. Poster URLs point back to the Stalker process at -`/assets/marketing/poster/.png`; `main.ts` serves the committed PNG -directory directly, so the Xtream server does not need to be running. +for the wildcard VOD listing. Fixtures keep deployment-neutral poster paths, +then the JSON middleware resolves them against the request origin (including +forwarded host/protocol) as +`/assets/marketing/poster/.png`. `main.ts` serves the +committed PNG directory directly, so the Xtream server does not need to run. ### Adding a New Scenario @@ -262,17 +264,17 @@ Call `POST /reset` to clear all favorites (and regenerated data) between test ru ```typescript webServer: [ - { - command: 'pnpm nx run web:serve', - url: 'http://localhost:4200', - reuseExistingServer: !process.env['CI'], - }, - { - command: 'pnpm nx run stalker-mock-server:serve', - url: 'http://localhost:3210/health', - reuseExistingServer: !process.env['CI'], - }, -] + { + command: 'pnpm nx run web:serve', + url: 'http://localhost:4200', + reuseExistingServer: !process.env['CI'], + }, + { + command: 'pnpm nx run stalker-mock-server:serve', + url: 'http://localhost:3210/health', + reuseExistingServer: !process.env['CI'], + }, +]; ``` Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance. @@ -292,13 +294,13 @@ const MOCK_URL = 'http://localhost:3210/portal.php'; const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario test.beforeEach(async ({ request }) => { - await request.post('http://localhost:3210/reset'); + await request.post('http://localhost:3210/reset'); }); test('browse VOD categories', async ({ page }) => { - // Add portal via UI or programmatically via IndexedDB - // Navigate to portal - // Assert category list matches expected count (8 for default scenario) + // Add portal via UI or programmatically via IndexedDB + // Navigate to portal + // Assert category list matches expected count (8 for default scenario) }); ``` diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index e78dd4bd9..146bfa063 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -44,6 +44,8 @@ store imports): | `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | | `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | | `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | +| `tmdb-credits.ts` | People out of credit payloads: display cast, person chips, and the two-shape union a series cast needs | +| `tmdb-cache-payload.ts` | Trims a details payload before caching (aggregate roles/crew) without changing what a merge over it produces | | `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | | `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | | `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | @@ -75,16 +77,44 @@ also show a production-status chip (`tmdb_status`): TMDB returns `status` as an ENGLISH string regardless of the request language, so it is normalized to a token (`normalizeSeriesStatus`) and rendered through translated labels (`seriesStatusLabelKey`) — the raw value never reaches -the UI. Unknown values are dropped rather than displayed. A "check key" button -in the settings section validates the API key against `/configuration`. +the UI. Unknown values are dropped rather than displayed. A "check key" +button in the settings section validates the API key against +`/configuration`, and a cache panel beside it shows the stored row count +plus payload size and can drop the lot (`DB_GET_TMDB_CACHE_STATS` / +`DB_CLEAR_TMDB_METADATA`, in-memory map in the PWA). Sizing is a full +scan, so it only runs once the TMDB section is the active one. Clearing +costs nothing but the next few requests — enrichment refetches on demand +— and it is also the escape hatch when a lookup-key version bump orphans +rows, since a bump makes them unreachable rather than deleting them. ## Match Confidence Wrong metadata is worse than no metadata, so id resolution is conservative: 1. If the provider returns a usable `tmdb_id` (Xtream VOD info often does), - it is trusted fully and no search runs. Series have no show-level - `tmdb_id`, so they always go through search. + its details are fetched directly and normally used as-is. Series have no + show-level `tmdb_id`, so they always go through search. The id is a + strong hint rather than gospel — panels ship dead and stale ones — so + the payload it returns is weighed against the provider item + (`assessProviderId`): + - a matching title **or** a compatible release year (±1; for series, + any earlier premiere) → **corroborated**, use it; + - both years known and incompatible → **contradicted**, the stale-id + signature ("Blade Runner 2049" carrying the 1982 film's id): the + title search may take over, and does so only if it finds a confident + match of its own; + - title differs with no year to arbitrate → **inconclusive**, keep the + details. TMDB returns titles in the request language and + normalization strips stylized prefixes ("IT - Chapter Two" → + "chapter two"), so a name mismatch alone says more about our inputs + than about the id. + + A 404 is the one hard verdict: the id is recorded as dead + (`badProviderId:` row), skipped next time, and the title search + takes over. Transient failures (auth, rate limit, 5xx, offline) neither + disable the id nor trigger a search — that request would hit the same + outage, and a title match that did come back would be weaker evidence + than the id already in hand. 2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized title. Normalization strips bracketed tags, quality markers (`4K`, `1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics, @@ -121,7 +151,19 @@ are still fetched in the app language afterwards. Details are fetched with `/movie/{id}?append_to_response=credits,videos,recommendations` (`/tv/{id}` -for series). Credits provide cast/director; videos supply the best YouTube +for series). Credits provide cast/director — for series the request also +appends `aggregate_credits`, because TMDB documents a TV id's `credits` as +the **latest season's** credits. What `aggregate_credits` covers is +described in one self-contradicting sentence — "it does not return the +newest season. Instead, it is a view of all the entire cast & crew for all +episodes belonging to a TV show" — so it is either the whole run minus the +newest season or the whole run. `unifiedTvCast` is written not to care: +it unions the two by set difference (whole-run billing order first, then +anyone `credits` has that the aggregate lacks), which under the second +reading appends nothing. Either way, long-running shows neither lose +departed regulars nor miss new ones. Payloads cached +before this landed have no `aggregate_credits` and fall back to the old +behaviour until they are refetched; videos supply the best YouTube trailer (official trailer > trailer > teaser, merged into `youtube_trailer` / `tmdb_trailer`); recommendations power the "Similar" rail. In Xtream detail views the rail shows only recommendations that @@ -247,14 +289,18 @@ Filmography has two scopes: ## Cache -Single table with two row kinds discriminated by `lookup_key` prefix: +Single table with several row kinds discriminated by the `lookup_key` +prefix: ``` tmdb_metadata ( media_type 'movie' | 'tv' | 'person', lookup_key 'id:|v2' -- details payload row + 'id:|season:' -- season payload row 'title:|year:|v2' -- search resolution row 'person:' -- person payload row + 'trending:week' -- trending list row + 'badProviderId:' -- id confirmed 404 by TMDB language TEXT, -- TMDB language code tmdb_id INTEGER, -- NULL on a search row = negative cache payload TEXT, -- raw JSON details, NULL for search rows @@ -278,12 +324,31 @@ person cache rows are unaffected. Electron IPC path (follows the standard DB worker contract, see [SQLite DB Worker](./sqlite-db-worker.md)): -- Worker ops: `DB_GET_TMDB_METADATA`, `DB_SET_TMDB_METADATA` +- Worker ops: `DB_GET_TMDB_METADATA`, `DB_SET_TMDB_METADATA`, plus the two + maintenance ops behind the settings cache panel, + `DB_GET_TMDB_CACHE_STATS` and `DB_CLEAR_TMDB_METADATA` (`database-worker.types.ts`, `database.worker.ts`, `operations/tmdb.operations.ts`) - IPC registration: `events/database/tmdb.events.ts` -- Preload bridge: `dbGetTmdbMetadata` / `dbSetTmdbMetadata` on - `window.electron` (typed in `ElectronBridgeApi`) +- Preload bridge: `dbGetTmdbMetadata` / `dbSetTmdbMetadata` / + `dbGetTmdbCacheStats` / `dbClearTmdbMetadata` on `window.electron` (typed + in `ElectronBridgeApi`) + +`TmdbCacheService` treats the maintenance pair as optional on the bridge: an +Electron shell that predates them reports `null` (unsupported) rather than +falling back to the renderer map, which is always empty in Electron and +would claim the SQLite cache is empty. A `clear()` first awaits the writes +already in flight so they land and are deleted with everything else, and +holds the clear promise for its duration so a write starting meanwhile +queues behind the delete instead of racing it. Rows written after the user +cleared are deliberately kept. + +The panel's full path — settings button, preload bridge, DB worker, real +SQLite — is covered by `@settings @electron @persistence sizes and clears +the TMDB metadata cache` in `apps/electron-backend-e2e/src/settings.e2e.ts`. +It seeds a row through `dbSetTmdbMetadata` rather than through enrichment, +which needs an API key that builds outside the release pipeline do not +carry. The PWA uses a session-scoped in-memory map (acceptable for phase 1; TMDB supports CORS so the PWA calls the API directly). @@ -292,7 +357,12 @@ supports CORS so the PWA calls the API directly). `Settings.tmdb?: { enabled: boolean; apiKey?: string }` (`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has -a "Metadata (TMDB)" section (enable toggle + optional API key override). +a "Metadata (TMDB)" section: enable toggle, optional API key override with a +"check key" button (validates against `/configuration`), and a cache panel +showing the stored row count plus payload size with a button that drops the +lot. Sizing is a full table scan, so it runs only once that section is the +active one, and a failed read or clear says so instead of showing an empty +cache. The embedded default key lives in `DEFAULT_TMDB_API_KEY` (`libs/services/src/lib/tmdb/tmdb-config.ts`) and is an **empty placeholder @@ -343,3 +413,18 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache heroes additionally show the tracked "S{n}·E{n}" badge from the playback position (no TMDB involved); the watch-progress bar is limited to movie/series heroes. + +### `badProviderId:` rows + +Providers ship `tmdb_id` values that do not exist. A failed details fetch +caches nothing, so without a marker the same 404 is re-issued on every +detail open, forever. These rows record that verdict: `tmdb_id` NULL, +language `any` (a dead id is dead in every language), read with the +negative-match TTL. + +Only a **confirmed 404** is recorded. Transient failures (401, 429, 5xx, +offline) leave no marker — they say nothing about the id. Neither does a +title mismatch: that id exists and may be correct for a *different* item, +and since the row is keyed by id alone and shared across playlists, +recording per-item mismatches here would deny the direct lookup to every +other item that legitimately uses the same id. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 5a0b6c9c0..c0f4ffbb3 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -255,10 +255,25 @@ handlers in `apps/electron-backend/src/app/events/window.events.ts`): WebContents. Close goes through `win.close()` so the existing window-bounds persistence in `app.ts` still runs. 2. `WINDOW:STATE_CHANGED` is pushed main → renderer on - maximize/unmaximize/enter-full-screen/leave-full-screen so the - maximize/restore glyph stays correct for externally triggered changes - (double-click on a drag region, OS snap, F11). The controls hide - themselves while the window is fullscreen. + maximize/unmaximize and on the fullscreen events — + `enter/leave-full-screen` plus the `enter/leave-html-full-screen` + variants emitted for HTML-element fullscreen (video player + fullscreen) — so the maximize/restore glyph stays correct for + externally triggered changes (double-click on a drag region, OS + snap, F11). The controls hide themselves while the window is + fullscreen. + + Window state is **never re-read at event time**. `attachWindowStateEvents` + seeds `{ isMaximized, isFullScreen }` once at window creation and each + event patches only the flag it names; every push carries a copy of that + tracked state. On Windows both getters can still report the + pre-transition value while the matching event fires — `isFullScreen()` + stays `true` during an HTML fullscreen exit, and `isMaximized()` reads + `false` while the window is fullscreen. Because the renderer replaces + both flags on every push and no later event corrects a stale one, + polling left the controls hidden forever after leaving fullscreen and + stuck the maximize/restore glyph on the wrong icon. Regression coverage: + `app-window-state.spec.ts` and `window-controls.e2e.ts`. Layout integration: diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 49519db85..6a96fd7b0 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -290,8 +290,9 @@ This scenario is reserved for release screenshots and marketing materials: including documentary, noir, animated family, retro rescue drama, cyberpunk, anime-inspired space school, and workplace dramedy styles - screenshot capture remains deterministic and offline because - `tools/release/capture-v020-screenshots.ts` consumes the local mock server - assets and never calls OpenAI + `tools/release/capture-release-screenshots.ts` consumes the local mock server + assets and never calls OpenAI; its guards additionally verify that the + service answering on the mock port really is this fixture server - the SVG renderer in `marketing.generator.ts` remains the fallback for missing assets, live logos, season covers, and episode thumbnails diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index d2ea2baee..9fcdaf2ca 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -71,6 +71,7 @@ export const FavoritesActions = createActionGroup({ events: { 'Update Favorites': props<{ channel: Channel }>(), 'Set Favorites': props<{ channelIds: string[] }>(), + 'Hydrate Favorites': props<{ channelIds: string[] }>(), }, }); diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts new file mode 100644 index 000000000..25d781018 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts @@ -0,0 +1,36 @@ +import { createReducer } from '@ngrx/store'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { FavoritesActions } from '../actions'; +import { playlistsAdapter } from '../playlists.state'; +import { initialState } from '../state'; +import { favoritesReducers } from './favorites.reducers'; + +const reducer = createReducer(initialState, ...favoritesReducers); + +describe('favoritesReducers', () => { + it('hydrates persisted favorites into state without using the persistence action', () => { + const playlist = { + _id: 'playlist-1', + title: 'Synthetic playlist', + favorites: ['stale-channel'], + } as PlaylistMeta; + const state = { + ...initialState, + playlists: playlistsAdapter.addOne(playlist, { + ...initialState.playlists, + selectedId: playlist._id, + }), + }; + + const nextState = reducer( + state, + FavoritesActions.hydrateFavorites({ + channelIds: ['persisted-channel'], + }) + ); + + expect(nextState.playlists.entities[playlist._id]?.favorites).toEqual([ + 'persisted-channel', + ]); + }); +}); diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts index 4e83e0947..12bb4a383 100644 --- a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts @@ -32,26 +32,30 @@ export const favoritesReducers = [ }, }; }), - on(FavoritesActions.setFavorites, (state, action): PlaylistState => { - const selectedId = state.playlists.selectedId; - const playlist = state.playlists.entities[selectedId]; - if (!selectedId || !playlist) { - return state; - } + on( + FavoritesActions.setFavorites, + FavoritesActions.hydrateFavorites, + (state, action): PlaylistState => { + const selectedId = state.playlists.selectedId; + const playlist = state.playlists.entities[selectedId]; + if (!selectedId || !playlist) { + return state; + } - const { channelIds } = action; - return { - ...state, - playlists: { - ...state.playlists, - entities: { - ...state.playlists.entities, - [selectedId]: { - ...playlist, - favorites: channelIds, + const { channelIds } = action; + return { + ...state, + playlists: { + ...state.playlists, + entities: { + ...state.playlists.entities, + [selectedId]: { + ...playlist, + favorites: channelIds, + }, }, }, - }, - }; - }), + }; + } + ), ]; diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts index cee66647b..4581781d4 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { of, Subject } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; @@ -180,6 +180,52 @@ describe('M3uWorkspaceRouteSession', () => { ); }); + it('hydrates persisted favorites without dispatching the persistence action', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + favorites: [PRIMARY_CHANNEL.url], + playlist: { + items: [PRIMARY_CHANNEL], + }, + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(store.dispatch).toHaveBeenCalledWith( + FavoritesActions.hydrateFavorites({ + channelIds: [PRIMARY_CHANNEL.url], + }) + ); + expect( + store.dispatch.mock.calls.some( + ([action]) => action.type === FavoritesActions.setFavorites.type + ) + ).toBe(false); + }); + + it('clears in-memory favorites without dispatching persistence when playlist loading fails', async () => { + playlistsService.getPlaylist.mockReturnValue( + throwError(() => new Error('Playlist loading failed')) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannels({ channels: [] }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + FavoritesActions.hydrateFavorites({ channelIds: [] }) + ); + expect( + store.dispatch.mock.calls.some( + ([action]) => action.type === FavoritesActions.setFavorites.type + ) + ).toBe(false); + }); + it('ignores stale playlist responses after a newer route request wins', async () => { const firstResponse = new Subject(); const secondResponse = new Subject(); @@ -225,7 +271,7 @@ describe('M3uWorkspaceRouteSession', () => { [ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })], ]); expect(store.dispatch).toHaveBeenCalledWith( - FavoritesActions.setFavorites({ + FavoritesActions.hydrateFavorites({ channelIds: [NEXT_CHANNEL.url], }) ); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 09d8ed28b..0db0dcd87 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -117,7 +117,7 @@ export class M3uWorkspaceRouteSession { (favorite): favorite is string => typeof favorite === 'string' ); this.store.dispatch( - FavoritesActions.setFavorites({ + FavoritesActions.hydrateFavorites({ channelIds: favorites, }) ); @@ -128,7 +128,7 @@ export class M3uWorkspaceRouteSession { this.store.dispatch(ChannelActions.setChannels({ channels: [] })); this.store.dispatch( - FavoritesActions.setFavorites({ channelIds: [] }) + FavoritesActions.hydrateFavorites({ channelIds: [] }) ); } } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e529618be..bd30b8c03 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -82,7 +82,6 @@ : (playerSettings.player ?? null) " [volume]="volume()" - [showCaptions]="!!playerSettings.showCaptions" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index b4879d433..5279ae96a 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -117,7 +117,6 @@ class StubWebPlayerViewComponent { readonly playback = input(null); readonly playerOverride = input(null); readonly volume = input(1); - readonly showCaptions = input(false); readonly externalFallbackRequested = output(); } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index a887f4a39..dfeb98601 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -367,7 +367,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Selected video player options */ playerSettings: Partial = { player: VideoPlayer.VideoJs, - showCaptions: false, }; readonly isDesktop = this.runtime.isElectron; @@ -403,7 +402,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { effect(() => { this.playerSettings = { player: this.settingsStore.player(), - showCaptions: this.settingsStore.showCaptions(), }; }); @@ -739,7 +737,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.playerSettings = { player: (settings as Settings).player || VideoPlayer.VideoJs, - showCaptions: (settings as Settings).showCaptions || false, }; } }); diff --git a/libs/services/src/lib/playlist-refresh.service.spec.ts b/libs/services/src/lib/playlist-refresh.service.spec.ts new file mode 100644 index 000000000..920db2940 --- /dev/null +++ b/libs/services/src/lib/playlist-refresh.service.spec.ts @@ -0,0 +1,49 @@ +import type { + ElectronBridgeApi, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; + +import { PlaylistRefreshService } from './playlist-refresh.service'; + +describe('PlaylistRefreshService', () => { + const originalElectron = window.electron; + const payload: PlaylistRefreshPayload = { + operationId: 'playlist-refresh-cancelled', + playlistId: 'playlist-1', + title: 'Large playlist', + url: 'http://127.0.0.1/large.m3u', + }; + + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + writable: true, + }); + }); + + it('creates a renderer-local AbortError from a cancellation result', async () => { + const unsubscribe = jest.fn(); + const electron = { + onPlaylistRefreshEvent: jest.fn(() => unsubscribe), + refreshPlaylist: jest.fn().mockResolvedValue({ + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + }), + } as unknown as ElectronBridgeApi; + Object.defineProperty(window, 'electron', { + configurable: true, + value: electron, + writable: true, + }); + + await expect( + new PlaylistRefreshService().refreshPlaylist(payload) + ).rejects.toMatchObject({ + message: + 'Playlist refresh "playlist-refresh-cancelled" was cancelled', + name: 'AbortError', + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/services/src/lib/playlist-refresh.service.ts b/libs/services/src/lib/playlist-refresh.service.ts index d4ab49d79..f73f36ab7 100644 --- a/libs/services/src/lib/playlist-refresh.service.ts +++ b/libs/services/src/lib/playlist-refresh.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { + isPlaylistRefreshCancelledResult, Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, @@ -30,7 +31,15 @@ export class PlaylistRefreshService { }); try { - return await window.electron.refreshPlaylist(payload); + const result = await window.electron.refreshPlaylist(payload); + if (isPlaylistRefreshCancelledResult(result)) { + const error = new Error( + `Playlist refresh "${result.operationId}" was cancelled` + ); + error.name = 'AbortError'; + throw error; + } + return result; } finally { unsubscribe?.(); } diff --git a/libs/services/src/lib/tmdb/index.ts b/libs/services/src/lib/tmdb/index.ts index cd28e0596..0b94b3c1f 100644 --- a/libs/services/src/lib/tmdb/index.ts +++ b/libs/services/src/lib/tmdb/index.ts @@ -2,6 +2,7 @@ export * from './tmdb-api.service'; export * from './tmdb-cache.service'; export * from './tmdb-config'; export * from './tmdb-enrichment.service'; +export * from './tmdb-id-resolver.service'; export * from './tmdb-episode-merge'; export * from './tmdb-matcher'; export * from './tmdb-merge'; diff --git a/libs/services/src/lib/tmdb/tmdb-api.service.ts b/libs/services/src/lib/tmdb/tmdb-api.service.ts index 4d7144591..b2b6555b0 100644 --- a/libs/services/src/lib/tmdb/tmdb-api.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-api.service.ts @@ -14,6 +14,25 @@ import { * both the Electron renderer and the PWA can call it without a proxy. * Supports classic v3 keys (query param) and v4 read tokens (Bearer). */ +/** + * A non-2xx TMDB response. Carries the status so callers can tell a + * definitive verdict (404 — this id does not exist) apart from a + * transient one (401/429/5xx, offline), which must stay retryable. + */ +export class TmdbApiError extends Error { + constructor( + readonly status: number, + statusText = '' + ) { + super(`TMDB request failed: ${status} ${statusText}`.trim()); + this.name = 'TmdbApiError'; + } +} + +export function isTmdbNotFound(error: unknown): boolean { + return error instanceof TmdbApiError && error.status === 404; +} + @Injectable({ providedIn: 'root' }) export class TmdbApiService { async searchMovie( @@ -71,7 +90,14 @@ export class TmdbApiService { ): Promise { return this.request( `/tv/${tmdbId}`, - { language, append_to_response: 'credits,videos,recommendations' }, + { + language, + // aggregate_credits spans the whole run; plain `credits` on + // a TV id is documented as the LATEST SEASON only, so both + // are needed to reconstruct the real cast (see tmdb-merge) + append_to_response: + 'credits,aggregate_credits,videos,recommendations', + }, apiKey ); } @@ -155,9 +181,7 @@ export class TmdbApiService { }); if (!response.ok) { - throw new Error( - `TMDB request failed: ${response.status} ${response.statusText}` - ); + throw new TmdbApiError(response.status, response.statusText); } return (await response.json()) as T; diff --git a/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts new file mode 100644 index 000000000..d96cfdd63 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts @@ -0,0 +1,175 @@ +import { mergeSerieInfoWithTmdb } from './tmdb-merge'; +import { trimDetailsForCache } from './tmdb-cache-payload'; +import { XtreamSerieInfo } from '@iptvnator/shared/interfaces'; +import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types'; + +describe('trimDetailsForCache', () => { + it('passes a movie payload through untouched', () => { + const movie: TmdbMovieDetails = { + id: 603, + title: 'The Matrix', + credits: { cast: [{ name: 'Keanu Reeves' }] }, + }; + + expect(trimDetailsForCache(movie)).toBe(movie); + }); + + it('keeps every aggregate cast member', () => { + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: Array.from({ length: 120 }, (_, i) => ({ + id: i, + name: `Actor ${i}`, + order: i, + })), + }, + }; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails; + + // The tail is what tells a returning actor from a new arrival + expect(trimmed.aggregate_credits?.cast).toHaveLength(120); + }); + + it('reduces roles to the character with the most episodes', () => { + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + roles: [ + { character: 'Cameo Guy', episode_count: 1 }, + { character: 'Billy Butcher', episode_count: 32 }, + ], + }, + ], + }, + }; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails; + + expect(trimmed.aggregate_credits?.cast?.[0].roles).toEqual([ + { character: 'Billy Butcher', episode_count: 32 }, + ]); + }); + + it('keeps the named role even when a blank one has more episodes', () => { + // TMDB uses unnamed roles for uncredited appearances; the merge + // skips them, so the trim must not cache one in their place + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + roles: [ + { character: '', episode_count: 40 }, + { character: 'Billy Butcher', episode_count: 32 }, + ], + }, + ], + }, + }; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails; + + expect(trimmed.aggregate_credits?.cast?.[0].roles).toEqual([ + { character: 'Billy Butcher', episode_count: 32 }, + ]); + }); + + it('drops the aggregate crew nothing reads', () => { + const details = { + id: 76479, + aggregate_credits: { + cast: [{ id: 1, name: 'Karl Urban', order: 0 }], + crew: Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Crew ${i}`, + })), + }, + } as TmdbTvDetails; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails & { + aggregate_credits?: { crew?: unknown[] }; + }; + + expect(trimmed.aggregate_credits?.crew).toBeUndefined(); + expect(trimmed.aggregate_credits?.cast).toHaveLength(1); + }); + + it('does not mutate the payload the caller still uses', () => { + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + roles: [ + { character: 'A', episode_count: 1 }, + { character: 'B', episode_count: 2 }, + ], + }, + ], + }, + }; + + trimDetailsForCache(details); + + expect(details.aggregate_credits?.cast?.[0].roles).toHaveLength(2); + }); + + it('merges a trimmed payload to the same cast as the full one', () => { + // The property that matters: the first render (network payload) and + // every later one (cached payload) must show the same people + const info = { + name: 'The Boys', + cover: '', + plot: '', + cast: '', + director: '', + genre: '', + releaseDate: '', + last_modified: '', + rating: '', + rating_5based: 0, + backdrop_path: [], + youtube_trailer: '', + episode_run_time: '', + category_id: '1', + } as XtreamSerieInfo; + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: Array.from({ length: 60 }, (_, i) => ({ + id: i, + name: `Regular ${i}`, + order: i, + roles: [{ character: `Role ${i}`, episode_count: 10 }], + })), + }, + credits: { + // A long-serving actor billed 50th who is still in the + // newest season — dropping the aggregate tail would have + // promoted them into a reserved arrival slot + cast: [{ id: 50, name: 'Regular 50', order: 0 }], + }, + }; + + const fromFull = mergeSerieInfoWithTmdb(info, details); + const fromCache = mergeSerieInfoWithTmdb( + info, + trimDetailsForCache(details) as TmdbTvDetails + ); + + expect(fromCache.cast).toBe(fromFull.cast); + expect(fromCache.cast).not.toContain('Regular 50'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-cache-payload.ts b/libs/services/src/lib/tmdb/tmdb-cache-payload.ts new file mode 100644 index 000000000..7289855a8 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache-payload.ts @@ -0,0 +1,47 @@ +import { pickAggregateRole } from './tmdb-credits'; +import { TmdbDetails, TmdbTvDetails } from './tmdb.types'; + +/** + * Trims a details payload before it is cached. + * + * `aggregate_credits` spans a show's whole run: a crew list that can reach + * into the thousands, plus every cast member carrying one `roles[]` entry + * per character they ever played. Caching that verbatim grows + * `tmdb_metadata` (and the PWA's in-memory map) by orders of magnitude for + * data nothing reads. + * + * What goes is chosen so that a merge over the trimmed payload produces + * exactly what a merge over the full one would: + * + * - the whole cast stays, ids included. It is the only record of who was + * in the show before the newest season, so dropping the tail would make + * a returning actor read as a new arrival on the cached path — the + * displayed cast would then differ between the first render and every + * later one. + * - `roles[]` keeps only the entry the merge itself would pick — the + * named character with the most episodes, chosen by the very same + * function, so the two can never drift apart. + * - `crew` goes entirely: series credits come from `created_by`, so + * nothing reads it. + * + * Everything else is passed through untouched — payloads still hold + * whatever a later phase might want without a refetch. + */ +export function trimDetailsForCache(details: TmdbDetails): TmdbDetails { + const aggregate = (details as TmdbTvDetails).aggregate_credits; + if (!aggregate) { + return details; + } + + const cast = (aggregate.cast ?? []).map((member) => { + const roles = member.roles ?? []; + if (roles.length <= 1) { + return member; + } + // The merge's own choice, not a second one that could differ + const main = pickAggregateRole(member); + return { ...member, roles: main ? [main] : [] }; + }); + + return { ...details, aggregate_credits: { cast } } as TmdbDetails; +} diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts index d7a4e40bd..d9e2da417 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts @@ -50,4 +50,145 @@ describe('TmdbCacheService (in-memory LRU)', () => { const cached = await service.get('movie', 'id:same', 'en-US'); expect(cached?.lookupKey).toBe('id:same'); }); + + describe('stats and clearing (PWA path)', () => { + it('counts entries and ENCODED payload bytes', async () => { + // String.length would report 3 for a 6-byte Cyrillic payload + await service.set({ ...entry('id:1'), payload: '"да"' }); + + const stats = await service.getStats(); + expect(stats?.entries).toBe(1); + expect(stats?.bytes).toBe( + new TextEncoder().encode('"да"').length + ); + }); + + it('clears the map and reports how many entries went', async () => { + await service.set(entry('id:1')); + await service.set(entry('id:2')); + + await expect(service.clear()).resolves.toBe(2); + await expect(service.getStats()).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('drops a write that was in flight when the cache was cleared', async () => { + // Otherwise the late write silently restores exactly the data + // the user just asked to remove + const pending = service.set(entry('id:late')); + await service.clear(); + await pending; + + await expect( + service.get('movie', 'id:late', 'en-US') + ).resolves.toBeNull(); + }); + }); +}); + +describe('TmdbCacheService (Electron bridge)', () => { + const entry = (lookupKey: string): TmdbCacheEntry => ({ + mediaType: 'movie', + lookupKey, + language: 'en-US', + tmdbId: 1, + payload: '{}', + }); + + interface ElectronStub { + dbGetTmdbMetadata: jest.Mock; + dbSetTmdbMetadata: jest.Mock; + dbGetTmdbCacheStats?: jest.Mock; + dbClearTmdbMetadata?: jest.Mock; + } + + let electron: ElectronStub; + + /** Resolves the next dbSetTmdbMetadata call on demand */ + let releaseWrite: () => void; + + beforeEach(() => { + releaseWrite = () => undefined; + electron = { + dbGetTmdbMetadata: jest.fn().mockResolvedValue(null), + dbSetTmdbMetadata: jest.fn( + () => + new Promise((resolve) => { + releaseWrite = resolve; + }) + ), + dbGetTmdbCacheStats: jest + .fn() + .mockResolvedValue({ entries: 3, bytes: 90 }), + dbClearTmdbMetadata: jest.fn().mockResolvedValue({ deleted: 3 }), + }; + (window as unknown as { electron: unknown }).electron = electron; + }); + + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('waits for an in-flight write instead of re-clearing after it', async () => { + const service = new TmdbCacheService(); + const pending = service.set(entry('id:late')); + + const cleared = service.clear(); + // The clear must not reach SQLite before the write it has to remove + await Promise.resolve(); + expect(electron.dbClearTmdbMetadata).not.toHaveBeenCalled(); + + releaseWrite(); + await pending; + await expect(cleared).resolves.toBe(3); + expect(electron.dbClearTmdbMetadata).toHaveBeenCalledTimes(1); + }); + + it('keeps rows written after the clear', async () => { + const service = new TmdbCacheService(); + await service.clear(); + + const pending = service.set(entry('id:fresh')); + releaseWrite(); + await pending; + + // A second clear here would delete data the user never asked to lose + expect(electron.dbClearTmdbMetadata).toHaveBeenCalledTimes(1); + }); + + it('lands a write that starts during the clear after the delete', async () => { + const service = new TmdbCacheService(); + const order: string[] = []; + electron.dbClearTmdbMetadata = jest.fn(async () => { + order.push('clear'); + return { deleted: 3 }; + }); + + const first = service.set(entry('id:before')); + const cleared = service.clear(); + + // Starts while the clear is waiting on the first write — without + // serialization its row would reach SQLite before the delete + const second = service.set(entry('id:during')); + electron.dbSetTmdbMetadata.mockImplementation(async () => { + order.push('write'); + }); + + releaseWrite(); + await Promise.all([first, cleared, second]); + + expect(order).toEqual(['clear', 'write']); + }); + + it('reports an unsupported shell instead of the empty renderer map', async () => { + // Newer renderer, older preload: rows are in SQLite but out of reach + delete electron.dbGetTmdbCacheStats; + delete electron.dbClearTmdbMetadata; + const service = new TmdbCacheService(); + + await expect(service.getStats()).resolves.toBeNull(); + await expect(service.clear()).resolves.toBeNull(); + }); }); diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.ts index b2bb18d33..60dd2f975 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.ts @@ -1,5 +1,9 @@ import { Injectable } from '@angular/core'; -import { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from '@iptvnator/shared/interfaces'; /** PWA in-memory cache ceiling — details payloads are a few KB each */ const MEMORY_CACHE_MAX_ENTRIES = 300; @@ -18,6 +22,17 @@ const MEMORY_CACHE_MAX_ENTRIES = 300; export class TmdbCacheService { private readonly memoryCache = new Map(); + /** + * Electron writes that have not landed yet. {@link clear} waits for + * them, so a write already in flight cannot outlive the clear that + * followed it — and, unlike re-clearing after the late write lands, + * waiting cannot take rows written *after* the user cleared with it. + */ + private readonly pendingWrites = new Set>(); + + /** Set for the duration of {@link clear}; writes queue behind it */ + private clearInFlight: Promise | null = null; + private get bridge() { // typeof checks guard against version skew: an older Electron shell // may not expose the TMDB cache methods yet @@ -59,17 +74,29 @@ export class TmdbCacheService { } async set(entry: TmdbCacheEntry): Promise { - const stamped: TmdbCacheEntry = { - ...entry, - fetchedAt: new Date().toISOString(), - }; - const bridge = this.bridge; if (bridge) { + // A clear owns the table until it finishes. Dispatching now + // would race the delete and lose a row the user never asked to + // lose, so this write waits it out and lands after it. + while (this.clearInFlight) { + await this.clearInFlight; + } + + // The IIFE also converts a synchronous throw from the bridge + // into a rejection, so `set` never throws at its caller + const write = (async () => { + try { + await bridge.dbSetTmdbMetadata(this.stamp(entry)); + } catch (error) { + console.warn('TMDB cache write failed:', error); + } + })(); + this.pendingWrites.add(write); try { - await bridge.dbSetTmdbMetadata(stamped); - } catch (error) { - console.warn('TMDB cache write failed:', error); + await write; + } finally { + this.pendingWrites.delete(write); } return; } @@ -80,7 +107,7 @@ export class TmdbCacheService { entry.language ); this.memoryCache.delete(key); - this.memoryCache.set(key, stamped); + this.memoryCache.set(key, this.stamp(entry)); // delete-then-reinsert above means at most one entry over the cap if (this.memoryCache.size > MEMORY_CACHE_MAX_ENTRIES) { const oldest = this.memoryCache.keys().next().value; @@ -90,6 +117,11 @@ export class TmdbCacheService { } } + /** Stamped at write time, not at call time — a write may have waited */ + private stamp(entry: TmdbCacheEntry): TmdbCacheEntry { + return { ...entry, fetchedAt: new Date().toISOString() }; + } + isFresh(entry: TmdbCacheEntry | null, ttlMs: number): boolean { if (!entry?.fetchedAt) { return false; @@ -106,4 +138,102 @@ export class TmdbCacheService { ): string { return `${mediaType}:${language}:${lookupKey}`; } + + /** + * Cache size for the settings panel. In the PWA the cache is the + * session-scoped map, so the numbers describe that instead. + */ + async getStats(): Promise { + const bridge = this.bridge; + if (bridge) { + if (!bridge.dbGetTmdbCacheStats) { + // Version skew: this shell persists to SQLite but predates + // the maintenance ops. The renderer map is empty in + // Electron, so falling back to it would report a cache that + // is anything but empty as having nothing in it. + return null; + } + try { + return await bridge.dbGetTmdbCacheStats(); + } catch (error) { + // `null`, not zeros: reporting a failed read as an empty + // cache would hide real rows and disable the Clear button + console.warn('TMDB cache stats failed:', error); + return null; + } + } + + // TextEncoder measures encoded bytes; String.length counts UTF-16 + // code units, which understates non-ASCII payloads and would + // disagree with the SQLite BLOB byte count. + const encoder = new TextEncoder(); + let bytes = 0; + for (const entry of this.memoryCache.values()) { + bytes += entry.payload ? encoder.encode(entry.payload).length : 0; + } + return { entries: this.memoryCache.size, bytes }; + } + + /** + * Drops everything. Enrichment refetches on demand, so this only + * costs the next few requests. + */ + async clear(): Promise { + const bridge = this.bridge; + const clearedFromMemory = this.memoryCache.size; + this.memoryCache.clear(); + + if (!bridge) { + return clearedFromMemory; + } + + const clearMetadata = bridge.dbClearTmdbMetadata; + if (!clearMetadata) { + // Same version skew as getStats(): the rows are in SQLite and + // stay there, so reporting a successful clear would be a lie + return null; + } + + // Called through the bridge so the preload keeps its receiver + const run = this.runClear(() => clearMetadata.call(bridge)); + this.clearInFlight = run; + try { + return await run; + } finally { + this.clearInFlight = null; + } + } + + /** + * Everything between the writes already in flight and the delete. Held + * in `clearInFlight` for its duration, which is what puts writes that + * start meanwhile on the far side of the delete rather than under it. + */ + private async runClear( + clearMetadata: () => Promise<{ deleted: number } | undefined> + ): Promise { + // Writes issued before the clear land first, so the delete takes + // them too. + if (this.pendingWrites.size > 0) { + // Not Promise.allSettled: the web target compiles against + // lib es2018. The writes swallow their own errors anyway. + await Promise.all( + [...this.pendingWrites].map((write) => + write.then( + () => undefined, + () => undefined + ) + ) + ); + } + + try { + const result = await clearMetadata(); + return result?.deleted ?? 0; + } catch (error) { + // `null` so the panel can say it failed and stay actionable + console.warn('TMDB cache clear failed:', error); + return null; + } + } } diff --git a/libs/services/src/lib/tmdb/tmdb-credits.ts b/libs/services/src/lib/tmdb/tmdb-credits.ts new file mode 100644 index 000000000..66e48d672 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-credits.ts @@ -0,0 +1,197 @@ +import { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces'; +import { tmdbProfileUrl } from './tmdb-config'; +import { TmdbCastMember, TmdbCredits, TmdbTvDetails } from './tmdb.types'; + +/** + * Extraction of people from TMDB credit payloads: the display cast, the + * clickable person chips, and the two-shape union a series cast needs. + * Pure functions — the field-level merge itself lives in `tmdb-merge.ts`. + */ + +const MAX_CAST_NAMES = 10; + +/** + * Slots held back for people who appear only in the newest season. On a + * long-running show the whole-run cast alone fills the display limit, so + * without a reservation the arrivals this union exists to preserve would + * be sliced straight back off. + */ +const RESERVED_NEW_SEASON_SLOTS = 3; + +const byBillingOrder = (a: { order?: number }, b: { order?: number }) => + (a.order ?? 0) - (b.order ?? 0); + +export function limitCast(cast: TmdbCastMember[]): TmdbCastMember[] { + return cast + .slice(0, MAX_CAST_NAMES) + .filter((member) => Boolean(member.name)); +} + +export function topCast(credits: TmdbCredits | undefined): TmdbCastMember[] { + return limitCast([...(credits?.cast ?? [])].sort(byBillingOrder)); +} + +/** + * The role an aggregate member is actually known for. A returning actor + * accumulates one entry per character, so a single-episode cameo sits in + * `roles[]` next to the lead they played for ten seasons — pick by episode + * count rather than by array position. Unnamed roles are skipped: TMDB + * uses them for uncredited appearances, and they would otherwise beat a + * real character on episode count alone. + * + * Exported because the cache trim keeps exactly this role and discards + * the rest — sharing the choice is what stops a cached payload from + * displaying a different character than the payload it was written from. + */ +export function pickAggregateRole< + T extends { character?: string; episode_count?: number }, +>(member: { roles?: T[] }): T | undefined { + const named = (member.roles ?? []).filter((role) => role.character?.trim()); + if (named.length === 0) { + return undefined; + } + return named.reduce((best, role) => + (role.episode_count ?? 0) > (best.episode_count ?? 0) ? role : best + ); +} + +function aggregateCharacter(member: { + roles?: { character?: string; episode_count?: number }[]; +}): string | undefined { + return pickAggregateRole(member)?.character; +} + +/** + * The cast of a SERIES, reconstructed from the two shapes TMDB offers. + * + * `/tv/{id}` `credits` is documented as the credits of the LATEST SEASON. + * TMDB describes `aggregate_credits` in one self-contradicting sentence — + * "it does not return the newest season. Instead, it is a view of all the + * entire cast & crew for all episodes belonging to a TV show" — so it is + * either the whole run minus the newest season, or the whole run. This + * union does not care which: whoever `credits` has and `aggregate_credits` + * does not is missing from the whole-run view and is appended, and under + * the second reading that set is simply empty. Either way, long-running + * shows neither lose departed regulars nor miss new arrivals. + */ +export function unifiedTvCast(details: TmdbTvDetails): TmdbCastMember[] { + const aggregate: TmdbCastMember[] = (details.aggregate_credits?.cast ?? []) + .filter((member) => Boolean(member.name)) + .sort(byBillingOrder) + .map((member) => ({ + id: member.id, + name: member.name, + character: aggregateCharacter(member), + order: member.order, + profile_path: member.profile_path, + })); + + // Cache rows written before aggregate_credits was requested, and shows + // TMDB has no aggregate for, still work — they just keep the old cast. + if (aggregate.length === 0) { + return [...(details.credits?.cast ?? [])].sort(byBillingOrder); + } + + const known = new Set( + aggregate + .map((member) => member.id) + .filter((id): id is number => id !== undefined) + ); + + // Newest-season arrivals go last on purpose: their `order` is scoped to + // that season and would otherwise outrank show-level billing. + const arrivals = [...(details.credits?.cast ?? [])] + .filter( + (member) => + Boolean(member.name) && + (member.id === undefined || !known.has(member.id)) + ) + .sort(byBillingOrder); + + if (arrivals.length === 0) { + return aggregate; + } + + // Reserve room for the top-billed arrivals rather than appending them + // where the cap will discard them. The reservation is a floor, not a + // quota: whatever the aggregate leaves unused still goes to arrivals, + // so a short whole-run cast never shrinks the list below the cap. + const reserved = Math.min(arrivals.length, RESERVED_NEW_SEASON_SLOTS); + const fromAggregate = aggregate.slice( + 0, + Math.max(0, MAX_CAST_NAMES - reserved) + ); + return [ + ...fromAggregate, + ...arrivals.slice(0, MAX_CAST_NAMES - fromAggregate.length), + ]; +} + +export function castNames(cast: TmdbCastMember[]): string { + return cast.map((member) => member.name).join(', '); +} + +/** Cast with profile photos for the avatar chips in detail views */ +export function enrichedCast(cast: TmdbCastMember[]): TmdbEnrichedCastMember[] { + return cast.map((member) => ({ + name: member.name, + ...(member.character ? { character: member.character } : {}), + profileUrl: tmdbProfileUrl(member.profile_path), + ...(member.id ? { tmdbPersonId: member.id } : {}), + })); +} + +export function directorNames(credits: TmdbCredits | undefined): string { + return (credits?.crew ?? []) + .filter((member) => member.job === 'Director') + .map((member) => member.name) + .filter(Boolean) + .join(', '); +} + +export function creatorNames(details: TmdbTvDetails): string { + return (details.created_by ?? []) + .map((creator) => creator.name) + .filter(Boolean) + .join(', '); +} + +/** + * Directors (movies) as clickable person chips — same shape as the cast + * chips, so a director opens the same person page as an actor. Deduped by + * TMDB id to collapse the duplicate crew rows TMDB sometimes returns. + */ +export function enrichedDirectors( + credits: TmdbCredits | undefined +): TmdbEnrichedCastMember[] { + const seen = new Set(); + const directors: TmdbEnrichedCastMember[] = []; + for (const member of credits?.crew ?? []) { + if (member.job !== 'Director' || !member.name) { + continue; + } + if (member.id !== undefined) { + if (seen.has(member.id)) { + continue; + } + seen.add(member.id); + } + directors.push({ + name: member.name, + profileUrl: tmdbProfileUrl(member.profile_path), + ...(member.id ? { tmdbPersonId: member.id } : {}), + }); + } + return directors; +} + +/** Series creators as clickable person chips (TV shows have no director) */ +export function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] { + return (details.created_by ?? []) + .filter((creator) => Boolean(creator.name)) + .map((creator) => ({ + name: creator.name, + profileUrl: tmdbProfileUrl(creator.profile_path), + ...(creator.id ? { tmdbPersonId: creator.id } : {}), + })); +} diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts new file mode 100644 index 000000000..6d5ecae41 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts @@ -0,0 +1,294 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { TmdbApiError, TmdbApiService } from './tmdb-api.service'; +import { TmdbCacheService } from './tmdb-cache.service'; +import { TmdbEnrichmentService } from './tmdb-enrichment.service'; +import { TmdbIdResolverService } from './tmdb-id-resolver.service'; +import { TmdbPersonService } from './tmdb-person.service'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbSeasonService } from './tmdb-season.service'; +import { TmdbTrendingService } from './tmdb-trending.service'; +import { TmdbMovieDetails } from './tmdb.types'; + +/** + * Regression coverage for provider-supplied `tmdb_id` handling. Panels + * ship dead and stale ids; before this, a dead one short-circuited the + * title search (leaving the item permanently unenriched) and a stale one + * silently rendered another film's metadata. + */ +describe('TmdbEnrichmentService — provider tmdb_id handling', () => { + const matrix: TmdbMovieDetails = { + id: 603, + title: 'The Matrix', + original_title: 'The Matrix', + overview: 'Plot', + videos: { results: [{ key: 'k', site: 'YouTube', type: 'Trailer' }] }, + }; + const unrelated: TmdbMovieDetails = { + id: 999, + title: 'Completely Different Film', + original_title: 'Completely Different Film', + release_date: '1979-12-14', + overview: 'Other plot', + videos: { results: [{ key: 'x', site: 'YouTube', type: 'Trailer' }] }, + }; + + let getMovieDetails: jest.Mock; + let resolveBySearch: jest.Mock; + let isKnownBadProviderId: jest.Mock; + let rememberBadProviderId: jest.Mock; + + // The services Jest target has no @angular/core/testing — build the + // service in a plain injection context instead of TestBed. + function createService(): TmdbEnrichmentService { + const injector = Injector.create({ + providers: [ + { + provide: TmdbRuntimeService, + useValue: { + isEnabled: () => true, + apiKey: () => 'key', + language: () => 'en-US', + appLanguage: () => 'en', + }, + }, + { + provide: TmdbApiService, + useValue: { getMovieDetails, getTvDetails: jest.fn() }, + }, + { + provide: TmdbCacheService, + useValue: { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + isFresh: () => false, + }, + }, + { + provide: TmdbIdResolverService, + useValue: { + resolveBySearch, + isKnownBadProviderId, + rememberBadProviderId, + }, + }, + { provide: TmdbPersonService, useValue: {} }, + { provide: TmdbSeasonService, useValue: {} }, + { provide: TmdbTrendingService, useValue: {} }, + ], + }); + return runInInjectionContext( + injector, + () => new TmdbEnrichmentService() + ); + } + + beforeEach(() => { + getMovieDetails = jest.fn().mockResolvedValue(matrix); + resolveBySearch = jest.fn().mockResolvedValue(null); + isKnownBadProviderId = jest.fn().mockResolvedValue(false); + rememberBadProviderId = jest.fn().mockResolvedValue(undefined); + }); + + it('uses a valid provider id without searching at all', async () => { + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 603, + title: 'The Matrix', + }); + + expect(details?.id).toBe(603); + expect(getMovieDetails).toHaveBeenCalledTimes(1); + expect(resolveBySearch).not.toHaveBeenCalled(); + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('falls back to the title search when the provider id 404s', async () => { + getMovieDetails + .mockRejectedValueOnce(new TmdbApiError(404, 'Not Found')) + .mockResolvedValueOnce(matrix); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 123456789, + title: 'The Matrix', + }); + + // Previously this returned null: the dead id skipped the search + expect(details?.id).toBe(603); + expect(resolveBySearch).toHaveBeenCalledTimes(1); + expect(rememberBadProviderId).toHaveBeenCalledWith( + 'movie', + 123456789 + ); + }); + + it('prefers a confident search match over a stale provider id', async () => { + // The id resolves, but to a 1979 film while our item is from 1999 + getMovieDetails + .mockResolvedValueOnce(unrelated) + .mockResolvedValueOnce(matrix); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 999, + title: 'The Matrix', + year: 1999, + }); + + expect(details?.id).toBe(603); + // The id EXISTS — it is just wrong for this item. The bad-id row is + // keyed by id alone and shared across playlists, so recording a + // per-item mismatch would deny the direct lookup to every other + // item that legitimately uses the same id. + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('does not blame the id for a transient failure', async () => { + // Rate limit, bad key, 5xx or offline: the id may be perfectly + // fine, so it must stay retryable rather than be disabled for days + getMovieDetails.mockRejectedValue(new TmdbApiError(429, 'Too Many')); + resolveBySearch.mockResolvedValue(null); + const service = createService(); + + await service.enrichMovie({ tmdbId: 603, title: 'The Matrix' }); + + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('does not search after a transient provider-id failure', async () => { + // The search would hit the same outage, and a title match that + // does come back is weaker evidence than the id we already have + getMovieDetails.mockRejectedValue(new TmdbApiError(503, 'Down')); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 603, + title: 'The Matrix', + }); + + expect(details).toBeNull(); + expect(resolveBySearch).not.toHaveBeenCalled(); + }); + + it('does not blame the id for a network error', async () => { + getMovieDetails.mockRejectedValue(new Error('offline')); + resolveBySearch.mockResolvedValue(null); + const service = createService(); + + await service.enrichMovie({ tmdbId: 603, title: 'The Matrix' }); + + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('keeps the provider payload when the title differs but the search finds nothing', async () => { + // A localized provider title legitimately fails the name check — + // TMDB returns titles in the REQUEST language. Never trade real + // metadata for none on suspicion alone. + getMovieDetails.mockResolvedValue(unrelated); + resolveBySearch.mockResolvedValue(null); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 999, + title: 'Ирония судьбы', + year: 1979, + }); + + expect(details?.id).toBe(999); + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('never lets a name mismatch alone unseat a working provider id', async () => { + // "IT - Chapter Two" normalizes to "chapter two" (the ALL-CAPS "IT" + // reads as a language tag), so the correct payload fails the name + // check. With no year to arbitrate, a search for "chapter two" + // would confidently return the 1979 film of that name. + getMovieDetails.mockResolvedValue({ + id: 474350, + title: 'It Chapter Two', + original_title: 'It Chapter Two', + overview: 'Plot', + }); + resolveBySearch.mockResolvedValue(30619); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 474350, + title: 'IT - Chapter Two', + }); + + expect(details?.id).toBe(474350); + expect(resolveBySearch).not.toHaveBeenCalled(); + }); + + it('catches a stale id that shares the title but not the year', async () => { + // The scraper case: "Blade Runner 2049" carrying the 1982 film's id. + // Both titles normalize to "blade runner", so only the year sees it. + getMovieDetails + .mockResolvedValueOnce({ + id: 78, + title: 'Blade Runner', + release_date: '1982-06-25', + }) + .mockResolvedValueOnce({ + id: 335984, + title: 'Blade Runner 2049', + release_date: '2017-10-04', + }); + resolveBySearch.mockResolvedValue(335984); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 78, + title: 'Blade Runner 2049', + year: 2017, + }); + + expect(details?.id).toBe(335984); + }); + + it('keeps the provider payload when the competing search throws', async () => { + // Same reasoning as above, but the search never returns a verdict: + // TMDB is offline or rate-limiting. Details in hand beat nothing. + getMovieDetails.mockResolvedValue(unrelated); + resolveBySearch.mockRejectedValue(new TmdbApiError(503, 'Down')); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 999, + title: 'Ирония судьбы', + year: 1999, + }); + + expect(details?.id).toBe(999); + }); + + it('skips a provider id already known to be bad', async () => { + isKnownBadProviderId.mockResolvedValue(true); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 123456789, + title: 'The Matrix', + }); + + expect(details?.id).toBe(603); + // One details call for the searched id — none for the dead one + expect(getMovieDetails).toHaveBeenCalledTimes(1); + expect(getMovieDetails).toHaveBeenCalledWith(603, 'en-US', 'key'); + }); + + it('returns null when there is no provider id and no search match', async () => { + const service = createService(); + + await expect( + service.enrichMovie({ title: 'Unknown Thing' }) + ).resolves.toBeNull(); + expect(getMovieDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index bfed5f41b..600448be5 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -1,21 +1,15 @@ import { Injectable, inject } from '@angular/core'; import { TmdbMediaType } from '@iptvnator/shared/interfaces'; -import { TmdbApiService } from './tmdb-api.service'; +import { TmdbApiService, isTmdbNotFound } from './tmdb-api.service'; +import { trimDetailsForCache } from './tmdb-cache-payload'; import { TmdbCacheService } from './tmdb-cache.service'; +import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config'; import { - TMDB_DETAILS_CACHE_TTL_MS, - TMDB_MATCH_CACHE_TTL_MS, - TMDB_NEGATIVE_MATCH_CACHE_TTL_MS, - tmdbSearchLanguageForTitle, -} from './tmdb-config'; -import { + assessProviderId, buildDetailsLookupKey, - buildSearchLookupKey, - buildSearchTitleVariants, - extractYear, parseProviderTmdbId, - pickConfidentMatch, } from './tmdb-matcher'; +import { TmdbIdResolverService } from './tmdb-id-resolver.service'; import { detailsFallbackLanguage, fillDetailsFromFallback, @@ -53,6 +47,7 @@ export class TmdbEnrichmentService { private readonly person = inject(TmdbPersonService); private readonly season = inject(TmdbSeasonService); private readonly trending = inject(TmdbTrendingService); + private readonly idResolver = inject(TmdbIdResolverService); isEnabled(): boolean { return this.runtime.isEnabled(); @@ -107,15 +102,31 @@ export class TmdbEnrichmentService { } try { - const tmdbId = - parseProviderTmdbId(query.tmdbId) ?? - (await this.resolveIdBySearch(mediaType, query)); - - if (tmdbId === null) { - return null; + const providerId = parseProviderTmdbId(query.tmdbId); + if ( + providerId !== null && + !(await this.idResolver.isKnownBadProviderId( + mediaType, + providerId + )) + ) { + const details = await this.detailsForProviderId( + mediaType, + providerId, + query + ); + if (details) { + return details; + } } - return await this.getDetails(mediaType, tmdbId); + const searchedId = await this.idResolver.resolveBySearch( + mediaType, + query + ); + return searchedId === null + ? null + : await this.getDetails(mediaType, searchedId); } catch (error) { console.warn(`TMDB ${mediaType} enrichment failed:`, error); return null; @@ -123,88 +134,79 @@ export class TmdbEnrichmentService { } /** - * Resolve a title/year to a TMDB id via /search with the confidence - * gate. Both hits and misses are cached; misses use a shorter TTL. + * Details for a provider-supplied `tmdb_id`, or `null` when the id is + * unusable and the caller should fall back to the title search. + * + * Providers ship broken ids. A dead one used to short-circuit the + * search and leave the item permanently unenriched; a stale-but-valid + * one silently rendered another title's plot and cast. Both cases now + * defer to the (confidence-gated) search — but only on evidence the id + * is wrong, and only when the search actually produces something, so a + * localized or stylized title never costs the user their metadata. */ - private async resolveIdBySearch( + private async detailsForProviderId( mediaType: TmdbMediaType, + providerId: number, query: TmdbEnrichmentQuery - ): Promise { - // Try the original title, the display title, then language-prefix- - // stripped fallbacks; the first confident match wins. - const variants = buildSearchTitleVariants( - query.title, - query.originalTitle - ); - if (variants.length === 0) { - return null; - } - - const year = query.year ?? extractYear(null, query.title); - const cacheLanguage = tmdbSearchLanguageForTitle( - variants[0], - this.runtime.appLanguage() - ); - const lookupKey = buildSearchLookupKey(variants[0], year); - - const cached = await this.cache.get( - mediaType, - lookupKey, - cacheLanguage - ); - const ttl = - cached?.tmdbId !== null && cached?.tmdbId !== undefined - ? TMDB_MATCH_CACHE_TTL_MS - : TMDB_NEGATIVE_MATCH_CACHE_TTL_MS; - if (this.cache.isFresh(cached, ttl)) { - return cached?.tmdbId ?? null; - } - - let match = null; - for (const variant of variants) { - // Cyrillic (and other non-app-script) titles search in their - // own language so TMDB returns comparable titles — see - // tmdbSearchLanguageForTitle. Search by title only: TMDB's - // year params filter strictly; the ±1/season tolerance lives - // in pickConfidentMatch instead. - const language = tmdbSearchLanguageForTitle( - variant, - this.runtime.appLanguage() - ); - const results = - mediaType === 'movie' - ? await this.api.searchMovie( - variant, - null, - language, - this.runtime.apiKey() - ) - : await this.api.searchTv( - variant, - null, - language, - this.runtime.apiKey() - ); - - match = pickConfidentMatch( - results, - { title: variant, year }, - mediaType - ); - if (match) { - break; + ): Promise { + let details: TmdbDetails | null; + try { + details = await this.getDetails(mediaType, providerId); + } catch (error) { + // Only a 404 proves the id is dead. Auth, rate-limit, 5xx and + // offline failures are transient — remembering those would + // disable a perfectly good id until the marker expires. + if (isTmdbNotFound(error)) { + console.warn( + `TMDB ${mediaType} provider id ${providerId} does not exist, falling back to search:`, + error + ); + await this.idResolver.rememberBadProviderId( + mediaType, + providerId + ); + return null; } + + // Rethrow instead: `enrich` reads a null here as "try the + // search", and searching during an outage or a rate limit just + // adds a request that will fail too — or, worse, succeeds and + // swaps a strong id for a title match. The id stays good for + // the next attempt. + throw error; } - await this.cache.set({ - mediaType, - lookupKey, - language: cacheLanguage, - tmdbId: match?.id ?? null, - payload: null, - }); + if (!details || assessProviderId(details, query, mediaType) !== 'contradicted') { + return details; + } - return match?.id ?? null; + // The id resolves to a title whose release year contradicts the + // provider's — the stale-id signature. Let the search try to beat + // it; keep these details if it cannot. + // + // Deliberately NOT remembered: the id exists and may be correct for + // a DIFFERENT item. The bad-id cache is keyed by id alone and shared + // across playlists, so recording a per-item mismatch there would + // deny the direct lookup to every other item that legitimately uses + // the same id. The search verdict is cached anyway, so the repeat + // cost is one details fetch. + // + // The search is best-effort here: offline, rate-limited or 5xx must + // fall back to the details we already have, not discard them. Its + // own gate needs a year, which this branch guarantees we have, so a + // result that comes back is corroborated rather than merely named + // alike. + const searchedId = await this.idResolver + .resolveBySearch(mediaType, query) + .catch(() => null); + if (searchedId === null || searchedId === providerId) { + return details; + } + + const searched = await this.getDetails(mediaType, searchedId).catch( + () => null + ); + return searched ?? details; } private async getDetails( @@ -282,7 +284,9 @@ export class TmdbEnrichmentService { lookupKey, language, tmdbId, - payload: JSON.stringify(details), + payload: JSON.stringify( + details === null ? null : trimDetailsForCache(details) + ), }); return details; diff --git a/libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts b/libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts new file mode 100644 index 000000000..d8e90051a --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts @@ -0,0 +1,158 @@ +import { Injectable, inject } from '@angular/core'; +import { TmdbMediaType } from '@iptvnator/shared/interfaces'; +import { TmdbApiService } from './tmdb-api.service'; +import { TmdbCacheService } from './tmdb-cache.service'; +import { + TMDB_MATCH_CACHE_TTL_MS, + TMDB_NEGATIVE_MATCH_CACHE_TTL_MS, + tmdbSearchLanguageForTitle, +} from './tmdb-config'; +import { + buildBadProviderIdLookupKey, + buildSearchLookupKey, + buildSearchTitleVariants, + extractYear, + pickConfidentMatch, +} from './tmdb-matcher'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbEnrichmentQuery } from './tmdb.types'; + +/** + * "This id is wrong" verdicts are about the provider's data, not about a + * translation, so they are cached under one language-independent row. + */ +const BAD_ID_CACHE_LANGUAGE = 'any'; + +/** + * Resolves a provider item to a TMDB id: the confidence-gated title search + * plus the negative cache for provider-supplied ids we have proven wrong. + * Extracted from the enrichment orchestrator so both concerns stay + * testable and the facade keeps its size in check. + */ +@Injectable({ providedIn: 'root' }) +export class TmdbIdResolverService { + private readonly runtime = inject(TmdbRuntimeService); + private readonly api = inject(TmdbApiService); + private readonly cache = inject(TmdbCacheService); + + /** + * Resolve a title/year to a TMDB id via /search with the confidence + * gate. Both hits and misses are cached; misses use a shorter TTL. + */ + async resolveBySearch( + mediaType: TmdbMediaType, + query: TmdbEnrichmentQuery + ): Promise { + // Try the original title, the display title, then language-prefix- + // stripped fallbacks; the first confident match wins. + const variants = buildSearchTitleVariants( + query.title, + query.originalTitle + ); + if (variants.length === 0) { + return null; + } + + const year = query.year ?? extractYear(null, query.title); + const cacheLanguage = tmdbSearchLanguageForTitle( + variants[0], + this.runtime.appLanguage() + ); + const lookupKey = buildSearchLookupKey(variants[0], year); + + const cached = await this.cache.get( + mediaType, + lookupKey, + cacheLanguage + ); + const ttl = + cached?.tmdbId !== null && cached?.tmdbId !== undefined + ? TMDB_MATCH_CACHE_TTL_MS + : TMDB_NEGATIVE_MATCH_CACHE_TTL_MS; + if (this.cache.isFresh(cached, ttl)) { + return cached?.tmdbId ?? null; + } + + let match = null; + for (const variant of variants) { + // Cyrillic (and other non-app-script) titles search in their + // own language so TMDB returns comparable titles — see + // tmdbSearchLanguageForTitle. Search by title only: TMDB's + // year params filter strictly; the ±1/season tolerance lives + // in pickConfidentMatch instead. + const language = tmdbSearchLanguageForTitle( + variant, + this.runtime.appLanguage() + ); + const results = + mediaType === 'movie' + ? await this.api.searchMovie( + variant, + null, + language, + this.runtime.apiKey() + ) + : await this.api.searchTv( + variant, + null, + language, + this.runtime.apiKey() + ); + + match = pickConfidentMatch( + results, + { title: variant, year }, + mediaType + ); + if (match) { + break; + } + } + + await this.cache.set({ + mediaType, + lookupKey, + language: cacheLanguage, + tmdbId: match?.id ?? null, + payload: null, + }); + + return match?.id ?? null; + } + + /** + * True when this id is known NOT TO EXIST on TMDB. Without this a dead + * id costs one wasted 404 on every single detail open, forever — + * failed detail fetches cache nothing. + * + * The verdict is deliberately about the ID, not about the item that + * supplied it: the row is keyed by id alone and shared across + * playlists, so only "TMDB returned 404 for this id" may be recorded + * here. A per-item mismatch is never cached — see + * `TmdbEnrichmentService.detailsForProviderId`. + */ + async isKnownBadProviderId( + mediaType: TmdbMediaType, + tmdbId: number + ): Promise { + const cached = await this.cache.get( + mediaType, + buildBadProviderIdLookupKey(tmdbId), + BAD_ID_CACHE_LANGUAGE + ); + return this.cache.isFresh(cached, TMDB_NEGATIVE_MATCH_CACHE_TTL_MS); + } + + async rememberBadProviderId( + mediaType: TmdbMediaType, + tmdbId: number + ): Promise { + await this.cache.set({ + mediaType, + lookupKey: buildBadProviderIdLookupKey(tmdbId), + language: BAD_ID_CACHE_LANGUAGE, + tmdbId: null, + payload: null, + }); + } +} diff --git a/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts b/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts index f61f7ce86..7a3b737a1 100644 --- a/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts @@ -1,6 +1,8 @@ import { + buildBadProviderIdLookupKey, buildDetailsLookupKey, buildSearchLookupKey, + detailsMatchProviderTitle, buildSearchTitleVariants, extractYear, normalizeTitle, @@ -295,3 +297,58 @@ describe('pickConfidentMatch', () => { ).toBeNull(); }); }); + +describe('buildBadProviderIdLookupKey', () => { + it('namespaces the id so it cannot collide with a details row', () => { + expect(buildBadProviderIdLookupKey(603)).toBe('badProviderId:603'); + expect(buildBadProviderIdLookupKey(603)).not.toBe( + buildDetailsLookupKey(603) + ); + }); +}); + +describe('detailsMatchProviderTitle', () => { + const details = { + title: 'The Matrix', + original_title: 'The Matrix', + }; + + it('accepts a match on a noisy provider title', () => { + expect( + detailsMatchProviderTitle(details, { + title: 'EN - The Matrix (1999) 1080p', + }) + ).toBe(true); + }); + + it('accepts a match against the original title only', () => { + expect( + detailsMatchProviderTitle( + { title: 'Die Hard', original_title: 'Stirb langsam' }, + { title: 'Stirb langsam' } + ) + ).toBe(true); + }); + + it('matches series payloads through name/original_name', () => { + expect( + detailsMatchProviderTitle( + { name: 'The Boys', original_name: 'The Boys' }, + { title: 'The Boys s05' } + ) + ).toBe(true); + }); + + it('reports a mismatch for an unrelated title', () => { + expect( + detailsMatchProviderTitle(details, { title: 'Blade Runner' }) + ).toBe(false); + }); + + it('never reports a mismatch when there is nothing to compare', () => { + expect(detailsMatchProviderTitle(details, { title: '' })).toBe(true); + expect(detailsMatchProviderTitle({}, { title: 'The Matrix' })).toBe( + false + ); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-matcher.ts b/libs/services/src/lib/tmdb/tmdb-matcher.ts index 2c60cca56..390e373a9 100644 --- a/libs/services/src/lib/tmdb/tmdb-matcher.ts +++ b/libs/services/src/lib/tmdb/tmdb-matcher.ts @@ -82,6 +82,11 @@ export function buildDetailsLookupKey(tmdbId: number): string { return `id:${tmdbId}|v2`; } +/** Negative-cache key for a provider tmdb_id we have proven wrong */ +export function buildBadProviderIdLookupKey(tmdbId: number): string { + return `badProviderId:${tmdbId}`; +} + /** Provider tmdb_id fields arrive as number, numeric string, or garbage */ export function parseProviderTmdbId( tmdbId: number | string | null | undefined @@ -90,6 +95,100 @@ export function parseProviderTmdbId( return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } +/** + * What the payload behind a provider `tmdb_id` says about that id. + * + * - `corroborated`: title or release year agrees; use the details. + * - `contradicted`: both years are known and they disagree — the classic + * stale id ("Blade Runner 2049" carrying the 1982 film's id). Only this + * verdict is strong enough to let the title search take over. + * - `inconclusive`: the title differs and there is no year to arbitrate + * with. Keep the details: TMDB returns titles in the REQUEST language, + * and normalization strips stylized prefixes ("IT - Chapter Two"), so a + * name mismatch alone says more about our own inputs than about the id. + */ +export type ProviderIdVerdict = + | 'corroborated' + | 'contradicted' + | 'inconclusive'; + +/** The same tolerance the search gate uses, applied in reverse */ +function yearsAgree( + providerYear: number, + detailsYear: number, + mediaType: TmdbMediaType +): boolean { + if (Math.abs(detailsYear - providerYear) <= 1) { + return true; + } + // Series: portals report the current season's year while TMDB reports + // the premiere, so a show that started earlier still agrees. + return mediaType === 'tv' && detailsYear < providerYear; +} + +export function assessProviderId( + details: { + title?: string; + original_title?: string; + release_date?: string; + name?: string; + original_name?: string; + first_air_date?: string; + }, + query: { title?: string | null; originalTitle?: string | null; year?: number | null }, + mediaType: TmdbMediaType +): ProviderIdVerdict { + // Same effective year the search would use, so the two agree on what + // "the provider's year" means + const providerYear = query.year ?? extractYear(null, query.title); + const detailsYear = extractYear( + mediaType === 'movie' ? details.release_date : details.first_air_date + ); + + if (providerYear !== null && detailsYear !== null) { + return yearsAgree(providerYear, detailsYear, mediaType) + ? 'corroborated' + : 'contradicted'; + } + + return detailsMatchProviderTitle(details, query) + ? 'corroborated' + : 'inconclusive'; +} + +/** + * Does a details payload plausibly describe the item we asked about? + * Title-only signal — see {@link assessProviderId} for the verdict callers + * should act on. + */ +export function detailsMatchProviderTitle( + details: { + title?: string; + original_title?: string; + name?: string; + original_name?: string; + }, + query: { title?: string | null; originalTitle?: string | null } +): boolean { + const variants = new Set( + buildSearchTitleVariants(query.title, query.originalTitle) + ); + if (variants.size === 0) { + // Nothing to compare against — never call that a mismatch + return true; + } + + return [ + details.title, + details.original_title, + details.name, + details.original_name, + ].some((title) => { + const normalized = normalizeTitle(title); + return normalized !== '' && variants.has(normalized); + }); +} + function resultTitles(result: TmdbSearchResult, mediaType: TmdbMediaType) { return mediaType === 'movie' ? [result.title, result.original_title] diff --git a/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts new file mode 100644 index 000000000..5afa94629 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts @@ -0,0 +1,236 @@ +import { XtreamSerieInfo } from '@iptvnator/shared/interfaces'; +import { mergeSerieInfoWithTmdb } from './tmdb-merge'; +import { TmdbTvDetails } from './tmdb.types'; + +describe('series cast (aggregate + latest season)', () => { + const info: XtreamSerieInfo = { + name: 'The Boys', + cover: '', + plot: '', + cast: '', + director: '', + genre: '', + releaseDate: '', + last_modified: '', + rating: '', + rating_5based: 0, + backdrop_path: [], + youtube_trailer: '', + episode_run_time: '', + category_id: '1', + }; + + // TMDB documents /tv/{id} `credits` as the LATEST SEASON only, and + // aggregate_credits as everything EXCEPT the newest season. + const details: TmdbTvDetails = { + id: 76479, + name: 'The Boys', + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + profile_path: '/urban.jpg', + roles: [{ character: 'Billy Butcher' }], + }, + { + id: 2, + name: 'Jack Quaid', + order: 1, + roles: [{ character: 'Hughie' }], + }, + ], + }, + credits: { + cast: [ + // Still around in the newest season + { id: 1, name: 'Karl Urban', order: 0, character: 'Butcher' }, + // Joined only in the newest season + { + id: 3, + name: 'Newcomer Person', + order: 1, + character: 'Rookie', + }, + ], + }, + }; + + it('keeps whole-run cast that the latest season dropped', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + // Jack Quaid is absent from `credits` — the old code lost him + expect(merged.cast).toContain('Jack Quaid'); + }); + + it('adds newest-season arrivals after the show billing order', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + expect(merged.cast).toBe('Karl Urban, Jack Quaid, Newcomer Person'); + }); + + it('takes the character from the aggregate roles array', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + expect(merged.tmdb_cast?.[0]).toEqual({ + name: 'Karl Urban', + character: 'Billy Butcher', + profileUrl: 'https://image.tmdb.org/t/p/w185/urban.jpg', + tmdbPersonId: 1, + }); + }); + + it('does not duplicate people present in both payloads', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + expect( + merged.tmdb_cast?.filter((m) => m.name === 'Karl Urban') + ).toHaveLength(1); + }); + + it('keeps newest-season arrivals when the aggregate already fills the cap', () => { + // The case the union exists for: a long-running show whose + // whole-run cast alone exceeds the display limit + const bigAggregate = Array.from({ length: 12 }, (_, i) => ({ + id: 100 + i, + name: `Regular ${i}`, + order: i, + roles: [{ character: `Role ${i}` }], + })); + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { cast: bigAggregate }, + credits: { + cast: [ + { id: 900, name: 'Brand New Lead', order: 0 }, + { id: 901, name: 'Brand New Sidekick', order: 1 }, + ], + }, + }); + + const names = merged.tmdb_cast?.map((member) => member.name) ?? []; + expect(names).toHaveLength(10); + expect(names).toContain('Brand New Lead'); + expect(names).toContain('Brand New Sidekick'); + // Top billing survives; the reservation eats into the tail only + expect(names[0]).toBe('Regular 0'); + }); + + it('gives every slot to the aggregate when nobody is new', () => { + const bigAggregate = Array.from({ length: 12 }, (_, i) => ({ + id: 100 + i, + name: `Regular ${i}`, + order: i, + })); + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { cast: bigAggregate }, + credits: { cast: [{ id: 100, name: 'Regular 0', order: 0 }] }, + }); + + expect(merged.tmdb_cast).toHaveLength(10); + expect(merged.tmdb_cast?.[9].name).toBe('Regular 9'); + }); + + it('does not shrink the list when the whole-run cast is short', () => { + // The reservation is a floor for arrivals, not a ceiling: with four + // regulars and five newcomers all nine fit under the cap + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { + cast: Array.from({ length: 4 }, (_, i) => ({ + id: 100 + i, + name: `Regular ${i}`, + order: i, + })), + }, + credits: { + cast: Array.from({ length: 5 }, (_, i) => ({ + id: 200 + i, + name: `Newcomer ${i}`, + order: i, + })), + }, + }); + + expect(merged.tmdb_cast).toHaveLength(9); + expect(merged.cast).toContain('Newcomer 4'); + }); + + it('takes the role the actor played the most episodes of', () => { + // A one-episode cameo sits in roles[] next to the lead part + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + roles: [ + { character: 'Cameo Guy', episode_count: 1 }, + { character: 'Billy Butcher', episode_count: 32 }, + ], + }, + ], + }, + }); + + expect(merged.tmdb_cast?.[0].character).toBe('Billy Butcher'); + }); + + it('always fills the cap when there are enough people to fill it', () => { + // The reservation decides WHO makes the cut, never how many: the + // list is the display limit or everyone available, whichever is + // smaller. The earlier defect broke exactly this. + const cast = (n: number, offset: number, label: string) => + Array.from({ length: n }, (_, i) => ({ + id: offset + i, + name: `${label} ${i}`, + order: i, + })); + + for (const [regulars, newcomers] of [ + [0, 4], + [2, 1], + [4, 5], + [7, 5], + [9, 4], + [12, 2], + [12, 0], + ]) { + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { cast: cast(regulars, 100, 'Regular') }, + credits: { cast: cast(newcomers, 900, 'Newcomer') }, + }); + + expect({ + regulars, + newcomers, + shown: merged.tmdb_cast?.length ?? 0, + }).toEqual({ + regulars, + newcomers, + shown: Math.min(10, regulars + newcomers), + }); + } + }); + + it('falls back to plain credits when no aggregate is present', () => { + // Cache rows written before aggregate_credits was requested + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + credits: { + cast: [ + { id: 3, name: 'Second Billed', order: 1 }, + { id: 1, name: 'Top Billed', order: 0 }, + ], + }, + }); + expect(merged.cast).toBe('Top Billed, Second Billed'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 084ff13df..c64af3f98 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -2,7 +2,9 @@ import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces'; import { mergeSerieInfoWithTmdb, mergeVodInfoWithTmdb } from './tmdb-merge'; import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types'; -function providerVodInfo(overrides: Partial = {}): XtreamVodInfo { +function providerVodInfo( + overrides: Partial = {} +): XtreamVodInfo { return { kinopoisk_url: '', tmdb_id: '', @@ -183,9 +185,7 @@ describe('mergeVodInfoWithTmdb', () => { expect(merged.rating).toBe(7); expect(merged.movie_image).toBe('http://provider/poster.jpg'); expect(merged.tmdb_cast).toBeUndefined(); - expect(merged.backdrop_path).toEqual([ - 'http://provider/backdrop.jpg', - ]); + expect(merged.backdrop_path).toEqual(['http://provider/backdrop.jpg']); }); it('ignores TMDB rating without votes', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index 4f309605d..13d8669c8 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -1,16 +1,25 @@ import { normalizeSeriesStatus, StalkerVodInfo, - TmdbEnrichedCastMember, TmdbMediaType, TmdbRecommendation, XtreamSerieInfo, XtreamVodInfo, } from '@iptvnator/shared/interfaces'; -import { tmdbBackdropUrl, tmdbPosterUrl, tmdbProfileUrl } from './tmdb-config'; +import { tmdbBackdropUrl, tmdbPosterUrl } from './tmdb-config'; +import { + castNames, + creatorNames, + directorNames, + enrichedCast, + enrichedCreators, + enrichedDirectors, + limitCast, + topCast, + unifiedTvCast, +} from './tmdb-credits'; import { extractYear } from './tmdb-matcher'; import { - TmdbCredits, TmdbDetails, TmdbMovieDetails, TmdbTvDetails, @@ -21,90 +30,9 @@ import { * The provider stays authoritative for stream-related data; TMDB wins for * editorial fields (plot, cast, director, genres, rating, artwork) when it * has a value, otherwise the provider value is kept. Nothing is mutated. + * People (cast, directors, creators) are extracted in `tmdb-credits.ts`. */ -const MAX_CAST_NAMES = 10; - -function topCast(credits: TmdbCredits | undefined) { - return [...(credits?.cast ?? [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .slice(0, MAX_CAST_NAMES) - .filter((member) => Boolean(member.name)); -} - -function castNames(credits: TmdbCredits | undefined): string { - return topCast(credits) - .map((member) => member.name) - .join(', '); -} - -/** Cast with profile photos for the avatar chips in detail views */ -function enrichedCast( - credits: TmdbCredits | undefined -): TmdbEnrichedCastMember[] { - return topCast(credits).map((member) => ({ - name: member.name, - ...(member.character ? { character: member.character } : {}), - profileUrl: tmdbProfileUrl(member.profile_path), - ...(member.id ? { tmdbPersonId: member.id } : {}), - })); -} - -function directorNames(credits: TmdbCredits | undefined): string { - return (credits?.crew ?? []) - .filter((member) => member.job === 'Director') - .map((member) => member.name) - .filter(Boolean) - .join(', '); -} - -function creatorNames(details: TmdbTvDetails): string { - return (details.created_by ?? []) - .map((creator) => creator.name) - .filter(Boolean) - .join(', '); -} - -/** - * Directors (movies) as clickable person chips — same shape as the cast - * chips, so a director opens the same person page as an actor. Deduped by - * TMDB id to collapse the duplicate crew rows TMDB sometimes returns. - */ -function enrichedDirectors( - credits: TmdbCredits | undefined -): TmdbEnrichedCastMember[] { - const seen = new Set(); - const directors: TmdbEnrichedCastMember[] = []; - for (const member of credits?.crew ?? []) { - if (member.job !== 'Director' || !member.name) { - continue; - } - if (member.id !== undefined) { - if (seen.has(member.id)) { - continue; - } - seen.add(member.id); - } - directors.push({ - name: member.name, - profileUrl: tmdbProfileUrl(member.profile_path), - ...(member.id ? { tmdbPersonId: member.id } : {}), - }); - } - return directors; -} - -/** Series creators as clickable person chips (TV shows have no director) */ -function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] { - return (details.created_by ?? []) - .filter((creator) => Boolean(creator.name)) - .map((creator) => ({ - name: creator.name, - profileUrl: tmdbProfileUrl(creator.profile_path), - ...(creator.id ? { tmdbPersonId: creator.id } : {}), - })); -} - const MAX_RECOMMENDATIONS = 12; /** Best YouTube trailer key: official trailer > any trailer > teaser */ @@ -170,11 +98,12 @@ export function mergeVodInfoWithTmdb( info: XtreamVodInfo, details: TmdbMovieDetails ): XtreamVodInfo { - const tmdbCast = enrichedCast(details.credits); + const movieCast = topCast(details.credits); + const tmdbCast = enrichedCast(movieCast); const tmdbDirectors = enrichedDirectors(details.credits); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); - const cast = castNames(details.credits); + const cast = castNames(movieCast); const director = directorNames(details.credits); const genre = genreNames(details); const rating = tmdbRating(details); @@ -217,12 +146,13 @@ export function mergeSerieInfoWithTmdb( info: XtreamSerieInfo, details: TmdbTvDetails ): XtreamSerieInfo { - const tmdbCast = enrichedCast(details.credits); + const seriesCast = limitCast(unifiedTvCast(details)); + const tmdbCast = enrichedCast(seriesCast); const tmdbDirectors = enrichedCreators(details); const status = normalizeSeriesStatus(details.status); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); - const cast = castNames(details.credits); + const cast = castNames(seriesCast); const creators = creatorNames(details); const genre = genreNames(details); const rating = tmdbRating(details); @@ -262,7 +192,11 @@ export function mergeStalkerInfoWithTmdb( details: TmdbMovieDetails | TmdbTvDetails, mediaType: TmdbMediaType ): StalkerVodInfo { - const tmdbCast = enrichedCast(details.credits); + const selectedCast = + mediaType === 'movie' + ? topCast(details.credits) + : limitCast(unifiedTvCast(details as TmdbTvDetails)); + const tmdbCast = enrichedCast(selectedCast); const tmdbDirectors = mediaType === 'movie' ? enrichedDirectors(details.credits) @@ -273,7 +207,7 @@ export function mergeStalkerInfoWithTmdb( : null; const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); - const cast = castNames(details.credits); + const cast = castNames(selectedCast); const director = mediaType === 'movie' ? directorNames(details.credits) diff --git a/libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts b/libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts new file mode 100644 index 000000000..99c199191 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts @@ -0,0 +1,92 @@ +import { assessProviderId } from './tmdb-matcher'; + +describe('assessProviderId', () => { + it('corroborates on a matching year even when the title reads wrong', () => { + // "IT - Chapter Two" normalizes to "chapter two" because the + // ALL-CAPS "IT" looks like a language tag + expect( + assessProviderId( + { + title: 'It Chapter Two', + release_date: '2019-09-04', + }, + { title: 'IT - Chapter Two', year: 2019 }, + 'movie' + ) + ).toBe('corroborated'); + }); + + it('is inconclusive when the title differs and no year can arbitrate', () => { + // Never 'contradicted': normalization artifacts and localized + // titles both land here, and the id is usually fine + expect( + assessProviderId( + { title: 'It Chapter Two' }, + { title: 'IT - Chapter Two' }, + 'movie' + ) + ).toBe('inconclusive'); + }); + + it('contradicts a same-title id from another year', () => { + // Both normalize to "blade runner" — only the year sees the swap + expect( + assessProviderId( + { title: 'Blade Runner', release_date: '1982-06-25' }, + { title: 'Blade Runner 2049', year: 2017 }, + 'movie' + ) + ).toBe('contradicted'); + }); + + it('reads the year out of the provider title when there is no field', () => { + expect( + assessProviderId( + { title: 'The Lion King', release_date: '1994-06-24' }, + { title: 'The Lion King 2019' }, + 'movie' + ) + ).toBe('contradicted'); + }); + + it('allows a one-year drift between provider and TMDB dates', () => { + expect( + assessProviderId( + { title: 'Some Film', release_date: '2018-12-28' }, + { title: 'Totally Other Name', year: 2019 }, + 'movie' + ) + ).toBe('corroborated'); + }); + + it('accepts a series that premiered before the reported season year', () => { + // Portals report the current season's year, TMDB the premiere + expect( + assessProviderId( + { name: 'The Boys', first_air_date: '2019-07-25' }, + { title: 'The Boys', year: 2026 }, + 'tv' + ) + ).toBe('corroborated'); + }); + + it('contradicts a series that started after the reported year', () => { + expect( + assessProviderId( + { name: 'Something Else', first_air_date: '2024-01-01' }, + { title: 'The Boys', year: 2019 }, + 'tv' + ) + ).toBe('contradicted'); + }); + + it('corroborates on the title when neither side has a year', () => { + expect( + assessProviderId( + { title: 'The Matrix' }, + { title: 'The Matrix' }, + 'movie' + ) + ).toBe('corroborated'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index c6ec2b093..ccb5087af 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -57,6 +57,23 @@ export interface TmdbCredits { crew?: TmdbCrewMember[]; } +/** + * `/tv/{id}/aggregate_credits` groups a person's work across the whole + * run, so a character lives in `roles[]` rather than on the member. + */ +export interface TmdbAggregateCastMember { + id?: number; + name: string; + order?: number; + profile_path?: string | null; + total_episode_count?: number; + roles?: { character?: string; episode_count?: number }[]; +} + +export interface TmdbAggregateCredits { + cast?: TmdbAggregateCastMember[]; +} + export interface TmdbGenre { id: number; name: string; @@ -100,6 +117,8 @@ export interface TmdbTvDetails extends TmdbDetailsBase { first_air_date?: string; /** English production status ("Ended", "Returning Series", ...) */ status?: string; + /** Series-wide cast; `credits` alone covers only the latest season */ + aggregate_credits?: TmdbAggregateCredits; episode_run_time?: number[]; created_by?: { id?: number; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index fe63da397..08214323f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -21,6 +21,7 @@ import { XtreamBackupRecentlyViewedItem, } from './playlist-backup.interface'; import { + PlaylistRefreshCancelledResult, PlaylistRefreshEvent, PlaylistRefreshPayload, } from './playlist-refresh.interface'; @@ -32,7 +33,11 @@ import { import { PortalDebugEvent } from './portal-debug.interface'; import { CatalogTitleMatch } from './catalog-title-match.interface'; import { Settings } from './settings.interface'; -import { TmdbCacheEntry, TmdbCacheMediaType } from './tmdb.interface'; +import { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from './tmdb.interface'; import { XtreamCategory } from './xtream-category.interface'; export const ELECTRON_BRIDGE_CONTENT_TYPES = { @@ -667,7 +672,9 @@ export interface ElectronBridgeApi { url: string, method?: 'GET' | 'HEAD' ) => Promise; - refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; cancelPlaylistRefresh: ( operationId: string ) => Promise; @@ -821,6 +828,11 @@ export interface ElectronBridgeApi { language: string ) => Promise; dbSetTmdbMetadata: (entry: TmdbCacheEntry) => Promise; + /** Row count + payload bytes for the settings cache panel */ + dbGetTmdbCacheStats: () => Promise; + dbClearTmdbMetadata: () => Promise< + ElectronBridgeResult & { deleted: number } + >; /** Cross-playlist title matching (actor page "All portals" scope) */ dbMatchTitles: (titles: string[]) => Promise; onChannelChange?: ( diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index b02ee028f..6a153f952 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -27,3 +27,25 @@ export interface PlaylistRefreshPayload { url?: string; trustedInsecureTlsHosts?: string[]; } + +export const PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE = + 'playlist-refresh-cancelled' as const; + +export interface PlaylistRefreshCancelledResult { + operationId: string; + type: typeof PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE; +} + +export function isPlaylistRefreshCancelledResult( + value: unknown +): value is PlaylistRefreshCancelledResult { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Record; + return ( + candidate['type'] === PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE && + typeof candidate['operationId'] === 'string' + ); +} diff --git a/libs/shared/interfaces/src/lib/tmdb.interface.ts b/libs/shared/interfaces/src/lib/tmdb.interface.ts index 0174b3265..a00db1381 100644 --- a/libs/shared/interfaces/src/lib/tmdb.interface.ts +++ b/libs/shared/interfaces/src/lib/tmdb.interface.ts @@ -106,6 +106,13 @@ export interface TmdbRecommendation { * - `title:|year:` — search resolution result; a `null` * `tmdbId` is a cached "no confident match" verdict (negative cache) */ +/** Size of the persisted TMDB cache, shown in the settings panel */ +export interface TmdbCacheStats { + entries: number; + /** Total payload size in bytes */ + bytes: number; +} + export interface TmdbCacheEntry { mediaType: TmdbCacheMediaType; lookupKey: string; diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts new file mode 100644 index 000000000..0e769e077 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts @@ -0,0 +1,109 @@ +import { + MockHls, + createSession, + hlsInstances, + initArtPlayerSourceSessionModule, + resetArtPlayerSourceFixtures, +} from './art-player-source-session.spec-fixtures'; + +/** + * Regression coverage for #1155: with ArtPlayer's own chrome (shared controls + * off, the shipping default) the `showCaptions` preference reached no engine at + * all, so an HLS rendition flagged DEFAULT/AUTOSELECT kept subtitles on screen. + * + * The preference seeds each source here rather than staying authoritative, so + * the engine's caption menu still works once playback is running. + */ +describe('ArtPlayerSourceSession caption preference without shared controls', () => { + beforeAll(async () => { + await initArtPlayerSourceSessionModule(); + }); + + beforeEach(() => { + resetArtPlayerSourceFixtures(); + }); + + it('deselects the HLS default subtitle track while the preference is off', () => { + const { session, player, video } = startHls(() => false); + + const hls = selectDefaultSubtitleTrack(hlsInstances[0]); + + expect(hls.subtitleTrack).toBe(-1); + // Display stays enabled: hls.js applies `subtitleDisplay` to whatever + // the vendor caption menu picks, so suppressing it would make the menu + // inert while the preference is off. + expect(hls.subtitleDisplay).toBe(true); + expect(player.video).toBe(video); + session.destroy(); + }); + + it('keeps a selection made after playback started', () => { + const { session, video } = startHls(() => false); + const hls = selectDefaultSubtitleTrack(hlsInstances[0]); + expect(hls.subtitleTrack).toBe(-1); + + video.dispatchEvent(new Event('playing')); + hls.subtitleTrack = 0; + hls.emit(MockHls.Events.SUBTITLE_TRACK_SWITCH); + + expect(hls.subtitleTrack).toBe(0); + session.destroy(); + }); + + it('keeps HLS subtitles selected while the preference is on', () => { + const { session } = startHls(() => true); + + const hls = selectDefaultSubtitleTrack(hlsInstances[0]); + + expect(hls.subtitleTrack).toBe(0); + session.destroy(); + }); + + it('re-seeds the preference for a replacement source', () => { + const { session, player, video } = startHls(() => false); + video.dispatchEvent(new Event('playing')); + + session.customType['m3u8']?.( + video, + 'https://example.test/second.m3u8', + player + ); + const replacement = selectDefaultSubtitleTrack(hlsInstances[1]); + + expect(replacement.subtitleTrack).toBe(-1); + session.destroy(); + }); + + it('stops touching the engine after the session is destroyed', () => { + const { session } = startHls(() => false); + const hls = hlsInstances[0]; + + session.destroy(); + const untouched = selectDefaultSubtitleTrack(hls); + + expect(untouched.subtitleTrack).toBe(0); + }); + + function startHls(showCaptions: () => boolean) { + const { session, player, video } = createSession({ + sharedControls: false, + showCaptions, + }); + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + return { session, player, video }; + } + + /** Mimics hls.js auto-selecting a DEFAULT/AUTOSELECT rendition. */ + function selectDefaultSubtitleTrack(hls: MockHls): MockHls { + hls.subtitleTracks = [{ name: 'English' }]; + hls.subtitleDisplay = true; + hls.subtitleTrack = 0; + hls.emit(MockHls.Events.SUBTITLE_TRACKS_UPDATED); + return hls; + } +}); diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts index 26a046c2c..cbc8b0ce5 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -131,12 +131,14 @@ export function resetArtPlayerSourceFixtures(): void { export function createSession({ sharedControls, isLive = true, + showCaptions = () => false, emitPlaybackIssue = () => undefined, getDrm, loadShaka, }: { sharedControls: boolean; isLive?: boolean; + showCaptions?: () => boolean; emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void; getDrm?: () => ChannelDrm | undefined; loadShaka?: ShakaModuleLoader; @@ -161,7 +163,7 @@ export function createSession({ sharedControls, controlsAdapter: adapter, isLive: () => isLive, - showCaptions: () => false, + showCaptions, emitPlaybackIssue, getDrm, loadShaka, diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index def6b1be7..eb6cedb3a 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -18,6 +18,7 @@ import { type WebVideoControlsSource, WebVideoSourceControlsBridge, } from '../web-video-support/web-video-source-controls.bridge'; +import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks'; import { addHlsAudioTrackSettings } from './art-player-audio-tracks'; export interface ArtPlayerSourceSessionConfig { @@ -49,6 +50,11 @@ export class ArtPlayerSourceSession { private player: Artplayer | null = null; private controlsBridge: WebVideoSourceControlsBridge | null = null; + /** + * Legacy (ArtPlayer chrome) counterpart of {@link controlsBridge}: keeps + * the `showCaptions` preference authoritative without a controls adapter. + */ + private captionTracks: WebVideoSourceTracks | null = null; private pendingControlsSource: WebVideoControlsSource = { kind: 'native' }; private hls: Hls | null = null; private hlsManifestListener: @@ -73,6 +79,12 @@ export class ArtPlayerSourceSession { this.player = player; if (!this.config.sharedControls) { + this.captionTracks = new WebVideoSourceTracks({ + video: player.video, + showCaptions: this.config.showCaptions, + vendorCaptionControls: true, + }); + this.captionTracks.setSource(this.pendingControlsSource); return; } @@ -89,6 +101,7 @@ export class ArtPlayerSourceSession { refreshInputs(): void { this.controlsBridge?.refreshInputs(); + this.captionTracks?.refreshInputs(); } resolveDuration(fallbackDuration: number): number { @@ -106,6 +119,8 @@ export class ArtPlayerSourceSession { this.destroyed = true; this.controlsBridge?.destroy(); this.controlsBridge = null; + this.captionTracks?.destroy(); + this.captionTracks = null; this.destroyHls(); this.destroyMpegTs(); this.shakaSession?.destroy(); @@ -215,6 +230,7 @@ export class ArtPlayerSourceSession { private prepareForSourceChange(): void { this.controlsBridge?.clearSource(); + this.captionTracks?.clearSource(); this.destroyHls(); this.destroyMpegTs(); this.shakaSession?.stop(); @@ -234,6 +250,7 @@ export class ArtPlayerSourceSession { private bindControlsSource(source: WebVideoControlsSource): void { this.pendingControlsSource = source; this.controlsBridge?.setSource(source); + this.captionTracks?.setSource(source); } private destroyHls(): void { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts index 4072cfa01..d1a44277b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts @@ -12,8 +12,6 @@ describe('HtmlVideoElementSession', () => { video, getChannelUrl: () => 'https://example.test/video.ts', getStartTime: () => 12, - showCaptions: () => true, - sharedControls: () => false, emitPlaybackIssue: (issue) => playbackIssues.push(issue), emitTimeUpdate: (value) => timeUpdates.push(value), emitPlaybackEnded: playbackEnded, @@ -74,7 +72,10 @@ describe('HtmlVideoElementSession', () => { session.destroy(); }); - it('suppresses legacy captions only after successful non-shared playback', async () => { + // Caption state belongs to WebVideoSourceTracks, which follows the active + // source engine. The old play()-time one-shot ran before hls.js had added + // its text tracks, which is what made the preference look like a no-op. + it('leaves text tracks alone when playback starts', async () => { const video = document.createElement('video'); const tracks = [ { mode: 'showing' as TextTrackMode }, @@ -85,41 +86,27 @@ describe('HtmlVideoElementSession', () => { value: tracks, }); jest.spyOn(video, 'play').mockResolvedValue(undefined); - const session = createSession(video, { - showCaptions: () => false, - sharedControls: () => false, - }); + const session = createSession(video); session.play(); await Promise.resolve(); - expect(tracks.map((track) => track.mode)).toEqual(['hidden', 'hidden']); + expect(tracks.map((track) => track.mode)).toEqual([ + 'showing', + 'showing', + ]); }); - it.each([ - { showCaptions: true, sharedControls: false }, - { showCaptions: false, sharedControls: true }, - ])( - 'keeps captions when showCaptions=$showCaptions and sharedControls=$sharedControls', - async ({ showCaptions, sharedControls }) => { - const video = document.createElement('video'); - const tracks = [{ mode: 'showing' as TextTrackMode }]; - Object.defineProperty(video, 'textTracks', { - configurable: true, - value: tracks, - }); - jest.spyOn(video, 'play').mockResolvedValue(undefined); - const session = createSession(video, { - showCaptions: () => showCaptions, - sharedControls: () => sharedControls, - }); + it('swallows autoplay rejections', async () => { + const video = document.createElement('video'); + jest.spyOn(video, 'play').mockRejectedValue( + new Error('autoplay blocked') + ); + const session = createSession(video); - session.play(); - await Promise.resolve(); - - expect(tracks[0].mode).toBe('showing'); - } - ); + expect(() => session.play()).not.toThrow(); + await Promise.resolve(); + }); }); function createSession( @@ -132,8 +119,6 @@ function createSession( video, getChannelUrl: () => undefined, getStartTime: () => 0, - showCaptions: () => true, - sharedControls: () => false, emitPlaybackIssue: jest.fn(), emitTimeUpdate: jest.fn(), emitPlaybackEnded: jest.fn(), diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 79ae2396b..3e320c19b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -12,8 +12,6 @@ export interface HtmlVideoElementSessionConfig { video: HTMLVideoElement; getChannelUrl: () => string | undefined; getStartTime: () => number; - showCaptions: () => boolean; - sharedControls: () => boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; emitPlaybackEnded: () => void; @@ -95,25 +93,10 @@ export class HtmlVideoElementSession { return; } - void playPromise - .then(() => { - if ( - !this.config.sharedControls() && - !this.config.showCaptions() - ) { - this.disableCaptions(); - } - }) - .catch(() => { - // Autoplay failures are surfaced by the surrounding player UI. - }); - } - - disableCaptions(): void { - const tracks = this.config.video.textTracks; - for (let index = 0; index < tracks.length; index += 1) { - tracks[index].mode = 'hidden'; - } + // Autoplay failures are surfaced by the surrounding player UI. Caption + // state is owned by WebVideoSourceTracks, which tracks the active + // source engine instead of guessing once when playback starts. + void playPromise.catch(() => undefined); } destroy(): void { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts new file mode 100644 index 000000000..b8396f8be --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts @@ -0,0 +1,150 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DataService } from '@iptvnator/services'; +import { Channel } from '@iptvnator/shared/interfaces'; +import { + createTextTrack, + FakeTextTrackList, +} from './html-video-player-controls.spec-fixtures'; +import { HtmlVideoPlayerComponent } from './html-video-player.component'; + +/** `.mp4` skips the hls.js/mpegts.js branches and binds the native source. */ +const NATIVE_SOURCE_URL = 'http://test.example/stream.mp4'; +const OTHER_SOURCE_URL = 'http://test.example/next.mp4'; + +const TEST_CHANNEL: Channel = { + id: '1234', + url: NATIVE_SOURCE_URL, + name: 'Test channel', + group: { title: 'News group' }, + http: { origin: '', referrer: '', 'user-agent': 'localhost' }, + radio: 'false', + tvg: { id: '', logo: '', name: '', rec: '', url: '' }, +}; + +/** + * Regression coverage for #1155: without shared controls (the shipping + * default) the `showCaptions` preference had no owner at all, so a track the + * source engine enabled by default kept subtitles on screen. + */ +describe('HtmlVideoPlayerComponent caption preference without shared controls', () => { + let component: HtmlVideoPlayerComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: DataService, + useValue: { + sendIpcEvent: jest.fn().mockResolvedValue(undefined), + }, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { setUserAgent: jest.fn().mockResolvedValue(true) }, + }); + fixture = TestBed.createComponent(HtmlVideoPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + jest.restoreAllMocks(); + }); + + it('suppresses initial and late default captions when the preference is off', () => { + const initial = createTextTrack({ kind: 'captions', mode: 'showing' }); + const textTracks = stubTextTracks([initial]); + setPreference(false); + + component.playChannel(TEST_CHANNEL); + + expect(initial.mode).toBe('hidden'); + + const late = createTextTrack({ kind: 'subtitles', mode: 'showing' }); + textTracks.add(late); + + expect(late.mode).toBe('hidden'); + }); + + it('leaves captions alone when the preference is on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + stubTextTracks([track]); + setPreference(true); + + component.playChannel(TEST_CHANNEL); + + expect(track.mode).toBe('showing'); + }); + + // The native controls stay visible in this mode, so the preference must + // seed the source and then get out of the user's way. + it('keeps a caption the user enables after playback started', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + const textTracks = stubTextTracks([track]); + setPreference(false); + component.playChannel(TEST_CHANNEL); + expect(track.mode).toBe('hidden'); + + component.videoPlayer.nativeElement.dispatchEvent( + new Event('playing') + ); + track.mode = 'showing'; + textTracks.emit('change'); + + expect(track.mode).toBe('showing'); + }); + + it('re-seeds the preference for the next channel', () => { + const first = createTextTrack({ kind: 'captions', mode: 'showing' }); + const textTracks = stubTextTracks([first]); + setPreference(false); + component.playChannel(TEST_CHANNEL); + component.videoPlayer.nativeElement.dispatchEvent( + new Event('playing') + ); + + const next = createTextTrack({ kind: 'captions', mode: 'showing' }); + textTracks.replaceSilently([next]); + component.playChannel({ ...TEST_CHANNEL, url: OTHER_SOURCE_URL }); + + expect(next.mode).toBe('hidden'); + }); + + it('restores suppressed captions when the preference is turned on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + stubTextTracks([track]); + setPreference(false); + component.playChannel(TEST_CHANNEL); + expect(track.mode).toBe('hidden'); + + setPreference(true); + + expect(track.mode).toBe('showing'); + }); + + function setPreference(showCaptions: boolean): void { + fixture.componentRef.setInput('showCaptions', showCaptions); + fixture.detectChanges(); + } + + function stubTextTracks(tracks: TextTrack[]): FakeTextTrackList { + const video = component.videoPlayer.nativeElement; + const textTracks = new FakeTextTrackList(tracks); + Object.defineProperty(video, 'textTracks', { + configurable: true, + value: textTracks.asTextTrackList(), + }); + jest.spyOn(video, 'play').mockResolvedValue(undefined); + jest.spyOn(video, 'load').mockImplementation(() => undefined); + return textTracks; + } +}); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts index 54009faea..33349e0e3 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts @@ -211,20 +211,24 @@ describe('HtmlVideoPlayerComponent shared controls host', () => { expect(setContext).toHaveBeenCalledTimes(1); }); - it('does not run legacy post-play caption suppression', async () => { + it('leaves caption state to the controls bridge when playback starts', async () => { const { component } = renderSharedControls( HtmlVideoPlayerComponent, fixtures ); - const disableCaptions = jest.spyOn( - component as HtmlVideoPlayerComponentInstance, - 'disableCaptions' - ); + const video = (component as HtmlVideoPlayerComponentInstance) + .videoPlayer.nativeElement; + const tracks = [{ mode: 'showing' as TextTrackMode }]; + Object.defineProperty(video, 'textTracks', { + configurable: true, + value: tracks, + }); + jest.spyOn(video, 'play').mockResolvedValue(undefined); component.handlePlayOperation(); await Promise.resolve(); - expect(disableCaptions).not.toHaveBeenCalled(); + expect(tracks[0].mode).toBe('showing'); }); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 3c2477389..63304da58 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -91,23 +91,6 @@ describe('HtmlVideoPlayerComponent', () => { expect(adapterAttach).not.toHaveBeenCalled(); }); - it('keeps legacy post-play caption suppression when shared controls are disabled', async () => { - const video = component.videoPlayer.nativeElement; - const tracks = [{ mode: 'showing' as TextTrackMode }]; - Object.defineProperty(video, 'textTracks', { - configurable: true, - value: tracks, - }); - jest.spyOn(video, 'play').mockResolvedValue(undefined); - fixture.componentRef.setInput('showCaptions', false); - fixture.detectChanges(); - - component.handlePlayOperation(); - await Promise.resolve(); - - expect(tracks[0].mode).toBe('hidden'); - }); - it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => { const el = component.videoPlayer.nativeElement; const removeSpy = jest.spyOn(el, 'removeEventListener'); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 4ebb45ae0..25f80a8ca 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -36,6 +36,7 @@ import { clearNativeVideoSources, setNativeVideoSource, } from '../web-video-support/web-video-native-source.util'; +import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks'; import { HtmlVideoElementSession } from './html-video-element-session'; import { emitFatalHlsPlaybackError, @@ -101,6 +102,12 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { private shakaSession: ShakaVideoSession | null = null; private controlsSource: HtmlVideoControlsSource | null = null; private controlsBridge: HtmlVideoPlayerControlsBridge | null = null; + /** + * Legacy (vendor-chrome) counterpart of {@link controlsBridge}: without + * shared controls there is no adapter to feed, but the `showCaptions` + * preference still has to reach the active source engine. + */ + private captionTracks: WebVideoSourceTracks | null = null; private videoSession: HtmlVideoElementSession | null = null; ngOnInit() { @@ -116,9 +123,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { showCaptions: () => this.showCaptions(), }); this.controlsBridge.attach(); - if (this.controlsSource) { - this.controlsBridge.setSource(this.controlsSource); - } + } else { + this.captionTracks = new WebVideoSourceTracks({ + video: this.videoPlayer.nativeElement, + showCaptions: () => this.showCaptions(), + vendorCaptionControls: true, + }); + } + if (this.controlsSource) { + this.bindControlsSource(this.controlsSource); } this.getVideoSession().attach(); } @@ -136,6 +149,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { } if (changes['isLive'] || changes['showCaptions']) { this.controlsBridge?.refreshInputs(); + this.captionTracks?.refreshInputs(); } if (changes['interactionEnabled']?.currentValue === false) { exitOwnedFullscreen( @@ -273,10 +287,12 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { private bindControlsSource(source: HtmlVideoControlsSource): void { this.controlsSource = source; this.controlsBridge?.setSource(source); + this.captionTracks?.setSource(source); } private clearControlsSource(): void { this.controlsBridge?.clearSource(); + this.captionTracks?.clearSource(); this.controlsSource = null; } @@ -307,13 +323,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { return this.shakaSession; } - /** - * Disables text based captions based on the global settings - */ - disableCaptions(): void { - this.getVideoSession().disableCaptions(); - } - /** * Handles promise based play operation */ @@ -342,6 +351,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ngOnDestroy(): void { this.controlsBridge?.destroy(); this.controlsBridge = null; + this.captionTracks?.destroy(); + this.captionTracks = null; this.controlsSource = null; this.videoSession?.destroy(); this.videoSession = null; @@ -356,8 +367,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { video: this.videoPlayer.nativeElement, getChannelUrl: () => this.channel?.url, getStartTime: () => this.startTime, - showCaptions: () => this.showCaptions(), - sharedControls: () => this.sharedControls, emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), emitTimeUpdate: (value) => this.timeUpdate.emit(value), emitPlaybackEnded: () => this.playbackEnded.emit(), diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts new file mode 100644 index 000000000..cb6a7cd0c --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts @@ -0,0 +1,66 @@ +import { VjsAudioTracks } from './vjs-audio-tracks'; +import type { VideoJsPlayer } from './vjs-player.types'; +import { VjsTextTracks } from './vjs-text-tracks'; + +export interface VjsLegacyTracksConfig { + player: VideoJsPlayer; + showCaptions: () => boolean; +} + +/** + * Audio and text track ownership for the legacy Video.js chrome. + * + * Shared controls route the same helpers through `VjsPlayerControlsBridge`; + * without them nothing applied the `showCaptions` preference to the player's + * text tracks, so provider defaults stayed on screen (#1155). + * + * Here the preference is a per-source default rather than an authoritative + * state: Video.js still renders its own captions menu, so enforcing the + * preference for the whole session would make that menu inert. The preference + * is applied while the source starts up and released once playback begins. + * There is no controls UI to notify, hence the no-op refresh. + */ +export class VjsLegacyTracks { + private readonly audioTracks: VjsAudioTracks; + private readonly textTracks: VjsTextTracks; + private readonly handlePlaying = () => { + this.playbackStarted = true; + }; + private playbackStarted = false; + + constructor(private readonly config: VjsLegacyTracksConfig) { + const refresh = () => undefined; + this.audioTracks = new VjsAudioTracks({ + player: config.player, + refresh, + }); + this.textTracks = new VjsTextTracks({ + player: config.player, + showCaptions: config.showCaptions, + refresh, + playbackStarted: () => this.playbackStarted, + }); + config.player.on('playing', this.handlePlaying); + } + + bind(): void { + this.audioTracks.bind(); + this.textTracks.bind(); + } + + refreshInputs(): void { + this.textTracks.refreshInputs(); + } + + clear(): void { + // Every source starts unsettled so its own defaults are seeded again. + this.playbackStarted = false; + this.audioTracks.clear(); + this.textTracks.clear(); + } + + destroy(): void { + this.clear(); + this.config.player.off('playing', this.handlePlaying); + } +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts new file mode 100644 index 000000000..6d8266f6c --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts @@ -0,0 +1,264 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; +import type { + VideoJsPlayer, + VideoJsTextTrack, + VideoJsTextTrackList, +} from './vjs-player.types'; + +const videoJsMock = jest.fn(); + +jest.unstable_mockModule('video.js', () => ({ default: videoJsMock })); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); +jest.unstable_mockModule('mpegts.js', () => ({ + default: { + Events: { ERROR: 'error' }, + createPlayer: jest.fn(), + isSupported: jest.fn(() => false), + }, +})); + +/** + * Regression coverage for #1155: with Video.js' own control bar (shared + * controls off, the shipping default) the `showCaptions` preference was never + * read, so a VHS rendition flagged DEFAULT kept subtitles on screen. + */ +describe('VjsPlayerComponent caption preference without shared controls', () => { + let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent; + let fixture: ComponentFixture; + let harness: ReturnType; + + beforeAll(async () => { + ({ VjsPlayerComponent } = await import('./vjs-player.component')); + }); + + beforeEach(async () => { + harness = createHarness(); + videoJsMock + .mockReset() + .mockImplementation( + (_target: Element, _options: unknown, ready: () => void) => { + harness.ready = ready; + return harness.player; + } + ); + await TestBed.configureTestingModule({ + imports: [VjsPlayerComponent], + }).compileComponents(); + fixture = TestBed.createComponent(VjsPlayerComponent); + }); + + afterEach(() => { + if (!fixture.componentRef.hostView.destroyed) { + fixture.destroy(); + } + localStorage.removeItem('volume'); + }); + + it('suppresses initial and late default captions while the preference is off', () => { + const initial = createTextTrack({ + kind: 'captions', + mode: 'showing', + }); + harness.tracks.replaceSilently([initial]); + render(false); + + expect(initial.mode).toBe('disabled'); + + const late = createTextTrack({ kind: 'subtitles', mode: 'showing' }); + harness.tracks.add(late); + + expect(late.mode).toBe('disabled'); + }); + + it('keeps captions displayed while the preference is on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(true); + + expect(track.mode).toBe('showing'); + }); + + // The Video.js control bar stays visible in this mode, so the preference + // must seed the source and then get out of the user's way. + it('keeps a caption the user enables after playback started', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(false); + expect(track.mode).toBe('disabled'); + + harness.emit('playing'); + track.mode = 'showing'; + harness.tracks.emit('change'); + + expect(track.mode).toBe('showing'); + }); + + it('restores the suppressed track when the preference is turned on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(false); + expect(track.mode).toBe('disabled'); + + fixture.componentRef.setInput('showCaptions', true); + fixture.detectChanges(); + + expect(track.mode).toBe('showing'); + }); + + it('re-applies the preference to the text tracks of a new source', () => { + render(false); + + const replacement = createTextTrack({ + kind: 'subtitles', + mode: 'showing', + }); + harness.tracks.replaceSilently([replacement]); + fixture.componentRef.setInput('options', { + sources: [{ src: 'https://example.test/second.mp4' }], + }); + fixture.detectChanges(); + harness.emit('loadedmetadata'); + + expect(replacement.mode).toBe('disabled'); + }); + + it('stops managing text tracks once the component is destroyed', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(false); + + fixture.destroy(); + track.mode = 'showing'; + harness.tracks.emit('change'); + + expect(track.mode).toBe('showing'); + }); + + function render(showCaptions: boolean): void { + fixture.componentRef.setInput('showCaptions', showCaptions); + fixture.componentRef.setInput('options', { + sources: [{ src: 'https://example.test/first.mp4' }], + }); + fixture.detectChanges(); + harness.ready(); + fixture.detectChanges(); + } +}); + +function createTextTrack( + overrides: Partial = {} +): VideoJsTextTrack { + return { kind: 'subtitles', mode: 'disabled', ...overrides }; +} + +class FakeTextTrackList { + private tracks: VideoJsTextTrack[] = []; + private indexedLength = 0; + private readonly listeners = new Map< + string, + Set + >(); + + readonly addEventListener = jest.fn( + (event: string, listener: EventListenerOrEventListenerObject): void => { + const registered = this.listeners.get(event) ?? new Set(); + registered.add(listener); + this.listeners.set(event, registered); + } + ); + + readonly removeEventListener = jest.fn( + (event: string, listener: EventListenerOrEventListenerObject): void => { + this.listeners.get(event)?.delete(listener); + } + ); + + get length(): number { + return this.tracks.length; + } + + replaceSilently(tracks: VideoJsTextTrack[]): void { + this.tracks = [...tracks]; + this.syncIndexedTracks(); + } + + add(track: VideoJsTextTrack): void { + this.tracks.push(track); + this.syncIndexedTracks(); + this.emit('addtrack'); + } + + emit(eventName: 'addtrack' | 'removetrack' | 'change'): void { + const event = new Event(eventName); + for (const listener of this.listeners.get(eventName) ?? []) { + if (typeof listener === 'function') { + listener.call(this, event); + } else { + listener.handleEvent(event); + } + } + } + + asTextTrackList(): VideoJsTextTrackList { + return this as unknown as VideoJsTextTrackList; + } + + private syncIndexedTracks(): void { + for (let index = 0; index < this.indexedLength; index += 1) { + Reflect.deleteProperty(this, index); + } + this.tracks.forEach((track, index) => { + Object.defineProperty(this, index, { + configurable: true, + enumerable: true, + value: track, + }); + }); + this.indexedLength = this.tracks.length; + } +} + +function createHarness() { + const listeners = new Map void>>(); + const tracks = new FakeTextTrackList(); + const harness = { + tracks, + currentVideo: document.createElement('video'), + ready: () => undefined, + emit(event: string) { + for (const listener of listeners.get(event) ?? []) { + listener(); + } + }, + player: null as unknown as VideoJsPlayer, + }; + harness.player = { + audioTracks: jest.fn(() => null), + textTracks: jest.fn(() => tracks.asTextTrackList()), + currentTime: jest.fn(() => 0), + duration: jest.fn(() => 0), + error: jest.fn(() => null), + getChild: jest.fn(() => null), + on: jest.fn((event: string, listener: () => void) => { + const registered = listeners.get(event) ?? new Set<() => void>(); + registered.add(listener); + listeners.set(event, registered); + }), + off: jest.fn((event: string, listener: () => void) => { + listeners.get(event)?.delete(listener); + }), + pause: jest.fn(), + paused: jest.fn(() => true), + reset: jest.fn(), + src: jest.fn(), + tech: jest.fn(() => ({ el: () => harness.currentVideo })), + volume: jest.fn(() => 1), + dispose: jest.fn(), + qualitySelectorHls: jest.fn(), + aspectRatioPanel: jest.fn(), + } as unknown as VideoJsPlayer; + return harness; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 0c63496c7..9911dd61a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -31,11 +31,8 @@ import { } from '../player-controls'; import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; -import { - VjsAudioTracks, - logVjsAudioTracks, - setupVjsAudioTrackMenu, -} from './vjs-audio-tracks'; +import { logVjsAudioTracks, setupVjsAudioTrackMenu } from './vjs-audio-tracks'; +import { VjsLegacyTracks } from './vjs-legacy-tracks'; import { VjsMpegTsSession } from './vjs-mpegts-session'; import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge'; import { @@ -112,7 +109,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { debugVjsPlayer('Failed to reset Video.js player:', error), }); private controlsBridge: VjsPlayerControlsBridge | null = null; - private legacyAudioTracks: VjsAudioTracks | null = null; + /** Track ownership for the legacy chrome; null with shared controls. */ + private legacyTracks: VjsLegacyTracks | null = null; private desiredSource: VideoPlayerSource | null = null; private readyHandled = false; private destroyed = false; @@ -161,6 +159,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { } if (changes['showCaptions']) { this.controlsBridge?.refreshInputs(); + this.legacyTracks?.refreshInputs(); } if (changes['interactionEnabled']?.currentValue === false) { exitOwnedVjsFullscreen( @@ -187,8 +186,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.resetCoordinator.destroy(); this.controlsBridge?.destroy(); this.controlsBridge = null; - this.legacyAudioTracks?.clear(); - this.legacyAudioTracks = null; + this.legacyTracks?.destroy(); + this.legacyTracks = null; this.mpegTsSession.destroy(); this.videoSession.destroy(); if (this.player) { @@ -211,7 +210,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.volume() ); this.player.volume(this.volume()); - this.initializeLegacyAudioTracks(); + this.initializeLegacyTracks(); const video = this.bindCurrentTechVideo(); if (video && this.resetCoordinator.canApplyReadySource()) { this.activateSource(this.desiredSource, video); @@ -224,7 +223,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.player.currentTime(this.startTime()); } this.playbackIssue.emit(null); - this.legacyAudioTracks?.bind(); + this.legacyTracks?.bind(); logVjsAudioTracks(this.player); setupVjsAudioTrackMenu(this.player); this.controlsBridge?.refreshInputs(); @@ -271,7 +270,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { private readonly handlePlayerReset = () => { this.player.volume(this.resetCoordinator.handlePlayerReset()); this.controlsBridge?.clearSource(); - this.legacyAudioTracks?.clear(); + this.legacyTracks?.clear(); this.mpegTsSession.destroy(); this.restoreDesiredSourceAfterReset(true); }; @@ -283,7 +282,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { private changeSource(source: VideoPlayerSource | null): void { this.playbackIssue.emit(null); this.controlsBridge?.clearSource(); - this.legacyAudioTracks?.clear(); + this.legacyTracks?.clear(); this.mpegTsSession.destroy(); this.desiredSource = source; this.resetCoordinator.clearSourceApplied(); @@ -325,7 +324,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.player.src(source); this.controlsBridge?.setSource(); - this.legacyAudioTracks?.bind(); + this.legacyTracks?.bind(); this.resetCoordinator.markSourceApplied(); } @@ -338,7 +337,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { return; } this.controlsBridge?.setSource(); - this.legacyAudioTracks?.bind(); + this.legacyTracks?.bind(); if (this.mpegTsSession.isSupportedSource(source.src)) { debugVjsPlayer('Using mpegts.js for TS stream:', source.src); this.mpegTsSession.start(source.src, video); @@ -368,15 +367,15 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { return video; } - private initializeLegacyAudioTracks(): void { - if (this.sharedControls || this.legacyAudioTracks) { + private initializeLegacyTracks(): void { + if (this.sharedControls || this.legacyTracks) { return; } - this.legacyAudioTracks = new VjsAudioTracks({ + this.legacyTracks = new VjsLegacyTracks({ player: this.player, - refresh: () => undefined, + showCaptions: () => this.showCaptions(), }); - this.legacyAudioTracks.bind(); + this.legacyTracks.bind(); } private bindPlayerEvents(): void { diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts b/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts index 95937a78b..608e2740a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts @@ -21,6 +21,14 @@ export interface VjsTextTracksConfig { player: Pick; showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe. Its presence + * switches the caption preference from authoritative (shared controls, the + * default) to source-default: the preference seeds each new source and + * stops being enforced once the probe returns true, so the Video.js + * captions menu keeps working. Shared controls omit it. + */ + playbackStarted?: () => boolean; } export class VjsTextTracks { @@ -169,6 +177,11 @@ export class VjsTextTracks { } if (!this.config.showCaptions()) { + if (this.config.playbackStarted?.()) { + // Source-default mode: the Video.js captions menu owns the + // selection once playback is running. + return; + } const selectedTrack = tracks.find( ({ track }) => track.mode === 'showing' )?.track; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index dcd83c1d2..71b8b09b3 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -1,5 +1,5 @@ import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, input, output, signal } from '@angular/core'; +import { signal } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, @@ -19,6 +19,12 @@ import { import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; import { type PlaybackDiagnostic, PlaybackDiagnosticCode, @@ -32,69 +38,8 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); -@Component({ selector: 'app-vjs-player', template: '' }) -class StubVjsPlayerComponent { - readonly options = input(); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ selector: 'app-html-video-player', template: '' }) -class StubHtmlVideoPlayerComponent { - readonly channel = input(); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ selector: 'app-art-player', template: '' }) -class StubArtPlayerComponent { - readonly channel = input(); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ selector: 'app-embedded-mpv-player', template: '' }) -class StubEmbeddedMpvPlayerComponent { - readonly playback = input.required(); - readonly mediaTitle = input(null); - readonly recordingFolder = input(''); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - const webPlayerSharedControls = signal(false); +const showCaptionsSetting = signal(false); describe('WebPlayerViewComponent shared web controls metadata', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; @@ -108,6 +53,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { beforeEach(async () => { webPlayerSharedControls.set(false); + showCaptionsSetting.set(false); await TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Playthrough, @@ -125,7 +71,10 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { }, { provide: SettingsStore, - useValue: { webPlayerSharedControls }, + useValue: { + webPlayerSharedControls, + showCaptions: showCaptionsSetting, + }, }, ], }) @@ -212,8 +161,23 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { expect(htmlPlayer.isLive()).toBe(false); }); + // Regression for #1155: portal hosts never bound a showCaptions input, so + // the preference has to come from the settings store instead. + it('reads the caption preference from settings without a host binding', async () => { + showCaptionsSetting.set(true); + + const htmlPlayer = await renderHtmlPlayer(); + + expect(htmlPlayer.showCaptions()).toBe(true); + + showCaptionsSetting.set(false); + fixture.detectChanges(); + + expect(htmlPlayer.showCaptions()).toBe(false); + }); + it('passes resolved playback metadata and caption preference to Video.js', async () => { - fixture.componentRef.setInput('showCaptions', true); + showCaptionsSetting.set(true); const vjsPlayer = await renderVjsPlayer({ isLive: false }); @@ -225,7 +189,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { }); it('passes resolved playback metadata and diagnostic interaction state to ArtPlayer', async () => { - fixture.componentRef.setInput('showCaptions', true); + showCaptionsSetting.set(true); const artPlayer = await renderArtPlayer({ isLive: false }); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index c09a866f9..e1c8a0c34 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -85,13 +85,13 @@ function resolveWebPlayerSharedControls(): boolean { export class WebPlayerViewComponent { storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsStore = inject(SettingsStore); streamUrl = input.required(); title = input(''); playback = input(null); startTime = input(0); volume = input(1); - showCaptions = input(false); playerOverride = input(null); seriesNavigation = input(null); /** Display-ready title lines for the fullscreen overlay; hosts with richer @@ -110,6 +110,16 @@ export class WebPlayerViewComponent { Settings | undefined >; + /** + * Subtitle preference for the built-in web players. Read from the settings + * store instead of an input so every host (M3U, Xtream, Stalker, portal + * detail pages) gets it without having to wire it through — the missing + * bindings were why the setting looked like a no-op (#1155). + */ + readonly showCaptions = computed( + () => this.settingsStore.showCaptions?.() ?? false + ); + channel!: Channel; vjsOptions!: { isLive: boolean; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts new file mode 100644 index 000000000..553ad2646 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -0,0 +1,70 @@ +import { Component, input, output } from '@angular/core'; +import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; + +/** + * Player stand-ins for WebPlayerViewComponent specs. They mirror the real + * component inputs so a spec can assert what the host hands each engine + * without pulling in video.js, ArtPlayer or the Electron bridge. + */ + +@Component({ selector: 'app-vjs-player', template: '' }) +export class StubVjsPlayerComponent { + readonly options = input(); + readonly mediaTitle = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly interactionEnabled = input(true); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +@Component({ selector: 'app-html-video-player', template: '' }) +export class StubHtmlVideoPlayerComponent { + readonly channel = input(); + readonly mediaTitle = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly isLive = input(true); + readonly interactionEnabled = input(true); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +@Component({ selector: 'app-art-player', template: '' }) +export class StubArtPlayerComponent { + readonly channel = input(); + readonly mediaTitle = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly isLive = input(true); + readonly interactionEnabled = input(true); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +@Component({ selector: 'app-embedded-mpv-player', template: '' }) +export class StubEmbeddedMpvPlayerComponent { + readonly playback = input.required(); + readonly mediaTitle = input(null); + readonly recordingFolder = input(''); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts b/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts index d1e5d47aa..30fe1862c 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts @@ -4,6 +4,15 @@ import type { PlayerTrack } from '../player-controls/player-controls.model'; export interface WebVideoHlsControlsConfig { showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe. Its presence + * switches the caption preference from authoritative (shared controls, the + * default) to source-default: the preference seeds each new source and + * stops being enforced once the probe returns true, so the engine's own + * caption menu keeps working. Shared controls omit it — they own the + * caption UI themselves and route user intent through `setSubtitleTrack`. + */ + playbackStarted?: () => boolean; } export class WebVideoHlsControls { @@ -125,14 +134,25 @@ export class WebVideoHlsControls { } if (!this.config.showCaptions()) { + const selectedTrack = this.readSelectedSubtitleTrack(); + if (this.config.playbackStarted) { + if (this.config.playbackStarted() || selectedTrack === null) { + return; + } + // Deselect instead of hiding: hls.js keeps `subtitleDisplay` + // authoritative over the track the vendor caption menu picks, + // so suppressing display would make that menu inert. A `-1` + // assignment also clears hls.js' own default-track selection, + // so it will not reselect one behind the user's back. + this.suppressedSubtitleTrack = selectedTrack; + this.setSubtitleTrackValue(-1); + return; + } + // HLS may choose its default track while display is suppressed. // Explicit user-off remains owned by subtitleOverride === -1 above. - if ( - Number.isInteger(this.hls.subtitleTrack) && - this.hls.subtitleTrack >= 0 && - this.hls.subtitleTrack < this.hls.subtitleTracks.length - ) { - this.suppressedSubtitleTrack = this.hls.subtitleTrack; + if (selectedTrack !== null) { + this.suppressedSubtitleTrack = selectedTrack; } this.setSubtitleDisplay(false); return; @@ -149,6 +169,19 @@ export class WebVideoHlsControls { } } + /** Current subtitle index, or null when nothing valid is selected. */ + private readSelectedSubtitleTrack(): number | null { + const hls = this.hls; + if (!hls) { + return null; + } + return Number.isInteger(hls.subtitleTrack) && + hls.subtitleTrack >= 0 && + hls.subtitleTrack < hls.subtitleTracks.length + ? hls.subtitleTrack + : null; + } + private setSubtitleTrackValue(id: number): void { if (this.hls && this.hls.subtitleTrack !== id) { this.hls.subtitleTrack = id; diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts b/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts index 744f1a781..2da4d985c 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts @@ -11,6 +11,14 @@ export interface WebVideoNativeTextTracksConfig { video: HTMLVideoElement; showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe. Its presence + * switches the caption preference from authoritative (shared controls, the + * default) to source-default: the preference seeds each new source and + * stops being enforced once the probe returns true, so the native caption + * menu keeps working. Shared controls omit it. + */ + playbackStarted?: () => boolean; } export class WebVideoNativeTextTracks { @@ -138,6 +146,11 @@ export class WebVideoNativeTextTracks { } if (!this.config.showCaptions()) { + if (this.config.playbackStarted?.()) { + // Source-default mode: the native caption menu owns the + // selection once playback is running. + return; + } for (const { track } of tracks) { if (track.mode !== 'showing') { continue; diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts index a59da9f04..6dcd6c642 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts @@ -9,7 +9,7 @@ import { WebVideoShakaControls } from './web-video-shaka-controls'; describe('WebVideoShakaControls', () => { const video = {} as HTMLVideoElement; - function createHarness() { + function createHarness(playbackStarted?: () => boolean) { const environment = createFakeShakaEnvironment({ onCreate: (player) => { player.textTracks = [ @@ -33,10 +33,45 @@ describe('WebVideoShakaControls', () => { const controls = new WebVideoShakaControls({ showCaptions: () => state.showCaptions, refresh: () => undefined, + playbackStarted, }); return { environment, session, controls, state }; } + // Vendor-chrome hosts seed the source through ShakaVideoSession.start() + // and then stop enforcing, mirroring the HLS and native helpers. + it('stops re-suppressing text once playback started in source-default mode', async () => { + const settled = { value: false }; + const { environment, session, controls } = createHarness( + () => settled.value + ); + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + expect(player.textTracks[0].active).toBe(false); + + settled.value = true; + player.selectTextTrack(player.textTracks[0]); + controls.refreshInputs(); + + expect(player.textTracks[0].active).toBe(true); + }); + + it('still seeds the source before playback started', async () => { + const { environment, session, controls } = createHarness(() => false); + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + player.selectTextTrack(player.textTracks[0]); + controls.refreshInputs(); + + expect(player.textTracks[0].active).toBe(false); + }); + it('restores the suppressed caption track when the preference turns on', async () => { const { environment, session, controls, state } = createHarness(); controls.bind(session); diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts index d66173bd4..78292aa70 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts @@ -8,6 +8,14 @@ import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; export interface WebVideoShakaControlsConfig { showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe, mirroring the HLS + * and native helpers. `ShakaVideoSession` already seeds the preference once + * the manifest is loaded, so re-suppressing on every later Shaka event + * would only override selections the host cannot see. Shared controls own + * the caption UI and omit it, staying authoritative. + */ + playbackStarted?: () => boolean; } const SUBTITLES_OFF = -1; @@ -117,9 +125,14 @@ export class WebVideoShakaControls { // off, and reselect the suppressed track when it turns back on. if (this.config.showCaptions()) { session.restoreSuppressedTextTrack(); - } else { - session.suppressTextTracks(); + return; } + if (this.config.playbackStarted?.()) { + // Source-default mode: the session already seeded this source at + // load time, so leave the running selection alone. + return; + } + session.suppressTextTracks(); } private getPlayer(): ShakaPlayerLike | null { diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts index 9368a48be..a1a92cc3a 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts @@ -1,16 +1,11 @@ -import type Hls from 'hls.js'; import type { PlayerTrack } from '../player-controls/player-controls.model'; import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter'; -import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; -import { WebVideoHlsControls } from './web-video-hls-controls'; -import { WebVideoNativeTextTracks } from './web-video-native-text-tracks'; -import { WebVideoShakaControls } from './web-video-shaka-controls'; +import { + type WebVideoControlsSource, + WebVideoSourceTracks, +} from './web-video-source-tracks'; -export type WebVideoControlsSource = - | { kind: 'native' } - | { kind: 'mpegts' } - | { kind: 'hls'; hls: Hls } - | { kind: 'shaka'; session: ShakaVideoSession }; +export type { WebVideoControlsSource }; export interface WebVideoSourceControlsBridgeConfig { video: HTMLVideoElement; @@ -21,28 +16,16 @@ export interface WebVideoSourceControlsBridgeConfig { export class WebVideoSourceControlsBridge { private readonly config: WebVideoSourceControlsBridgeConfig; - private readonly hlsControls: WebVideoHlsControls; - private readonly shakaControls: WebVideoShakaControls; - private readonly nativeTextTracks: WebVideoNativeTextTracks; - private source: WebVideoControlsSource | null = null; + private readonly tracks: WebVideoSourceTracks; private attached = false; private destroyed = false; constructor(config: WebVideoSourceControlsBridgeConfig) { this.config = config; - const refresh = () => this.config.adapter.refresh(); - this.hlsControls = new WebVideoHlsControls({ - showCaptions: config.showCaptions, - refresh, - }); - this.shakaControls = new WebVideoShakaControls({ - showCaptions: config.showCaptions, - refresh, - }); - this.nativeTextTracks = new WebVideoNativeTextTracks({ + this.tracks = new WebVideoSourceTracks({ video: config.video, showCaptions: config.showCaptions, - refresh, + refresh: () => this.config.adapter.refresh(), }); } @@ -67,15 +50,7 @@ export class WebVideoSourceControlsBridge { return; } - this.clearActiveSource(); - this.source = source; - if (source.kind === 'hls') { - this.hlsControls.bind(source.hls); - } else if (source.kind === 'shaka') { - this.shakaControls.bind(source.session); - } else { - this.nativeTextTracks.bind(); - } + this.tracks.setSource(source); this.config.adapter.refresh(); } @@ -84,13 +59,7 @@ export class WebVideoSourceControlsBridge { return; } - if (this.source?.kind === 'hls') { - this.hlsControls.refreshInputs(); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.refreshInputs(); - } else if (this.source) { - this.nativeTextTracks.refreshInputs(); - } + this.tracks.refreshInputs(); this.config.adapter.refresh(); } @@ -99,8 +68,7 @@ export class WebVideoSourceControlsBridge { return; } - this.clearActiveSource(); - this.source = null; + this.tracks.clearSource(); this.config.adapter.refresh(); } @@ -110,6 +78,7 @@ export class WebVideoSourceControlsBridge { } this.clearSource(); + this.tracks.destroy(); if (this.attached) { this.config.adapter.detach(); this.attached = false; @@ -118,7 +87,7 @@ export class WebVideoSourceControlsBridge { } readDuration(): number { - if (this.source?.kind !== 'mpegts' || this.config.isLive()) { + if (this.tracks.sourceKind !== 'mpegts' || this.config.isLive()) { return NaN; } @@ -142,51 +111,19 @@ export class WebVideoSourceControlsBridge { } private getAudioTracks(): PlayerTrack[] { - if (this.source?.kind === 'hls') { - return this.hlsControls.getAudioTracks(); - } - if (this.source?.kind === 'shaka') { - return this.shakaControls.getAudioTracks(); - } - return []; + return this.tracks.getAudioTracks(); } private setAudioTrack(id: number): void { - if (this.source?.kind === 'hls') { - this.hlsControls.setAudioTrack(id); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.setAudioTrack(id); - } + this.tracks.setAudioTrack(id); } private getSubtitleTracks(): PlayerTrack[] { - if (this.source?.kind === 'hls') { - return this.hlsControls.getSubtitleTracks(); - } - if (this.source?.kind === 'shaka') { - return this.shakaControls.getSubtitleTracks(); - } - return this.source ? this.nativeTextTracks.getSubtitleTracks() : []; + return this.tracks.getSubtitleTracks(); } private setSubtitleTrack(id: number): void { - if (this.source?.kind === 'hls') { - this.hlsControls.setSubtitleTrack(id); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.setSubtitleTrack(id); - } else if (this.source) { - this.nativeTextTracks.setSubtitleTrack(id); - } - } - - private clearActiveSource(): void { - if (this.source?.kind === 'hls') { - this.hlsControls.clear(); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.clear(); - } else if (this.source) { - this.nativeTextTracks.clear(); - } + this.tracks.setSubtitleTrack(id); } private readFinitePositive(read: () => number): number { diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts new file mode 100644 index 000000000..105703cf2 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts @@ -0,0 +1,188 @@ +import type Hls from 'hls.js'; +import type { PlayerTrack } from '../player-controls/player-controls.model'; +import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; +import { WebVideoHlsControls } from './web-video-hls-controls'; +import { WebVideoNativeTextTracks } from './web-video-native-text-tracks'; +import { WebVideoShakaControls } from './web-video-shaka-controls'; + +export type WebVideoControlsSource = + | { kind: 'native' } + | { kind: 'mpegts' } + | { kind: 'hls'; hls: Hls } + | { kind: 'shaka'; session: ShakaVideoSession }; + +export interface WebVideoSourceTracksConfig { + video: HTMLVideoElement; + showCaptions: () => boolean; + /** + * Notifies the owning controls UI that track state changed. Legacy players + * have no shared controls to refresh and leave it unset — they use this + * class purely to apply the caption preference. + */ + refresh?: () => void; + /** + * Set by hosts that keep the engine's own caption UI (the preference-off + * players). The caption preference then seeds each new source instead of + * staying authoritative, so the vendor caption menu keeps working once + * playback is running. Shared controls own the caption UI and omit it. + */ + vendorCaptionControls?: boolean; +} + +/** + * Owns the per-source audio/subtitle track controllers of a web video engine. + * + * It is deliberately free of any controls-UI dependency so both the shared + * controls bridge and the legacy (vendor-chrome) players apply the + * `showCaptions` preference through the exact same code path. + */ +export class WebVideoSourceTracks { + private readonly config: WebVideoSourceTracksConfig; + private readonly hlsControls: WebVideoHlsControls; + private readonly shakaControls: WebVideoShakaControls; + private readonly nativeTextTracks: WebVideoNativeTextTracks; + private source: WebVideoControlsSource | null = null; + private playbackStarted = false; + private playingListener: (() => void) | null = null; + private destroyed = false; + + constructor(config: WebVideoSourceTracksConfig) { + this.config = config; + const refresh = () => config.refresh?.(); + const playbackStarted = config.vendorCaptionControls + ? () => this.playbackStarted + : undefined; + this.hlsControls = new WebVideoHlsControls({ + showCaptions: config.showCaptions, + refresh, + playbackStarted, + }); + this.shakaControls = new WebVideoShakaControls({ + showCaptions: config.showCaptions, + refresh, + playbackStarted, + }); + this.nativeTextTracks = new WebVideoNativeTextTracks({ + video: config.video, + showCaptions: config.showCaptions, + refresh, + playbackStarted, + }); + if (config.vendorCaptionControls) { + const listener = () => { + this.playbackStarted = true; + }; + this.playingListener = listener; + config.video.addEventListener('playing', listener); + } + } + + get sourceKind(): WebVideoControlsSource['kind'] | null { + return this.source?.kind ?? null; + } + + setSource(source: WebVideoControlsSource): void { + if (this.destroyed) { + return; + } + + this.clearActiveSource(); + this.source = source; + // A new source starts unsettled so its own defaults are seeded again. + this.playbackStarted = false; + if (source.kind === 'hls') { + this.hlsControls.bind(source.hls); + } else if (source.kind === 'shaka') { + this.shakaControls.bind(source.session); + } else { + this.nativeTextTracks.bind(); + } + } + + refreshInputs(): void { + if (this.destroyed) { + return; + } + + if (this.source?.kind === 'hls') { + this.hlsControls.refreshInputs(); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.refreshInputs(); + } else if (this.source) { + this.nativeTextTracks.refreshInputs(); + } + } + + clearSource(): void { + if (this.destroyed) { + return; + } + + this.clearActiveSource(); + this.source = null; + } + + destroy(): void { + if (this.destroyed) { + return; + } + + this.clearSource(); + if (this.playingListener) { + this.config.video.removeEventListener( + 'playing', + this.playingListener + ); + this.playingListener = null; + } + this.destroyed = true; + } + + getAudioTracks(): PlayerTrack[] { + if (this.source?.kind === 'hls') { + return this.hlsControls.getAudioTracks(); + } + if (this.source?.kind === 'shaka') { + return this.shakaControls.getAudioTracks(); + } + return []; + } + + setAudioTrack(id: number): void { + if (this.source?.kind === 'hls') { + this.hlsControls.setAudioTrack(id); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.setAudioTrack(id); + } + } + + getSubtitleTracks(): PlayerTrack[] { + if (this.source?.kind === 'hls') { + return this.hlsControls.getSubtitleTracks(); + } + if (this.source?.kind === 'shaka') { + return this.shakaControls.getSubtitleTracks(); + } + return this.source ? this.nativeTextTracks.getSubtitleTracks() : []; + } + + setSubtitleTrack(id: number): void { + if (this.source?.kind === 'hls') { + this.hlsControls.setSubtitleTrack(id); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.setSubtitleTrack(id); + } else if (this.source) { + this.nativeTextTracks.setSubtitleTrack(id); + } + } + + private clearActiveSource(): void { + if (this.source?.kind === 'hls') { + this.hlsControls.clear(); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.clear(); + } else if (this.source) { + this.nativeTextTracks.clear(); + } + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index b4d59da5b..50e1e1e23 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -17,6 +17,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util'; type="button" class="nav-item settings-section-item" [class.active]="ctx.activeSection() === section.id" + [attr.data-test-id]="'settings-section-' + section.id" (click)="ctx.navigateToSection(section.id)" > {{ section.icon }} diff --git a/package.json b/package.json index 972020302..1362ed54b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "release:notes:github": "node tools/release/build-release-notes.mjs --format github", "release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog", "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", + "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" }, @@ -217,16 +218,21 @@ "pnpm": { "overrides": { "@hono/node-server@1.19.9": "1.19.14", + "@xmldom/xmldom@0.8.11": "0.8.13", "ajv@6.12.6": "6.14.0", + "ajv@8.17.1": "8.18.0", "brace-expansion@1.1.12": "1.1.13", "defu@6.1.4": "6.1.6", "devalue@5.6.2": "5.6.4", "express-rate-limit@8.2.1": "8.3.0", + "fast-uri@3.1.0": "3.1.4", "flatted@3.3.3": "3.4.2", "follow-redirects@1.15.11": "1.16.0", + "form-data@4.0.5": "4.0.6", "h3@1.15.5": "1.15.10", "hono@4.12.0": "4.12.14", "immutable@5.1.4": "5.1.5", + "js-yaml@4.1.1": "4.3.0", "lodash-es@4.17.22": "4.18.1", "node-abi@3.85.0": "3.92.0", "node-forge@1.3.3": "1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5659b2ab..06aa48366 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,16 +6,21 @@ settings: overrides: '@hono/node-server@1.19.9': 1.19.14 + '@xmldom/xmldom@0.8.11': 0.8.13 ajv@6.12.6: 6.14.0 + ajv@8.17.1: 8.18.0 brace-expansion@1.1.12: 1.1.13 defu@6.1.4: 6.1.6 devalue@5.6.2: 5.6.4 express-rate-limit@8.2.1: 8.3.0 + fast-uri@3.1.0: 3.1.4 flatted@3.3.3: 3.4.2 follow-redirects@1.15.11: 1.16.0 + form-data@4.0.5: 4.0.6 h3@1.15.5: 1.15.10 hono@4.12.0: 4.12.14 immutable@5.1.4: 5.1.5 + js-yaml@4.1.1: 4.3.0 lodash-es@4.17.22: 4.18.1 node-abi@3.85.0: 3.92.0 node-forge@1.3.3: 1.4.0 @@ -4712,10 +4717,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4806,7 +4810,7 @@ packages: ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -4814,7 +4818,7 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -4827,14 +4831,11 @@ packages: ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: - ajv: ^8.8.2 + ajv: 8.18.0 ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -6498,8 +6499,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -6620,8 +6621,8 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} forwarded@0.2.0: @@ -6871,6 +6872,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -7608,8 +7613,8 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsdom@26.1.0: @@ -11278,8 +11283,8 @@ snapshots: '@angular-devkit/core@21.1.4(chokidar@5.0.0)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 picomatch: 4.0.3 rxjs: 7.8.2 @@ -11595,7 +11600,7 @@ snapshots: hast-util-from-html: 2.0.3 hast-util-to-text: 4.0.2 import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 @@ -11621,7 +11626,7 @@ snapshots: hast-util-from-html: 2.0.3 hast-util-to-text: 4.0.2 import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 @@ -13272,7 +13277,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -16104,7 +16109,7 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.13': {} '@xtuc/ieee754@1.2.0': {} @@ -16190,10 +16195,6 @@ snapshots: optionalDependencies: ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -16214,17 +16215,10 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -16335,7 +16329,7 @@ snapshots: hosted-git-info: 4.1.0 is-ci: 3.0.1 isbinaryfile: 5.0.7 - js-yaml: 4.1.1 + js-yaml: 4.3.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.1.1 @@ -16460,7 +16454,7 @@ snapshots: html-escaper: 3.0.3 http-cache-semantics: 4.2.0 import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 magic-string: 0.30.21 magicast: 0.5.2 mrmime: 2.0.1 @@ -16555,7 +16549,7 @@ snapshots: axios@1.15.0: dependencies: follow-redirects: 1.16.0(debug@4.4.3) - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -16563,7 +16557,7 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0(debug@4.4.3) - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -16894,7 +16888,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-ci: 3.0.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 sanitize-filename: 1.6.3 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -17269,7 +17263,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -17279,7 +17273,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -17581,7 +17575,7 @@ snapshots: builder-util-runtime: 9.3.1 fs-extra: 10.1.0 iconv-lite: 0.6.3 - js-yaml: 4.1.1 + js-yaml: 4.3.0 optionalDependencies: dmg-license: 1.0.11 transitivePeerDependencies: @@ -17702,7 +17696,7 @@ snapshots: electron-conf@1.3.0(electron@41.7.2): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 electron: 41.7.2 electron-playwright-helpers@1.8.2: @@ -17715,7 +17709,7 @@ snapshots: builder-util: 26.0.11 builder-util-runtime: 9.3.1 chalk: 4.1.2 - form-data: 4.0.5 + form-data: 4.0.6 fs-extra: 10.1.0 lazy-val: 1.0.5 mime: 2.6.0 @@ -17730,7 +17724,7 @@ snapshots: dependencies: builder-util-runtime: 9.7.0 fs-extra: 10.1.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -18379,7 +18373,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.4: {} fastq@1.20.1: dependencies: @@ -18520,12 +18514,12 @@ snapshots: typescript: 5.9.3 webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -18806,6 +18800,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -19832,7 +19830,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -20786,14 +20784,14 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 '@videojs/vhs-utils': 3.0.5 - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 global: 4.4.0 mpd-parser@1.3.1: dependencies: '@babel/runtime': 7.28.4 '@videojs/vhs-utils': 4.1.1 - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 global: 4.4.0 mpegts.js@1.8.0: @@ -21098,7 +21096,7 @@ snapshots: figures: 3.2.0 flat: 5.0.2 follow-redirects: 1.16.0(debug@4.4.3) - form-data: 4.0.5 + form-data: 4.0.6 fs-constants: 1.0.0 function-bind: 1.1.2 get-caller-file: 2.0.5 @@ -21552,7 +21550,7 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 6ef2f9985..73258275c 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -130,6 +130,5 @@ export const maxLinesBaseline = [ 'libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts', 'libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts', 'libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts', - 'tools/release/capture-v020-screenshots.ts', 'tools/release/generate-marketing-artwork.ts', ]; diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs index 7f7a27de1..83745334b 100644 --- a/tools/release/build-release-notes.mjs +++ b/tools/release/build-release-notes.mjs @@ -19,6 +19,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { loadNotes } from './release-notes.mjs'; +import { manifestSlugs } from './screenshot-guards.mjs'; import { releaseSlug, renderBlogScaffold, @@ -232,6 +233,25 @@ function main() { const notesDir = path.resolve(workspaceRoot, options.dir); const { notes, errors } = loadNotes(notesDir); + // `screenshot:` slugs must exist in the capture manifest, otherwise the + // blog scaffold would reference images the capture run never produces. + const slugs = manifestSlugs( + JSON.parse( + readFileSync( + path.join(workspaceRoot, 'tools/release/screenshots.manifest.json'), + 'utf8' + ) + ) + ); + + for (const note of notes) { + if (note.screenshot && !slugs.has(note.screenshot)) { + errors.push( + `${path.basename(note.sourcePath)}: \`screenshot: ${note.screenshot}\` is not a slug in tools/release/screenshots.manifest.json` + ); + } + } + if (errors.length > 0) { console.error('Invalid release notes:\n'); for (const error of errors) { diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts new file mode 100644 index 000000000..bd643d07f --- /dev/null +++ b/tools/release/capture-app-driver.ts @@ -0,0 +1,361 @@ +/** + * App driving for capture-release-screenshots.ts: launch, readiness, demo + * seeding, theme switching, and the named setup actions the manifest refers + * to. Mechanics proven in the v0.20 capture run, generalized behind action + * names. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from '@playwright/test'; + +import { registerPlaylistId, requirePlaylistId } from './capture-navigation'; + +export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211'; +export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo'; +export const M3U_FIXTURE_TITLE = 'release-demo'; + +/** Synthetic categories that only the marketing fixture generator produces. */ +const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama']; + + +/* ------------------------------------------------------------------ */ +/* Fixtures */ +/* ------------------------------------------------------------------ */ + +/** Entirely synthetic channels; streams and logos point at the mock. */ +export function writeM3uFixture(dataDir: string): string { + const channels = [ + ['Newsroom', 'Aurora Local', 'aurora-local'], + ['Newsroom', 'Civic Pulse', 'civic-pulse'], + ['Sports', 'Fieldside One', 'fieldside-one'], + ['Sports', 'Motion Arena', 'motion-arena'], + ['Kids', 'Horizon Kids', 'horizon-kids'], + ['Kids', 'Story Lantern', 'story-lantern'], + ['Culture', 'Atlas Culture', 'atlas-culture'], + ['Culture', 'Night Music', 'night-music'], + ]; + const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`; + const lines = ['#EXTM3U']; + + channels.forEach(([group, title, slug], index) => { + lines.push( + `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`, + stream + ); + }); + + const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`); + writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + return filePath; +} + +export async function ensureXtreamMockServer( + workspaceRoot: string +): Promise { + const healthUrl = `${XTREAM_MOCK_ORIGIN}/health`; + + // Reusing whatever answers on the port is not enough: every guard treats + // localhost as trusted, so an unrelated local server or proxy could feed + // real catalog data and artwork straight into published screenshots. + // Require the marketing fixtures this capture is built around. + if (await isHealthy(healthUrl)) { + await assertMockServerIdentity(); + return undefined; + } + + const child = spawn( + path.join(workspaceRoot, 'node_modules/.bin/tsx'), + ['apps/xtream-mock-server/src/main.ts'], + { + cwd: workspaceRoot, + env: { ...process.env, NODE_ENV: 'development', PORT: '3211' }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + child.stderr?.on('data', (chunk) => + process.stderr.write(`[xtream-mock] ${chunk}`) + ); + + const deadline = Date.now() + 20_000; + + while (Date.now() < deadline) { + if (await isHealthy(healthUrl)) { + return child; + } + await sleep(500); + } + + child.kill('SIGTERM'); + throw new Error(`xtream-mock-server did not become healthy at ${healthUrl}`); +} + +/** + * Confirms the service on the mock port is our fixture server: it must serve + * the marketing catalog with the exact synthetic titles the shots rely on. + */ +async function assertMockServerIdentity(): Promise { + const response = await fetch( + `${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=get_vod_categories` + ).catch(() => null); + + if (!response?.ok) { + throw new Error( + `Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.` + ); + } + + const categories = (await response.json().catch(() => null)) as + | { category_name?: string }[] + | null; + const names = new Set( + (categories ?? []).map((entry) => entry.category_name) + ); + + for (const expected of MOCK_FIXTURE_CATEGORIES) { + if (!names.has(expected)) { + throw new Error( + `The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing category "${expected}"). Refusing to capture screenshots from unknown data.` + ); + } + } +} + +async function isHealthy(url: string): Promise { + try { + return (await fetch(url)).ok; + } catch { + return false; + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/* ------------------------------------------------------------------ */ +/* Launch and readiness */ +/* ------------------------------------------------------------------ */ + +export async function launchApp( + electronMainPath: string, + env: Record, + hostResolverRules: string +): Promise { + // The resolver switch is the only part of the network gate with no + // install-timing window: it takes effect before Electron runs a single + // line of app code, so startup traffic cannot slip through ahead of the + // session hook. + return electron.launch({ + args: [ + electronMainPath, + `--host-resolver-rules=${hostResolverRules}`, + ], + env, + }); +} + +export async function findMainWindow(app: ElectronApplication): Promise { + await sleep(1500); + + for (const candidate of app.windows()) { + if (!(await candidate.title()).includes('DevTools')) { + return candidate; + } + } + + return app.firstWindow(); +} + +export async function sizeWindow( + app: ElectronApplication, + viewport: { width: number; height: number } +): Promise { + await app.evaluate(({ BrowserWindow }, size) => { + const win = BrowserWindow.getAllWindows().find( + (candidate) => !candidate.webContents.getTitle().includes('DevTools') + ); + win?.setSize(size.width, size.height); + win?.center(); + }, viewport); +} + +export async function waitForAppReady(page: Page): Promise { + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('app-root', { timeout: 45_000 }); + await page.waitForFunction( + () => + (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > + 0, + { timeout: 45_000 } + ); +} + +/* ------------------------------------------------------------------ */ +/* Seeding */ +/* ------------------------------------------------------------------ */ + +export async function seedDemoData(page: Page, m3uPath: string): Promise { + await addXtreamPortal(page); + await addM3uPlaylist(page, m3uPath); + await seedDashboardActivity(page); +} + +/** + * The dashboard hero and rails only render with favorites/recent activity. + * Seed a handful of mock titles through the Electron DB bridge; backdrops + * point at the mock server, keeping G3/G4 satisfied. + */ +async function seedDashboardActivity(page: Page): Promise { + const playlistId = requirePlaylistId('xtreams'); + const backdrop = (title: string) => + `${XTREAM_MOCK_ORIGIN}/assets/marketing/backdrop/${title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-')}.svg?size=${encodeURIComponent('1280x720')}`; + const items = [ + { xtreamId: 62000, type: 'movie', backdropUrl: backdrop('Crimson Skylark'), recent: true }, + { xtreamId: 62001, type: 'movie', backdropUrl: backdrop('The Voltage Guard'), recent: true }, + { xtreamId: 62002, type: 'movie', backdropUrl: backdrop('Midnight Mantle'), recent: false }, + { xtreamId: 72000, type: 'series', backdropUrl: backdrop('Skyline Sentinels'), recent: true }, + { xtreamId: 72001, type: 'series', backdropUrl: backdrop('The Aegis Club'), recent: false }, + ] as const; + + await page.evaluate( + async ({ items, playlistId }) => { + const bridge = ( + window as typeof window & { + electron?: { + dbAddFavorite?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; + dbAddRecentItem?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; + dbGetContentByXtreamId?: ( + xtreamId: number, + playlistId: string, + contentType?: 'live' | 'movie' | 'series' + ) => Promise<{ id: number } | null>; + }; + } + ).electron; + + if (!bridge?.dbGetContentByXtreamId) { + throw new Error('Electron database bridge is unavailable.'); + } + + for (const item of items) { + const content = await bridge.dbGetContentByXtreamId( + item.xtreamId, + playlistId, + item.type + ); + + if (!content?.id) { + throw new Error( + `Could not find imported Xtream content ${item.xtreamId}.` + ); + } + + await bridge.dbAddFavorite?.(content.id, playlistId, item.backdropUrl); + + if (item.recent) { + await bridge.dbAddRecentItem?.(content.id, playlistId, item.backdropUrl); + } + } + }, + { items, playlistId } + ); +} + +async function addXtreamPortal(page: Page): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + await clickDialogOption(dialog, /xtream credentials/i); + await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE); + await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN); + await dialog.locator('#username').fill('marketing'); + await dialog.locator('#password').fill('marketing'); + await dialog + .getByRole('button', { name: /^(add|add playlist)$/i }) + .last() + .click(); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { + timeout: 45_000, + }); + registerPlaylistId('xtreams', idFromUrl(page.url(), 'xtreams')); + await page + .locator('.category-content-layout, app-content-card') + .first() + .waitFor({ state: 'visible', timeout: 45_000 }); +} + +async function addM3uPlaylist(page: Page, m3uPath: string): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + // Unanchored: the radio's accessible name concatenates title + subtitle. + await clickDialogOption(dialog, /m3u file/i); + + const fileInput = dialog.locator('input[type="file"][name="playlist"]'); + await fileInput.setInputFiles(m3uPath); + await dialog + .getByRole('button', { name: /add playlist/i }) + .last() + .click({ timeout: 15_000 }); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { + timeout: 45_000, + }); + registerPlaylistId('playlists', idFromUrl(page.url(), 'playlists')); + await page + .locator('[data-test-id="channel-item"]') + .first() + .waitFor({ state: 'visible', timeout: 60_000 }); +} + +async function openAddPlaylistDialog(page: Page): Promise { + await page.getByRole('button', { name: /add playlist/i }).first().click(); + await page + .locator('mat-dialog-container') + .last() + .waitFor({ state: 'visible', timeout: 15_000 }); +} + +async function clickDialogOption( + dialog: ReturnType, + label: RegExp +): Promise { + // The add-playlist dialog has changed shape across releases: source + // methods were tabs, then plain buttons, now a radio group. + for (const role of ['radio', 'tab', 'button'] as const) { + const option = dialog.getByRole(role, { name: label }).first(); + + if ((await option.count()) > 0) { + await option.click(); + return; + } + } + + throw new Error(`Dialog option matching ${label} not found`); +} + + +function idFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { + // `provider` is a closed union, but build the pattern from a literal + // anyway so no future caller can inject regex syntax through it. + const pattern = + provider === 'playlists' + ? /\/workspace\/playlists\/([^/]+)\// + : /\/workspace\/xtreams\/([^/]+)\//; + const match = url.match(pattern); + + if (!match) { + throw new Error(`Could not extract ${provider} id from ${url}`); + } + + return match[1]; +} diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts new file mode 100644 index 000000000..9a8f033ed --- /dev/null +++ b/tools/release/capture-navigation.ts @@ -0,0 +1,250 @@ +/** + * Named setup actions for capture-release-screenshots.ts — the vocabulary + * that screenshots.manifest.json steps refer to — plus theme switching and + * the playlist-id registry the actions navigate with. + */ + +import type { Page } from '@playwright/test'; + +let m3uPlaylistId: string | undefined; +let xtreamPlaylistId: string | undefined; + +export function registerPlaylistId( + provider: 'playlists' | 'xtreams', + id: string +): void { + if (provider === 'playlists') { + m3uPlaylistId = id; + } else { + xtreamPlaylistId = id; + } +} + +export function requirePlaylistId( + provider: 'playlists' | 'xtreams' +): string { + return requireId(provider); +} + +function requireId(provider: 'playlists' | 'xtreams'): string { + const id = provider === 'playlists' ? m3uPlaylistId : xtreamPlaylistId; + + if (!id) { + throw new Error(`No captured ${provider} playlist id — seeding failed?`); + } + + return id; +} + +/* ------------------------------------------------------------------ */ +/* Theme */ +/* ------------------------------------------------------------------ */ + +export async function applyTheme( + page: Page, + theme: 'dark' | 'light' +): Promise { + await runAction(page, 'open-settings', null); + const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; + const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); + + await themeButton.scrollIntoViewIfNeeded(); + await themeButton.click(); + + const saveButton = page.locator('[data-test-id="save-settings"]').first(); + + if (await saveButton.isEnabled()) { + await saveButton.click(); + await settleUi(page); + } + + await page.waitForFunction( + (expectedTheme) => + document.body.classList.contains('dark-theme') === + (expectedTheme === 'dark'), + theme, + { timeout: 10_000 } + ); +} + +/* ------------------------------------------------------------------ */ +/* Named setup actions */ +/* ------------------------------------------------------------------ */ + +export async function runAction( + page: Page, + action: string, + param: string | null +): Promise { + switch (action) { + case 'open-settings': { + await page.locator('a[href$="/workspace/settings"]').first().click(); + await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); + await page + .locator('[data-test-id="settings-container"]') + .waitFor({ state: 'visible', timeout: 15_000 }); + return; + } + case 'open-dashboard': { + await page + .locator('a.brand[href$="/workspace/dashboard"]') + .first() + .click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await page + .locator('[data-test-id="dashboard-hero"]') + .waitFor({ state: 'visible', timeout: 30_000 }); + await settleUi(page); + return; + } + case 'open-xtream-vod': { + await openXtreamSection(page, 'vod', param ?? 'Action & Mystery'); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, + { timeout: 30_000 } + ); + await page + .locator('app-content-hero') + .waitFor({ state: 'visible', timeout: 30_000 }); + await page.waitForTimeout(700); + return; + } + case 'open-xtream-series': { + await openXtreamSection(page, 'series', param ?? 'Urban Drama'); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, + { timeout: 30_000 } + ); + await page + .locator('app-season-container') + .waitFor({ state: 'visible', timeout: 30_000 }); + + // Season tabs auto-select a season; click the first pill only + // when no episodes rendered on their own. + const episode = page + .locator('.episode-card, .episode-list-item') + .first(); + + if (!(await episode.isVisible().catch(() => false))) { + await page + .locator('.season-tabs__pill, [data-testid="season-dropdown"]') + .first() + .click(); + } + + await episode.waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForTimeout(700); + return; + } + case 'open-m3u-groups': { + const playlistId = requireId('playlists'); + + await goHome(page); + await page + .locator(`a[href*="/workspace/playlists/${playlistId}"]`) + .first() + .click(); + await page.waitForURL( + (url) => url.href.includes(`/workspace/playlists/${playlistId}/`), + { timeout: 20_000 } + ); + await clickHrefSuffix( + page, + `/workspace/playlists/${playlistId}/groups` + ); + await page + .locator('.group-nav-item') + .first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.locator('.group-nav-item').first().click(); + // Deliberately no channel click: starting playback would pull a + // real HLS stream (the mock redirects to a public demo stream), + // and third-party video frames must never enter a release shot. + await page + .locator('[data-test-id="channel-item"]') + .first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForTimeout(500); + return; + } + default: + throw new Error(`Unknown setup action: ${action}`); + } +} + +/** Returns to the dashboard via the always-visible brand link. */ +async function goHome(page: Page): Promise { + if (/\/workspace\/dashboard/.test(page.url())) { + return; + } + + await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await settleUi(page); +} + +async function openXtreamSection( + page: Page, + section: 'vod' | 'series', + category: string +): Promise { + // Manifest steps must be order-independent, so every portal action + // starts from the dashboard, whose sources rail links into the portal. + await goHome(page); + await clickHrefSuffix( + page, + `/workspace/xtreams/${requireId('xtreams')}/vod` + ); + + if (section !== 'vod') { + await clickHrefSuffix( + page, + `/workspace/xtreams/${requireId('xtreams')}/${section}` + ); + } + + const item = page + .locator('app-workspace-context-panel .category-item') + .filter({ hasText: category }) + .first(); + + await item.waitFor({ state: 'visible', timeout: 30_000 }); + await item.click(); + await page.waitForTimeout(600); + + const card = page.locator('.category-content-layout mat-card').first(); + await card.waitFor({ state: 'visible', timeout: 30_000 }); + await card.click(); +} + +async function clickHrefSuffix(page: Page, suffix: string): Promise { + await page.locator(`a[href$="${suffix}"]`).first().click(); + // Predicate rather than a RegExp built from the suffix: the value carries + // playlist ids and path separators, and hand-escaping only some + // metacharacters is how incomplete-sanitization bugs are born. + await page.waitForURL((url) => url.href.includes(suffix), { + timeout: 20_000, + }); +} + +export async function settleUi(page: Page): Promise { + await page + .locator('.mat-mdc-snack-bar-container') + .first() + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => undefined); + // Park the cursor so no nav item keeps its hover tooltip in frame. + await page.mouse.move(640, 700); + await page.evaluate(() => { + document + .querySelectorAll( + '.mat-mdc-snack-bar-container, simple-snack-bar, .mat-mdc-tooltip, .cdk-describedby-message-container' + ) + .forEach((element) => { + (element.closest('.cdk-overlay-pane') ?? element).remove(); + }); + }); + await page.waitForTimeout(250); +} + + diff --git a/tools/release/capture-network-gate.ts b/tools/release/capture-network-gate.ts new file mode 100644 index 000000000..6540e56e3 --- /dev/null +++ b/tools/release/capture-network-gate.ts @@ -0,0 +1,122 @@ +/** + * The Electron-side half of G3: a request gate in the main process covering + * both Chromium's network stack and Node's `fetch`. + * + * Known and unavoidable timing window: this can only be installed once + * `electron.launch()` resolves, and Playwright releases Electron's ready + * event before that — so a Node-stack request fired in the first + * milliseconds of `app.whenReady()` would escape it. It cannot be closed + * from the test side: Playwright deletes `NODE_OPTIONS` unconditionally + * (`playwright-core/lib/server/electron/electron.js`), which rules out a + * `--require` preload, and it exposes no pre-ready hook. Closing it entirely + * would mean holding renderer startup from inside `main.ts` — production code + * bent around a screenshot tool. + * + * What covers that window instead: + * - Chromium-stack traffic (renderer, main-process `net`) is blocked from + * process start by `--host-resolver-rules`, which has no window at all; + * - the only startup Node `fetch` in the app, the update check, returns + * early on `!app.isPackaged`, and the capture always runs unpackaged. + */ + +import type { ElectronApplication } from '@playwright/test'; + +type NetworkPolicy = { + schemes: string[]; + hosts: string[]; + protocols: string[]; + stubPrefixes: string[]; +}; + +/** + * Records and blocks at the Electron session level, from before the renderer + * boots. Blocking here as well as in the page route matters because + * `page.route` only covers renderer traffic: anything the main process fetches + * (updater, telemetry, a future feature) would otherwise reach the network and + * only be reported afterwards — too late to be a fail-closed boundary. + * + * Stubbed URLs are allowed through so the page-level route can fulfill them + * locally; every other non-local URL is cancelled outright. + */ +export async function installRequestRecorder( + app: ElectronApplication, + policy: NetworkPolicy +): Promise { + // The body is serialized into the Electron main process, where esbuild's + // `__name` helper does not exist — so it must contain no *named* inner + // function (`const isLocal = …` or `function isLocal()`), only anonymous + // callbacks. A named one throws `__name is not defined` at install time. + await app.evaluate(({ session }, appliedPolicy) => { + const store: string[] = []; + const scope = globalThis as unknown as { + __captureRequests: string[]; + __captureIsLocal: (url: string) => boolean; + fetch: typeof fetch; + }; + + scope.__captureRequests = store; + + // Assigned to a member expression on purpose: esbuild's keepNames + // wraps functions bound to an identifier with a `__name` helper that + // does not exist in this serialized context. + scope.__captureIsLocal = (url: string) => { + if (appliedPolicy.stubPrefixes.some((p) => url.startsWith(p))) { + return true; + } + + try { + const parsed = new URL(url); + + return ( + appliedPolicy.schemes.includes(parsed.protocol) || + (appliedPolicy.protocols.includes(parsed.protocol) && + appliedPolicy.hosts.includes(parsed.hostname)) + ); + } catch { + return appliedPolicy.schemes.some((s) => url.startsWith(s)); + } + }; + + session.defaultSession.webRequest.onBeforeRequest( + (details, callback) => { + store.push(details.url); + callback({ cancel: !scope.__captureIsLocal(details.url) }); + } + ); + + // Chromium's network stack — and therefore both the resolver switch + // and the webRequest hook — never sees main-process `fetch`, which + // runs on Node's stack. The app update service uses exactly that, so + // wrap it: record every URL and reject non-local ones outright. + const originalFetch = scope.fetch; + + scope.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + + store.push(url); + + if (!scope.__captureIsLocal(url)) { + return Promise.reject( + new Error(`capture network gate blocked ${url}`) + ); + } + + return originalFetch(input, init); + }; + }, policy); +} + +export async function drainRecordedRequests( + app: ElectronApplication +): Promise { + return app.evaluate( + () => + (globalThis as unknown as { __captureRequests?: string[] }) + .__captureRequests ?? [] + ); +} diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts new file mode 100644 index 000000000..83f7d6a6a --- /dev/null +++ b/tools/release/capture-release-screenshots.ts @@ -0,0 +1,358 @@ +/** + * Release screenshot capture, manifest-driven and fail-closed. + * + * pnpm release:screenshots # release slug from package.json + * pnpm release:screenshots --release v0-24 # explicit + * pnpm release:screenshots --only dashboard --theme dark + * + * Reads tools/release/screenshots.manifest.json and writes + * apps/website/public/blog//screenshots/-.png against + * dist builds + the xtream mock server. Guards (screenshot-guards.mjs): + * + * G1 the real ~/.iptvnator/databases directory — including the SQLite WAL + * sidecars, compared after Electron exits and checkpoints — is proven + * untouched + * G2 the app gets an allowlisted environment, never ...process.env + * G3 a main-process recorder (installed before the renderer boots) plus a + * page-level deny-by-default route; any external attempt fails the run + * G4 every frame is checked for external resources / credential text + * G5 TMDB stays disabled (fresh profile default, asserted via IndexedDB) + * + * Frames are staged outside the repository and published only after every + * shot and every guard has passed, so a failure can neither leave unsafe + * frames behind nor destroy previously committed release assets. + */ + +import { accessSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import type { Page } from '@playwright/test'; + +import { + buildCaptureEnv, + compareDatabaseStates, + evaluateFrameReport, + externalRequestViolations, + HOST_RESOLVER_RULES, + isAllowedRequestUrl, + networkPolicy, + parseSetupStep, + publishDirectory, + snapshotDatabaseState, + stubbedResponseFor, + validateManifest, + validateReleaseSlug, +} from './screenshot-guards.mjs'; +import * as driver from './capture-app-driver'; +import { applyTheme, runAction, settleUi } from './capture-navigation'; +import { assertTmdbDisabled } from './capture-tmdb-check'; +import { + drainRecordedRequests, + installRequestRecorder, +} from './capture-network-gate'; + +type Theme = 'dark' | 'light'; + +const workspaceRoot = process.cwd(); +const manifestPath = path.join( + workspaceRoot, + 'tools/release/screenshots.manifest.json' +); +const electronMainPath = path.join( + workspaceRoot, + 'dist/apps/electron-backend/main.js' +); +const realDbDir = path.join(homedir(), '.iptvnator/databases'); + +const args = process.argv.slice(2); +const flag = (name: string): string | null => { + const index = args.indexOf(`--${name}`); + + return index !== -1 ? (args[index + 1] ?? null) : null; +}; + +const blockedRequests: string[] = []; + +async function main(): Promise { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const manifestErrors = validateManifest(manifest); + + if (manifestErrors.length > 0) { + throw new Error(`Invalid manifest:\n ${manifestErrors.join('\n ')}`); + } + + const release = + flag('release') ?? `v${readAppVersion().split('.').slice(0, 2).join('-')}`; + const releaseError = validateReleaseSlug(release); + + if (releaseError) { + throw new Error(`--release rejected: ${releaseError}`); + } + + const only = flag('only'); + const themeFilter = flag('theme'); + const shots = manifest.shots.filter( + (shot: { slug: string }) => !only || shot.slug === only + ); + + if (shots.length === 0) { + throw new Error(`--only ${only} matches no manifest slug`); + } + + // Without this an erased cast would accept `--theme --only`, and every + // non-`dark` value silently renders as light into a misnamed file. + if (themeFilter && !manifest.themes.includes(themeFilter)) { + throw new Error( + `--theme "${themeFilter}" is not one of ${manifest.themes.join(', ')}` + ); + } + + const themes: Theme[] = themeFilter + ? [themeFilter as Theme] + : manifest.themes; + const blogRoot = path.join(workspaceRoot, 'apps/website/public/blog'); + const outputRoot = path.join(blogRoot, release, 'screenshots'); + + // Belt and braces: the slug is validated above, but assert the resolved + // path really lands inside the blog tree before anything deletes there. + assertSync( + path.resolve(outputRoot).startsWith(`${path.resolve(blogRoot)}${path.sep}`), + `refusing to publish outside the blog tree: ${outputRoot}` + ); + + assertBuiltRuntime(); + + // G1: snapshot the real database directory BEFORE anything launches. + const dbBefore = snapshotDatabaseState(realDbDir); + + // Frames are staged outside the repo and published only once every shot + // and every guard has passed, so a late failure can never destroy the + // release assets an earlier run already committed. + const stagingDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-shots-')); + const mockServer = await driver.ensureXtreamMockServer(workspaceRoot); + const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-')); + let app: Awaited> | undefined; + let recordedRequests: string[] = []; + let captured = 0; + let primaryError: unknown; + + try { + // G2: constructed environment — nothing ambient crosses over. + app = await driver.launchApp( + electronMainPath, + buildCaptureEnv(process.env, { + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }), + HOST_RESOLVER_RULES + ); + + // G3, first layer: a main-process gate, installed before the renderer + // can boot. It both records and blocks, because page-level routing + // exists only once a page handle is obtained and never covers what the + // main process itself fetches. + await installRequestRecorder(app, networkPolicy()); + + const page = await driver.findMainWindow(app); + + // G3, second layer: page-level deny-by-default, which also blocks. + await page.route('**/*', async (route) => { + const url = route.request().url(); + + if (isAllowedRequestUrl(url)) { + await route.continue(); + return; + } + + // Known app-level calls are answered locally so the run stays + // hermetic without failing on legitimate app behavior. + const stub = stubbedResponseFor(url); + + if (stub) { + await route.fulfill({ + body: stub.body, + contentType: stub.contentType, + }); + return; + } + + blockedRequests.push(url); + await route.abort(); + }); + + await driver.sizeWindow(app, manifest.viewport); + await driver.waitForAppReady(page); + await assertTmdbDisabled(page); // G5 + await driver.seedDemoData(page, driver.writeM3uFixture(dataDir)); + + for (const theme of themes) { + await applyTheme(page, theme); + + for (const shot of shots) { + for (const step of shot.setup) { + const { action, param } = parseSetupStep(String(step)); + await runAction(page, action, param); + } + + await captureShot(page, stagingDir, shot.slug, theme); + captured += 1; + } + } + + recordedRequests = await drainRecordedRequests(app); + + // A recorder that observes nothing is indistinguishable from a + // passing run, which is how a guard silently stops guarding. Assert + // it saw the renderer's own document load rather than merely "some" + // traffic: seeding requests alone would prove nothing about whether + // the hook existed before the renderer started fetching. + assertSync( + recordedRequests.some((url) => /\/index\.html?($|[?#])/.test(url)), + `G3 failed: the request recorder never saw the renderer document load (${recordedRequests.length} URL(s) recorded) — it was installed too late to cover startup` + ); + + // G3: an attempted external request means a fixture is wrong, not + // that we got away with it. + const external = externalRequestViolations([ + ...blockedRequests, + ...recordedRequests, + ]); + + if (external.length > 0) { + throw new Error( + `G3 failed: ${external.length} external request(s) were attempted:\n ${external + .slice(0, 15) + .join('\n ')}` + ); + } + + assertSync( + snapshotDatabaseState(path.join(dataDir, 'databases')).exists, + 'G1 failed: the isolated database was never created — the app did not honor IPTVNATOR_E2E_DATA_DIR' + ); + } catch (error) { + // Captured rather than rethrown: a failing run is exactly when a lost + // isolation override is most likely, so G1 must still be evaluated. + primaryError = error; + } finally { + // Close before the G1 comparison: SQLite runs in WAL mode, so writes + // may sit in -wal until the worker shuts down and checkpoints. + await app?.close().catch(() => undefined); + mockServer?.kill('SIGTERM'); + rmSync(dataDir, { recursive: true, force: true }); + } + + const dbViolation = compareDatabaseStates( + dbBefore, + snapshotDatabaseState(realDbDir) + ); + + if (dbViolation) { + rmSync(stagingDir, { recursive: true, force: true }); + throw new Error( + `G1 failed: ${dbViolation}${ + primaryError instanceof Error + ? `\n(raised while handling: ${primaryError.message})` + : '' + }` + ); + } + + if (primaryError) { + rmSync(stagingDir, { recursive: true, force: true }); + throw primaryError; + } + + // A filtered run refreshes a subset, so it must overlay rather than + // replace: publishing only the captured frames would delete every other + // screenshot of the release. + publishDirectory(stagingDir, outputRoot, String(process.pid), { + mode: only || themeFilter ? 'merge' : 'replace', + }); + rmSync(stagingDir, { recursive: true, force: true }); + console.log( + `Captured ${captured} screenshot(s) into ${path.relative(workspaceRoot, outputRoot)}` + ); + console.log( + `Guards passed: ${new Set(recordedRequests).size} distinct request(s) observed, all local.` + ); +} + +async function captureShot( + page: Page, + outputRoot: string, + slug: string, + theme: Theme +): Promise { + await settleUi(page); + + // G4: inspect the frame before trusting it. + const report = await page.evaluate(() => { + const urls = new Set(); + + document.querySelectorAll('img[src]').forEach((img) => { + urls.add((img as HTMLImageElement).src); + }); + document.querySelectorAll('*').forEach((element) => { + const background = getComputedStyle(element).backgroundImage; + const match = background?.match(/url\("?([^")]+)"?\)/); + + if (match) { + urls.add(match[1]); + } + }); + + return { + resourceUrls: [...urls], + bodyText: document.body.innerText, + }; + }); + + const violations = evaluateFrameReport(report); + + if (violations.length > 0) { + throw new Error( + `G4 failed on ${slug} (${theme}):\n ${violations.join('\n ')}` + ); + } + + await page.screenshot({ + path: path.join(outputRoot, `${slug}-${theme}.png`), + type: 'png', + }); + console.log(` ✓ ${slug} (${theme})`); +} + +function assertBuiltRuntime(): void { + for (const required of [ + electronMainPath, + path.join(workspaceRoot, 'dist/apps/web/index.html'), + ]) { + try { + accessSync(required); + } catch { + throw new Error( + `Built runtime missing: ${required}\nBuild it first: pnpm nx run electron-backend:build-e2e` + ); + } + } +} + +function readAppVersion(): string { + return JSON.parse( + readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') + ).version; +} + +function assertSync(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tools/release/capture-tmdb-check.ts b/tools/release/capture-tmdb-check.ts new file mode 100644 index 000000000..8f0df00b6 --- /dev/null +++ b/tools/release/capture-tmdb-check.ts @@ -0,0 +1,56 @@ +/** + * G5: proof that TMDB enrichment stays off for the capture profile, so no + * licensed poster or still can reach a published screenshot. + */ + +import type { Page } from '@playwright/test'; + +/** + * G5: settings live in the renderer's IndexedDB (ngx-pwa StorageMap). A + * fresh profile has no entry, which means the TMDB defaults (disabled, + * empty key) apply. Read-only assertion — if a future change flips the + * default or seeds a key, the run stops before a licensed poster can render. + */ +export async function assertTmdbDisabled(page: Page): Promise { + const tmdb = await page.evaluate( + () => + new Promise<{ enabled?: boolean; apiKey?: string } | null>( + (resolve) => { + const request = indexedDB.open('ngStorage'); + + request.onerror = () => resolve(null); + request.onsuccess = () => { + const db = request.result; + + if (!db.objectStoreNames.contains('localStorage')) { + resolve(null); + return; + } + + const get = db + .transaction('localStorage') + .objectStore('localStorage') + .get('settings'); + + get.onerror = () => resolve(null); + get.onsuccess = () => + resolve( + (get.result as { tmdb?: { enabled?: boolean; apiKey?: string } }) + ?.tmdb ?? null + ); + }; + } + ) + ); + + assertSync( + !tmdb?.enabled && !tmdb?.apiKey, + `G5 failed: TMDB enrichment is active in the capture profile (${JSON.stringify(tmdb)})` + ); +} + +function assertSync(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/tools/release/capture-v020-screenshots.ts b/tools/release/capture-v020-screenshots.ts deleted file mode 100644 index 8d19387ef..000000000 --- a/tools/release/capture-v020-screenshots.ts +++ /dev/null @@ -1,1169 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process'; -import { - accessSync, - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { - _electron as electron, - type ElectronApplication, - type Page, -} from '@playwright/test'; - -type ThemeName = 'dark' | 'light'; -type ScreenshotSlug = - | 'dashboard' - | 'm3u-live-groups-two-column' - | 'settings' - | 'xtream-series-season-open' - | 'xtream-vod-details'; - -type CaptureTarget = { - slug: ScreenshotSlug; - title: string; - kicker: string; -}; - -const workspaceRoot = process.cwd(); -const electronMainPath = path.join( - workspaceRoot, - 'dist/apps/electron-backend/main.js' -); -const rendererIndexPath = path.join(workspaceRoot, 'dist/apps/web/index.html'); -const xtreamMockOrigin = 'http://localhost:3211'; -const outputRoot = path.join( - workspaceRoot, - 'apps/website/public/blog/v0-20/screenshots' -); -const heroOutputPath = path.join( - workspaceRoot, - 'apps/website/public/blog/v0-20/v0-20-hero.png' -); -const viewport = { width: 1280, height: 720 }; -const captureTargets: CaptureTarget[] = [ - { - slug: 'dashboard', - title: 'Content-first dashboard', - kicker: 'New in v0.20', - }, - { - slug: 'xtream-vod-details', - title: 'Richer movie details', - kicker: 'Xtream', - }, - { - slug: 'xtream-series-season-open', - title: 'Series seasons and episodes', - kicker: 'Xtream', - }, - { - slug: 'settings', - title: 'Reworked settings', - kicker: 'Customization', - }, - { - slug: 'm3u-live-groups-two-column', - title: 'Two-column live TV', - kicker: 'M3U', - }, -]; -let capturedM3uPlaylistId: string | undefined; -let capturedXtreamPlaylistId: string | undefined; - -async function main(): Promise { - assertBuiltRuntimeExists(); - - mkdirSync(outputRoot, { recursive: true }); - const managedMockServer = await ensureXtreamMockServer(); - const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-v020-shots-')); - const m3uFixturePath = writeM3uPlaylistFixture(dataDir); - let app: ElectronApplication | undefined; - - try { - app = await launchApp(dataDir); - const page = await findMainWindow(app); - await sizeElectronWindow(app); - await waitForAppReady(page); - await seedDemoData(page, m3uFixturePath); - - for (const theme of ['dark', 'light'] as ThemeName[]) { - await applyTheme(page, theme); - await captureThemeSet(page, theme); - } - - await createHeroImage(page); - await verifyExpectedAssets(); - } finally { - await app?.close().catch(() => undefined); - managedMockServer?.kill('SIGTERM'); - rmSync(dataDir, { recursive: true, force: true }); - } -} - -function assertBuiltRuntimeExists(): void { - if (!fileExists(electronMainPath) || !fileExists(rendererIndexPath)) { - throw new Error( - [ - 'Electron E2E runtime is missing.', - `Expected ${electronMainPath}`, - `Expected ${rendererIndexPath}`, - 'Build it first, for example: pnpm nx run electron-backend:build-e2e', - ].join('\n') - ); - } -} - -async function ensureXtreamMockServer(): Promise { - if (await isHealthy(`${xtreamMockOrigin}/health`)) { - return undefined; - } - - const child = spawn( - path.join(workspaceRoot, 'node_modules/.bin/tsx'), - [ - '--tsconfig', - 'tsconfig.base.json', - 'apps/xtream-mock-server/src/main.ts', - ], - { - cwd: workspaceRoot, - env: { - ...process.env, - NODE_ENV: 'development', - PORT: '3211', - }, - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - - child.stdout?.on('data', (chunk) => { - process.stdout.write(`[xtream-mock] ${chunk}`); - }); - child.stderr?.on('data', (chunk) => { - process.stderr.write(`[xtream-mock] ${chunk}`); - }); - - await waitForHealthy(`${xtreamMockOrigin}/health`, 20_000); - return child; -} - -function buildM3uPlaylist(): string { - const channels = [ - ['Newsroom', 'Aurora Local', 'aurora-local'], - ['Newsroom', 'Civic Pulse', 'civic-pulse'], - ['Sports', 'Fieldside One', 'fieldside-one'], - ['Sports', 'Motion Arena', 'motion-arena'], - ['Kids', 'Horizon Kids', 'horizon-kids'], - ['Kids', 'Story Lantern', 'story-lantern'], - ['Culture', 'Atlas Culture', 'atlas-culture'], - ['Culture', 'Night Music', 'night-music'], - ]; - const stream = `${xtreamMockOrigin}/live/marketing/marketing/52000.m3u8`; - const lines = ['#EXTM3U']; - - channels.forEach(([group, title, slug], index) => { - lines.push( - `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${xtreamMockOrigin}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}` - ); - lines.push(stream); - }); - - return `${lines.join('\n')}\n`; -} - -function writeM3uPlaylistFixture(dataDir: string): string { - const filePath = path.join(dataDir, 'v020-demo.m3u'); - writeFileSync(filePath, buildM3uPlaylist(), 'utf8'); - return filePath; -} - -function marketingAssetUrl( - kind: 'backdrop' | 'episode' | 'logo' | 'poster' | 'season', - title: string, - size: string -): string { - return `${xtreamMockOrigin}/assets/marketing/${kind}/${slugifyForFile( - title - )}.svg?size=${encodeURIComponent(size)}`; -} - -async function launchApp(dataDir: string): Promise { - return electron.launch({ - args: [electronMainPath], - env: { - ...process.env, - ELECTRON_IS_DEV: '0', - IPTVNATOR_E2E_DATA_DIR: dataDir, - NODE_ENV: 'test', - }, - }); -} - -async function findMainWindow(app: ElectronApplication): Promise { - await new Promise((resolve) => setTimeout(resolve, 1500)); - - for (const candidate of app.windows()) { - const title = await candidate.title(); - if (!title.includes('DevTools')) { - return candidate; - } - } - - return app.firstWindow(); -} - -async function sizeElectronWindow(app: ElectronApplication): Promise { - await app.evaluate(({ BrowserWindow }, size) => { - const win = BrowserWindow.getAllWindows().find( - (candidate) => !candidate.webContents.getTitle().includes('DevTools') - ); - win?.setSize(size.width, size.height); - win?.center(); - }, viewport); -} - -async function waitForAppReady(page: Page): Promise { - await page.waitForLoadState('domcontentloaded'); - await page.waitForSelector('app-root', { timeout: 45_000 }); - await page.waitForFunction( - () => (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > 0, - { timeout: 45_000 } - ); -} - -async function seedDemoData(page: Page, m3uPath: string): Promise { - await addXtreamPortal(page); - await addM3uPlaylist(page, m3uPath); - await seedDashboardActivity(page); -} - -async function addXtreamPortal(page: Page): Promise { - await openAddPlaylistDialog(page); - const dialog = page.locator('mat-dialog-container').last(); - - await clickDialogOption(dialog, /^xtream$/i); - await dialog.locator('#title').fill('v0.20 Fictional Xtream'); - await dialog.locator('#serverUrl').fill(xtreamMockOrigin); - await dialog.locator('#username').fill('marketing'); - await dialog.locator('#password').fill('marketing'); - await dialog.getByRole('button', { name: /^(add|add playlist)$/i }).last().click(); - await dialog.waitFor({ state: 'detached', timeout: 30_000 }); - await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { - timeout: 45_000, - }); - capturedXtreamPlaylistId = playlistIdFromUrl(page.url(), 'xtreams'); - await page.locator('.category-content-layout, app-content-card').first().waitFor({ - state: 'visible', - timeout: 45_000, - }); -} - -async function addM3uPlaylist(page: Page, m3uPath: string): Promise { - await openAddPlaylistDialog(page); - const dialog = page.locator('mat-dialog-container').last(); - - await clickDialogOption(dialog, /^m3u$/i); - await clickDialogOption(dialog, /add\s+via\s+file\s+upload/i); - - const fileInput = dialog.locator('input[type="file"][name="playlist"]'); - await fileInput.evaluate((element, selectedFilePath) => { - (element as HTMLInputElement).dataset.filePathOverride = - selectedFilePath; - }, m3uPath); - await fileInput.setInputFiles(m3uPath); - await dialog - .getByRole('button', { name: /add playlist/i }) - .last() - .click({ timeout: 15_000 }); - await dialog.waitFor({ state: 'detached', timeout: 30_000 }); - await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { - timeout: 45_000, - }); - capturedM3uPlaylistId = playlistIdFromUrl(page.url(), 'playlists'); - try { - await page.locator('[data-test-id="channel-item"]').first().waitFor({ - state: 'visible', - timeout: 60_000, - }); - } catch (error) { - await writeDebugScreenshot(page, 'm3u-import'); - const diagnostics = await page.evaluate(() => ({ - bodyText: document.body.innerText.slice(0, 2000), - title: document.title, - url: location.href, - })); - - console.error( - `[capture] M3U import did not show channels: ${JSON.stringify( - diagnostics, - null, - 2 - )}` - ); - throw error; - } -} - -async function seedDashboardActivity(page: Page): Promise { - const playlistId = requireCapturedPlaylistId('xtreams'); - const items = [ - { - xtreamId: 62000, - type: 'movie' as const, - backdropUrl: marketingAssetUrl('backdrop', 'Crimson Skylark', '1280x720'), - favorite: true, - recent: true, - }, - { - xtreamId: 62001, - type: 'movie' as const, - backdropUrl: marketingAssetUrl('backdrop', 'The Voltage Guard', '1280x720'), - favorite: true, - recent: true, - }, - { - xtreamId: 62002, - type: 'movie' as const, - backdropUrl: marketingAssetUrl('backdrop', 'Midnight Mantle', '1280x720'), - favorite: true, - recent: false, - }, - { - xtreamId: 72000, - type: 'series' as const, - backdropUrl: marketingAssetUrl('backdrop', 'Skyline Sentinels', '1280x720'), - favorite: true, - recent: true, - }, - { - xtreamId: 72001, - type: 'series' as const, - backdropUrl: marketingAssetUrl('backdrop', 'The Aegis Club', '1280x720'), - favorite: true, - recent: false, - }, - ]; - - await page.evaluate( - async ({ items, playlistId }) => { - const electronApi = (window as typeof window & { - electron?: { - dbAddFavorite?: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise; - dbAddRecentItem?: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise; - dbGetContentByXtreamId?: ( - xtreamId: number, - playlistId: string, - contentType?: 'live' | 'movie' | 'series' - ) => Promise<{ id: number } | null>; - }; - }).electron; - - if (!electronApi?.dbGetContentByXtreamId) { - throw new Error('Electron database bridge is unavailable.'); - } - - for (const item of items) { - const content = await electronApi.dbGetContentByXtreamId( - item.xtreamId, - playlistId, - item.type - ); - - if (!content?.id) { - throw new Error( - `Could not find imported Xtream content ${item.xtreamId}.` - ); - } - - if (item.favorite) { - await electronApi.dbAddFavorite?.( - content.id, - playlistId, - item.backdropUrl - ); - } - - if (item.recent) { - await electronApi.dbAddRecentItem?.( - content.id, - playlistId, - item.backdropUrl - ); - } - } - }, - { items, playlistId } - ); -} - -async function openAddPlaylistDialog(page: Page): Promise { - await page.getByRole('button', { name: /add playlist/i }).first().click(); - await page.locator('mat-dialog-container').last().waitFor({ - state: 'visible', - timeout: 15_000, - }); -} - -async function clickDialogOption(dialog: ReturnType, label: RegExp): Promise { - const tab = dialog.getByRole('tab', { name: label }).first(); - if ((await tab.count()) > 0) { - await tab.click(); - return; - } - - await dialog.getByRole('button', { name: label }).first().click(); -} - -async function applyTheme(page: Page, theme: ThemeName): Promise { - await openSettings(page); - const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; - const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); - - await themeButton.scrollIntoViewIfNeeded(); - await themeButton.click(); - - const saveButton = page.locator('[data-test-id="save-settings"]').first(); - if (await saveButton.isEnabled()) { - await saveButton.click(); - await waitForTransientUiToSettle(page); - } - - await page.waitForFunction( - (expectedTheme) => - document.body.classList.contains('dark-theme') === - (expectedTheme === 'dark'), - theme, - { timeout: 10_000 } - ); -} - -async function openSettings(page: Page): Promise { - await page.locator('a[href$="/workspace/settings"]').first().click(); - await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); - await page.locator('[data-test-id="settings-container"]').waitFor({ - state: 'visible', - timeout: 15_000, - }); -} - -async function captureThemeSet(page: Page, theme: ThemeName): Promise { - await captureCurrentView(page, theme, 'settings'); - await openDashboard(page); - await captureCurrentView(page, theme, 'dashboard'); - await openXtreamVodDetails(page); - await captureCurrentView(page, theme, 'xtream-vod-details'); - await openXtreamSeriesSeason(page); - await captureCurrentView(page, theme, 'xtream-series-season-open'); - await openM3uGroupsView(page); - await captureCurrentView(page, theme, 'm3u-live-groups-two-column'); -} - -async function openDashboard(page: Page): Promise { - await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); - await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); - await page.locator('[data-test-id="dashboard-page"]').waitFor({ - state: 'visible', - timeout: 20_000, - }); - await waitForTransientUiToSettle(page); - await page.locator('[data-test-id="dashboard-hero"]').waitFor({ - state: 'visible', - timeout: 30_000, - }); - - for (const testId of [ - 'dashboard-recently-watched-rail', - 'dashboard-global-favorites-rail', - 'dashboard-recent-sources-rail', - 'dashboard-xtream-recently-added-rail', - ]) { - await page.locator(`[data-test-id="${testId}"]`).waitFor({ - state: 'visible', - timeout: 30_000, - }); - } -} - -async function waitForTransientUiToSettle(page: Page): Promise { - await page - .locator('.mat-mdc-snack-bar-container') - .first() - .waitFor({ state: 'detached', timeout: 10_000 }) - .catch(() => undefined); - await page.evaluate(() => { - document - .querySelectorAll('.mat-mdc-snack-bar-container, simple-snack-bar') - .forEach((element) => { - const overlayPane = element.closest('.cdk-overlay-pane'); - (overlayPane ?? element).remove(); - }); - }); - await page.waitForTimeout(250); -} - -async function openXtreamVodDetails(page: Page): Promise { - const xtreamId = requireCapturedPlaylistId('xtreams'); - - await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/vod`); - await clickCategoryByName(page, 'Action & Mystery'); - await clickFirstGridCard(page); - await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, { - timeout: 30_000, - }); - await page.locator('app-content-hero').waitFor({ - state: 'visible', - timeout: 30_000, - }); - await page.waitForTimeout(700); -} - -async function openXtreamSeriesSeason(page: Page): Promise { - const xtreamId = requireCapturedPlaylistId('xtreams'); - - await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/series`); - await clickCategoryByName(page, 'Urban Drama'); - await clickFirstGridCard(page); - await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, { - timeout: 30_000, - }); - await page.locator('app-season-container').waitFor({ - state: 'visible', - timeout: 30_000, - }); - await page.locator('.season-card').first().click(); - await page.locator('.episode-card, .episode-list-item').first().waitFor({ - state: 'visible', - timeout: 20_000, - }); - await page.waitForTimeout(700); -} - -async function openM3uGroupsView(page: Page): Promise { - const playlistId = requireCapturedPlaylistId('playlists'); - - await switchPlaylistFromHeader(page, 'v020-demo'); - await page.waitForURL(new RegExp(`/workspace/playlists/${playlistId}/`), { - timeout: 20_000, - }); - await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`); - await page.waitForURL(/\/workspace\/playlists\/[^/]+\/groups/, { - timeout: 20_000, - }); - await page.locator('.group-nav-item').first().waitFor({ - state: 'visible', - timeout: 20_000, - }); - await page.locator('.group-nav-item').first().click(); - await page.locator('[data-test-id="channel-item"]').first().click(); - await page.waitForTimeout(1_000); -} - -async function switchPlaylistFromHeader( - page: Page, - title: string -): Promise { - await page.locator('app-playlist-switcher .playlist-switcher-trigger').click(); - - const item = page - .locator('.cdk-overlay-pane .playlist-item') - .filter({ hasText: title }) - .first(); - - await item.waitFor({ state: 'visible', timeout: 20_000 }); - await item.click(); - await page.locator('.cdk-overlay-pane .playlist-item').first().waitFor({ - state: 'detached', - timeout: 10_000, - }); -} - -async function clickCategoryByName( - page: Page, - categoryName: string -): Promise { - const category = page - .locator('app-workspace-context-panel .category-item') - .filter({ hasText: categoryName }) - .first(); - - try { - await category.waitFor({ state: 'visible', timeout: 30_000 }); - } catch (error) { - await writeDebugScreenshot(page, `category-${slugifyForFile(categoryName)}`); - const diagnostics = await page.evaluate(() => ({ - bodyText: document.body.innerText.slice(0, 2500), - categories: Array.from( - document.querySelectorAll( - 'app-workspace-context-panel .category-item' - ) - ).map((element) => ({ - id: element.getAttribute('data-category-id'), - text: (element.textContent ?? '').trim(), - })), - title: document.title, - url: location.href, - })); - - console.error( - `[capture] Category ${categoryName} was not visible: ${JSON.stringify( - diagnostics, - null, - 2 - )}` - ); - throw error; - } - await category.click(); - await page.waitForTimeout(600); -} - -async function clickFirstGridCard(page: Page): Promise { - const card = page.locator('.category-content-layout mat-card').first(); - - await card.waitFor({ state: 'visible', timeout: 30_000 }); - await card.click(); -} - -function requireCapturedPlaylistId( - provider: 'playlists' | 'xtreams' -): string { - const id = - provider === 'playlists' - ? capturedM3uPlaylistId - : capturedXtreamPlaylistId; - - if (!id) { - throw new Error(`Could not resolve captured ${provider} playlist id.`); - } - - return id; -} - -function playlistIdFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { - const match = url.match(new RegExp(`/workspace/${provider}/([^/]+)`)); - - if (!match?.[1]) { - throw new Error(`Could not resolve ${provider} playlist id from URL ${url}`); - } - - return match[1]; -} - -async function clickHrefSuffix(page: Page, suffix: string): Promise { - const link = page.locator(`a[href$="${suffix}"]`).first(); - - await link.waitFor({ state: 'attached', timeout: 20_000 }); - await link.click({ force: true }); - await page.waitForTimeout(500); -} - -async function captureCurrentView( - page: Page, - theme: ThemeName, - slug: ScreenshotSlug -): Promise { - const originalPath = screenshotPath('original', theme, slug); - - mkdirSync(path.dirname(originalPath), { recursive: true }); - await prepareForScreenshot(page); - await page.screenshot({ - path: originalPath, - type: 'png', - fullPage: slug === 'dashboard', - scale: 'css', - }); - - await createDesignedCopy( - page, - originalPath, - screenshotPath('designed', theme, slug), - { - ...captureTargets.find((target) => target.slug === slug)!, - theme, - } - ); -} - -async function prepareForScreenshot(page: Page): Promise { - await page.mouse.move(viewport.width - 32, viewport.height - 32); - await page.keyboard.press('Escape').catch(() => undefined); - await waitForTransientUiToSettle(page); - await page - .locator('.mat-mdc-tooltip, .mdc-tooltip, .mat-tooltip-panel') - .first() - .waitFor({ state: 'detached', timeout: 1_500 }) - .catch(() => undefined); - await page.waitForTimeout(200); -} - -async function writeDebugScreenshot(page: Page, name: string): Promise { - const debugPath = path.join(outputRoot, 'debug', `${name}.png`); - mkdirSync(path.dirname(debugPath), { recursive: true }); - await page.screenshot({ path: debugPath, fullPage: true, type: 'png' }); -} - -function screenshotPath( - variant: 'designed' | 'original', - theme: ThemeName, - slug: ScreenshotSlug -): string { - return path.join(outputRoot, variant, theme, `${slug}.png`); -} - -async function createDesignedCopy( - page: Page, - inputPath: string, - outputPath: string, - options: CaptureTarget & { theme: ThemeName } -): Promise { - mkdirSync(path.dirname(outputPath), { recursive: true }); - - const canvasWidth = 1920; - const canvasHeight = 1080; - const screenshotWidth = 1460; - const screenshotHeight = 822; - const left = 360; - const top = 170; - const palette = - options.theme === 'dark' - ? { - bg1: '#090b12', - bg2: '#172235', - accent: '#49c6e5', - text: '#f8fafc', - muted: '#a7b4c8', - } - : { - bg1: '#eef2f7', - bg2: '#dce7ef', - accent: '#2563eb', - text: '#111827', - muted: '#475569', - }; - const dataUrl = await page.evaluate( - async ({ - inputDataUrl, - options, - palette, - canvasWidth, - canvasHeight, - screenshotWidth, - screenshotHeight, - left, - top, - }) => { - const image = await loadCanvasImage(inputDataUrl); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - if (!context) { - throw new Error('Canvas 2D context is unavailable.'); - } - - canvas.width = canvasWidth; - canvas.height = canvasHeight; - - const background = context.createLinearGradient( - 0, - 0, - canvasWidth, - canvasHeight - ); - background.addColorStop(0, palette.bg1); - background.addColorStop(1, palette.bg2); - context.fillStyle = background; - context.fillRect(0, 0, canvasWidth, canvasHeight); - - context.globalAlpha = 0.16; - context.fillStyle = palette.accent; - context.beginPath(); - context.arc(1710, 140, 260, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 0.12; - context.beginPath(); - context.arc(160, 930, 340, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 0.28; - context.strokeStyle = palette.accent; - context.lineWidth = 4; - context.beginPath(); - context.moveTo(0, 810); - context.bezierCurveTo(420, 650, 720, 1040, 1220, 840); - context.bezierCurveTo(1460, 744, 1720, 610, 1920, 720); - context.stroke(); - context.globalAlpha = 1; - - context.fillStyle = palette.accent; - context.font = '800 28px Arial, Helvetica, sans-serif'; - context.fillText(options.kicker.toUpperCase(), 110, 150); - context.fillStyle = palette.text; - context.font = '900 72px Arial, Helvetica, sans-serif'; - context.fillText(options.title, 110, 220); - context.fillStyle = palette.muted; - context.font = '600 28px Arial, Helvetica, sans-serif'; - context.fillText( - 'IPTVnator v0.20 release preview using fictional demo content', - 110, - 278 - ); - - context.save(); - context.shadowColor = - options.theme === 'dark' - ? 'rgba(0, 0, 0, 0.42)' - : 'rgba(0, 0, 0, 0.18)'; - context.shadowBlur = 76; - context.shadowOffsetY = 34; - context.globalAlpha = options.theme === 'dark' ? 0.78 : 0.9; - context.fillStyle = options.theme === 'dark' ? '#020617' : '#ffffff'; - drawRoundRect( - context, - left - 18, - top - 18, - screenshotWidth + 36, - screenshotHeight + 36, - 34 - ); - context.fill(); - context.restore(); - - context.save(); - drawRoundRect(context, left, top, screenshotWidth, screenshotHeight, 18); - context.clip(); - context.drawImage( - image, - 0, - 0, - image.naturalWidth, - image.naturalHeight, - left, - top, - screenshotWidth, - screenshotHeight - ); - context.restore(); - - context.strokeStyle = hexToRgba(palette.accent, 0.35); - context.lineWidth = 2; - drawRoundRect( - context, - left - 18, - top - 18, - screenshotWidth + 36, - screenshotHeight + 36, - 34 - ); - context.stroke(); - - return canvas.toDataURL('image/png'); - - function loadCanvasImage(src: string): Promise { - return new Promise((resolve, reject) => { - const candidate = new Image(); - candidate.onload = () => resolve(candidate); - candidate.onerror = () => - reject(new Error('Could not load screenshot image.')); - candidate.src = src; - }); - } - - function drawRoundRect( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number - ): void { - context.beginPath(); - context.moveTo(x + radius, y); - context.lineTo(x + width - radius, y); - context.quadraticCurveTo(x + width, y, x + width, y + radius); - context.lineTo(x + width, y + height - radius); - context.quadraticCurveTo( - x + width, - y + height, - x + width - radius, - y + height - ); - context.lineTo(x + radius, y + height); - context.quadraticCurveTo(x, y + height, x, y + height - radius); - context.lineTo(x, y + radius); - context.quadraticCurveTo(x, y, x + radius, y); - context.closePath(); - } - - function hexToRgba(hex: string, alpha: number): string { - const normalized = hex.replace('#', ''); - const red = Number.parseInt(normalized.slice(0, 2), 16); - const green = Number.parseInt(normalized.slice(2, 4), 16); - const blue = Number.parseInt(normalized.slice(4, 6), 16); - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; - } - }, - { - inputDataUrl: fileToDataUrl(inputPath), - options, - palette, - canvasWidth, - canvasHeight, - screenshotWidth, - screenshotHeight, - left, - top, - } - ); - - writeDataUrlPng(outputPath, dataUrl); -} - -async function createHeroImage(page: Page): Promise { - const dashboard = screenshotPath('original', 'dark', 'dashboard'); - const vod = screenshotPath('original', 'dark', 'xtream-vod-details'); - const series = screenshotPath('original', 'light', 'xtream-series-season-open'); - mkdirSync(path.dirname(heroOutputPath), { recursive: true }); - - const dataUrl = await page.evaluate( - async ({ dashboard, vod, series }) => { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - if (!context) { - throw new Error('Canvas 2D context is unavailable.'); - } - - canvas.width = 1600; - canvas.height = 900; - - const background = context.createLinearGradient(0, 0, 1600, 900); - background.addColorStop(0, '#06111f'); - background.addColorStop(0.52, '#111827'); - background.addColorStop(1, '#233b54'); - context.fillStyle = background; - context.fillRect(0, 0, 1600, 900); - - context.globalAlpha = 0.18; - context.fillStyle = '#49c6e5'; - context.beginPath(); - context.arc(1380, 120, 240, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 0.15; - context.fillStyle = '#f2a65a'; - context.beginPath(); - context.arc(170, 790, 290, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 1; - - context.fillStyle = '#49c6e5'; - context.font = '800 30px Arial, Helvetica, sans-serif'; - context.fillText('IPTVNATOR V0.20', 86, 138); - context.fillStyle = '#f8fafc'; - context.font = '900 78px Arial, Helvetica, sans-serif'; - context.fillText('Unified UI.', 86, 224); - context.fillText('Content-first.', 86, 306); - context.fillStyle = '#b9c4d6'; - context.font = '600 28px Arial, Helvetica, sans-serif'; - context.fillText( - 'Dashboard, search, favorites, history, and settings reworked across M3U, Xtream, and Stalker.', - 88, - 366 - ); - - await drawFramedImage(context, dashboard, 650, 130, 780, 438, 26, '#020617'); - await drawFramedImage(context, vod, 500, 505, 520, 292, 22, '#020617'); - await drawFramedImage(context, series, 990, 565, 430, 242, 20, '#f8fafc'); - - return canvas.toDataURL('image/png'); - - async function drawFramedImage( - context: CanvasRenderingContext2D, - src: string, - x: number, - y: number, - width: number, - height: number, - radius: number, - frameColor: string - ): Promise { - const image = await loadCanvasImage(src); - - context.save(); - context.shadowColor = 'rgba(0, 0, 0, 0.45)'; - context.shadowBlur = 60; - context.shadowOffsetY = 28; - context.globalAlpha = frameColor === '#f8fafc' ? 0.94 : 0.82; - context.fillStyle = frameColor; - drawRoundRect(context, x, y, width, height, radius); - context.fill(); - context.restore(); - - context.save(); - drawRoundRect(context, x, y, width, height, radius); - context.clip(); - context.drawImage( - image, - 0, - 0, - image.naturalWidth, - image.naturalHeight, - x, - y, - width, - height - ); - context.restore(); - } - - function loadCanvasImage(src: string): Promise { - return new Promise((resolve, reject) => { - const candidate = new Image(); - candidate.onload = () => resolve(candidate); - candidate.onerror = () => - reject(new Error('Could not load screenshot image.')); - candidate.src = src; - }); - } - - function drawRoundRect( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number - ): void { - context.beginPath(); - context.moveTo(x + radius, y); - context.lineTo(x + width - radius, y); - context.quadraticCurveTo(x + width, y, x + width, y + radius); - context.lineTo(x + width, y + height - radius); - context.quadraticCurveTo( - x + width, - y + height, - x + width - radius, - y + height - ); - context.lineTo(x + radius, y + height); - context.quadraticCurveTo(x, y + height, x, y + height - radius); - context.lineTo(x, y + radius); - context.quadraticCurveTo(x, y, x + radius, y); - context.closePath(); - } - }, - { - dashboard: fileToDataUrl(dashboard), - vod: fileToDataUrl(vod), - series: fileToDataUrl(series), - } - ); - - writeDataUrlPng(heroOutputPath, dataUrl); -} - -async function verifyExpectedAssets(): Promise { - const expected = [ - heroOutputPath, - ...(['dark', 'light'] as ThemeName[]).flatMap((theme) => - captureTargets.flatMap((target) => [ - screenshotPath('original', theme, target.slug), - screenshotPath('designed', theme, target.slug), - ]) - ), - ]; - - for (const filePath of expected) { - if (!fileExists(filePath)) { - throw new Error(`Expected screenshot asset was not created: ${filePath}`); - } - - const dimensions = readPngDimensions(filePath); - if (!dimensions.width || !dimensions.height) { - throw new Error(`Screenshot asset has invalid dimensions: ${filePath}`); - } - } -} - -async function isHealthy(url: string): Promise { - try { - const response = await fetch(url); - return response.ok; - } catch { - return false; - } -} - -async function waitForHealthy(url: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await isHealthy(url)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - throw new Error(`Timed out waiting for ${url}`); -} - -function fileExists(filePath: string): boolean { - try { - accessSync(filePath); - return true; - } catch { - return false; - } -} - -function fileToDataUrl(filePath: string): string { - return `data:image/png;base64,${readFileSync(filePath).toString('base64')}`; -} - -function writeDataUrlPng(filePath: string, dataUrl: string): void { - const marker = 'data:image/png;base64,'; - - if (!dataUrl.startsWith(marker)) { - throw new Error(`Expected PNG data URL for ${filePath}`); - } - - writeFileSync(filePath, Buffer.from(dataUrl.slice(marker.length), 'base64')); -} - -function readPngDimensions(filePath: string): { height: number; width: number } { - const buffer = readFileSync(filePath); - const pngSignature = '89504e470d0a1a0a'; - - if (buffer.subarray(0, 8).toString('hex') !== pngSignature) { - throw new Error(`Expected PNG signature in ${filePath}`); - } - - return { - width: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20), - }; -} - -function slugifyForFile(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/tools/release/project.json b/tools/release/project.json index 5bc632856..ed024a440 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -12,11 +12,14 @@ "{workspaceRoot}/tools/release/release-notes-render.mjs", "{workspaceRoot}/tools/release/extract-changelog-section.mjs", "{workspaceRoot}/tools/release/check-release-note-gate.mjs", + "{workspaceRoot}/tools/release/screenshot-guards.mjs", + "{workspaceRoot}/tools/release/screenshots.manifest.json", "{workspaceRoot}/tools/release/release-notes.test.mjs", - "{workspaceRoot}/tools/release/release-note-gate.test.mjs" + "{workspaceRoot}/tools/release/release-note-gate.test.mjs", + "{workspaceRoot}/tools/release/screenshot-guards.test.mjs" ], "options": { - "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs", + "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/screenshot-guards.test.mjs", "cwd": "{workspaceRoot}" } }, @@ -27,8 +30,12 @@ "{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint/**/*" ], - "command": "eslint \"tools/release/*.mjs\"" + "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\"" } }, - "tags": ["scope:tools", "domain:release", "type:tool"] + "tags": [ + "scope:tools", + "domain:release", + "type:tool" + ] } diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs new file mode 100644 index 000000000..ae29b4950 --- /dev/null +++ b/tools/release/screenshot-guards.mjs @@ -0,0 +1,504 @@ +/** + * Fail-closed guards for the release screenshot capture + * (capture-release-screenshots.ts). Published screenshots must never contain + * a real playlist, credential, stream, or third-party artwork — these + * helpers make that a property of the run, not a hope. + * + * Pure (or fs-only) so the policy is unit-tested; the Playwright driver just + * wires them in. + */ + +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; + +/* ------------------------------------------------------------------ */ +/* Manifest */ +/* ------------------------------------------------------------------ */ + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +/** The capture and the blog scaffold both assume exactly these themes. */ +export const SUPPORTED_THEMES = ['dark', 'light']; + +/** + * Chromium switch applied at process start, so it has no install-timing + * window at all: every hostname fails to resolve except the local mock. It + * covers Chromium-stack traffic (renderer plus the main process `net` + * module); requests made through Node's own stack are not affected, which is + * why the session hook and the recorded-URL verdict still exist. + */ +export const HOST_RESOLVER_RULES = + 'MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1'; + +/** Setup actions the driver implements. Manifest steps must match. */ +export const KNOWN_ACTIONS = [ + 'open-dashboard', + 'open-settings', + 'open-xtream-vod', + 'open-xtream-series', + 'open-m3u-groups', +]; + +/** @param {string} step e.g. `open-xtream-vod=Hero Premieres` */ +export function parseSetupStep(step) { + const separator = step.indexOf('='); + const action = separator === -1 ? step : step.slice(0, separator); + const param = separator === -1 ? null : step.slice(separator + 1); + + return { action, param }; +} + +/** + * @param {object} manifest parsed screenshots.manifest.json + * @returns {string[]} problems; empty when valid + */ +export function validateManifest(manifest) { + const errors = []; + + if (manifest?.version !== 1) { + errors.push('manifest `version` must be 1'); + } + + // The blog scaffold always references both `-dark.png` and `-light.png`, + // and a full run publishes in replace mode — a manifest missing a theme + // would silently delete the previous set and leave the post with broken + // images. + const themes = manifest?.themes; + + if ( + !Array.isArray(themes) || + themes.length !== SUPPORTED_THEMES.length || + SUPPORTED_THEMES.some((theme) => !themes.includes(theme)) + ) { + errors.push( + `manifest \`themes\` must be exactly ${SUPPORTED_THEMES.join(' and ')}, got ${JSON.stringify(themes)}` + ); + } + + const shots = Array.isArray(manifest?.shots) ? manifest.shots : null; + + if (!shots || shots.length === 0) { + return [...errors, 'manifest `shots` must be a non-empty array']; + } + + const seen = new Set(); + + for (const shot of shots) { + const label = shot?.slug ?? ''; + + if (!shot?.slug || !SLUG_PATTERN.test(shot.slug)) { + errors.push(`shot "${label}": slug must be a lowercase slug`); + } + + if (seen.has(shot?.slug)) { + errors.push(`shot "${label}": duplicate slug`); + } + + seen.add(shot?.slug); + + if (!Array.isArray(shot?.setup) || shot.setup.length === 0) { + errors.push(`shot "${label}": setup must be a non-empty array`); + continue; + } + + for (const step of shot.setup) { + const { action } = parseSetupStep(String(step)); + + if (!KNOWN_ACTIONS.includes(action)) { + errors.push( + `shot "${label}": unknown setup action "${action}" (expected ${KNOWN_ACTIONS.join(', ')})` + ); + } + } + } + + return errors; +} + +/** + * A release slug names a directory under apps/website/public/blog, which a + * full run replaces — including a recursive delete of what was there. Reject + * anything that could escape that tree or resolve somewhere unexpected. + * + * @param {string} release + * @returns {string | null} error message, or null when the slug is safe + */ +export function validateReleaseSlug(release) { + if (!release) { + return 'release slug is empty'; + } + + if (!/^[a-z0-9][a-z0-9.-]*$/.test(release) || release.includes('..')) { + return `release slug "${release}" must be a lowercase slug without path separators (for example v0-24)`; + } + + return null; +} + +/** @returns {Set} all slugs, for `.changes/` screenshot validation */ +export function manifestSlugs(manifest) { + return new Set((manifest?.shots ?? []).map((shot) => shot.slug)); +} + +/* ------------------------------------------------------------------ */ +/* G2 — environment allowlist */ +/* ------------------------------------------------------------------ */ + +const ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'SHELL', + 'TMPDIR', + 'TZ', + 'LANG', + 'DISPLAY', + 'WAYLAND_DISPLAY', + // X11 sessions authenticate through an xauth cookie file; without it the + // Electron child cannot open the display and the run dies before a window + // exists. It grants display access, not application credentials. + 'XAUTHORITY', +]; +const ENV_ALLOWED_PREFIXES = ['LC_', 'XDG_']; + +/** + * The capture app gets a constructed environment, never `...process.env`: + * ambient variables are how TMDB keys, proxies, and experiment flags leak + * into a run that must be hermetic. + * + * @param {Record} baseEnv + * @param {Record} overrides explicit run configuration + * @returns {Record} + */ +export function buildCaptureEnv(baseEnv, overrides) { + const env = {}; + + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined) { + continue; + } + + if ( + ENV_ALLOWLIST.includes(key) || + ENV_ALLOWED_PREFIXES.some((prefix) => key.startsWith(prefix)) + ) { + env[key] = value; + } + } + + return { ...env, ...overrides }; +} + +/* ------------------------------------------------------------------ */ +/* G3 — network gate */ +/* ------------------------------------------------------------------ */ + +/** Single source of truth for what may load during a capture run. */ +export const ALLOWED_SCHEMES = [ + 'file:', + 'data:', + 'blob:', + 'about:', + 'chrome:', + 'devtools:', +]; +export const ALLOWED_HOSTS = ['localhost', '127.0.0.1']; +export const ALLOWED_NETWORK_PROTOCOLS = ['http:', 'ws:']; + +/** + * Deny by default. Only local mock traffic and the app's own bundle may load; + * anything else (TMDB, logo CDNs, real streams) is blocked and — because the + * caller fails on a non-empty list — fatal, since a silently blocked request + * produces a frame that merely looks broken instead of unsafe. + * + * @param {string} url + * @returns {boolean} + */ +export function isAllowedRequestUrl(url) { + try { + const { hostname, protocol } = new URL(url); + + if (ALLOWED_SCHEMES.includes(protocol)) { + return true; + } + + return ( + ALLOWED_NETWORK_PROTOCOLS.includes(protocol) && + ALLOWED_HOSTS.includes(hostname) + ); + } catch { + // `blob:file:///…` and similar opaque forms do not parse as URLs. + return ALLOWED_SCHEMES.some((scheme) => url.startsWith(scheme)); + } +} + +/** + * App-level requests that are legitimate in production but must not leave + * the machine during a capture run. The driver fulfills them locally with + * an empty payload instead of letting them hit the network — a deterministic + * stub, not an allowlist hole. Keep this list minimal and exact. + * + * @param {string} url + * @returns {{ body: string, contentType: string } | null} stub response + */ +export const STUB_URL_PREFIXES = [ + // Settings "release notes" / update check. + 'https://api.github.com/repos/4gray/iptvnator/releases', +]; + +export function stubbedResponseFor(url) { + if (STUB_URL_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return { body: '[]', contentType: 'application/json' }; + } + + return null; +} + +/** + * Data handed to the main-process request hook, which cannot import this + * module. Keeping the values here means the two predicates cannot disagree + * about what is local even though the check itself is written twice. + * + * @returns {{ schemes: string[], hosts: string[], protocols: string[], stubPrefixes: string[] }} + */ +export function networkPolicy() { + return { + schemes: ALLOWED_SCHEMES, + hosts: ALLOWED_HOSTS, + protocols: ALLOWED_NETWORK_PROTOCOLS, + stubPrefixes: STUB_URL_PREFIXES, + }; +} + +/** + * Publishes a staged frame set over the release directory as one swap. + * + * Copy-then-rename rather than moving file by file: staging lives in the + * system temp directory, which may be a different filesystem (rename would + * fail with EXDEV), and a per-file loop that fails midway would leave the + * release holding a mix of new and old screenshots. Files are copied into a + * sibling of the target — same filesystem, so the directory swap is atomic — + * and the previous set is only deleted once the new one is in place. + * + * `mode` decides what happens to shots the staging set does not contain: + * a full run replaces the directory so slugs dropped from the manifest do not + * linger, while a filtered run (`--only`, `--theme`) must overlay its frames + * onto the existing set — otherwise refreshing one shot would delete every + * other screenshot of the release. + * + * @param {string} stagingDir + * @param {string} outputRoot + * @param {string} token unique suffix for the scratch directories + * @param {{ mode?: 'replace' | 'merge', rename?: (from: string, to: string) => void }} [options] + * `rename` is a seam for exercising the rollback path, which the filesystem + * will not fail on demand + * @returns {number} number of published files + */ +export function publishDirectory( + stagingDir, + outputRoot, + token, + { mode = 'replace', rename = renameSync } = {} +) { + const incoming = `${outputRoot}.incoming-${token}`; + const retired = `${outputRoot}.retired-${token}`; + + mkdirSync(path.dirname(outputRoot), { recursive: true }); + rmSync(incoming, { recursive: true, force: true }); + mkdirSync(incoming, { recursive: true }); + + if (mode === 'merge' && existsSync(outputRoot)) { + for (const name of readdirSync(outputRoot)) { + const source = path.join(outputRoot, name); + + if (statSync(source).isFile()) { + copyFileSync(source, path.join(incoming, name)); + } + } + } + + const names = readdirSync(stagingDir); + + for (const name of names) { + copyFileSync(path.join(stagingDir, name), path.join(incoming, name)); + } + + const hadPrevious = existsSync(outputRoot); + + if (hadPrevious) { + rename(outputRoot, retired); + } + + try { + rename(incoming, outputRoot); + } catch (error) { + if (hadPrevious) { + rename(retired, outputRoot); + } + + rmSync(incoming, { recursive: true, force: true }); + throw error; + } + + rmSync(retired, { recursive: true, force: true }); + + return names.length; +} + +/** + * Verdict over every request URL observed during a run — both the ones the + * page-level route blocked and the ones the main-process recorder saw during + * startup, before page interception exists. + * + * @param {string[]} urls + * @returns {string[]} unique offending URLs; empty when the run stayed local + */ +export function externalRequestViolations(urls) { + return [...new Set(urls)].filter( + (url) => !isAllowedRequestUrl(url) && !stubbedResponseFor(url) + ); +} + +/* ------------------------------------------------------------------ */ +/* G4 — frame content assertions */ +/* ------------------------------------------------------------------ */ + +const CREDENTIAL_TEXT_PATTERNS = [ + /https?:\/\/[^\s"']*(?:username|password)=/i, + /\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b/i, // MAC address (Stalker identity) + /https?:\/\/(?!localhost|127\.0\.0\.1)[^\s"']+\.m3u8?\b/i, +]; + +/** + * Evaluated against a DOM report collected right before each screenshot: + * every image/background URL and the visible text. External resources or + * credential-shaped text fail the shot. + * + * @param {{ resourceUrls: string[], bodyText: string }} report + * @returns {string[]} violations; empty when the frame is safe + */ +export function evaluateFrameReport(report) { + const violations = []; + + for (const url of report.resourceUrls) { + if (!isAllowedRequestUrl(url)) { + violations.push(`external resource in frame: ${url}`); + } + } + + for (const pattern of CREDENTIAL_TEXT_PATTERNS) { + const match = report.bodyText.match(pattern); + + if (match) { + violations.push( + `credential-shaped text visible in frame: "${match[0].slice(0, 80)}"` + ); + } + } + + return violations; +} + +/* ------------------------------------------------------------------ */ +/* G1 — real database untouched */ +/* ------------------------------------------------------------------ */ + +/** + * Snapshots every file in the real database directory — `iptvnator.db` plus + * its SQLite WAL sidecars (`-wal`, `-shm`), where writes live until a + * checkpoint, so a mutation cannot hide in them. + * + * Identity is size + mtime + inode, deliberately not a content hash: the + * production database is multi-gigabyte, and the previous `readFileSync` + + * sha256 approach threw on it and was swallowed into "file does not exist" — + * which silently disabled this guard entirely. Only ENOENT means absent; any + * other error propagates. + * + * @param {string} directory + * @returns {{ exists: boolean, entries: Record }} + */ +export function snapshotDatabaseState(directory) { + let names; + + try { + names = readdirSync(directory); + } catch (error) { + if (error.code === 'ENOENT') { + return { exists: false, entries: {} }; + } + + throw error; + } + + const entries = {}; + + for (const name of names.sort()) { + try { + const stats = statSync(path.join(directory, name)); + + if (stats.isFile()) { + entries[name] = { + size: stats.size, + mtimeMs: stats.mtimeMs, + ino: stats.ino, + }; + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + return { exists: true, entries }; +} + +/** + * @returns {string | null} violation message, or null when unchanged + */ +export function compareDatabaseStates(before, after) { + if (before.exists !== after.exists) { + return before.exists + ? 'the real database directory disappeared during the capture run' + : 'the real database directory was CREATED during the capture run'; + } + + if (!before.exists) { + return null; + } + + const names = new Set([ + ...Object.keys(before.entries), + ...Object.keys(after.entries), + ]); + + for (const name of [...names].sort()) { + const from = before.entries[name]; + const to = after.entries[name]; + + if (!from) { + return `the real database gained ${name} during the capture run`; + } + + if (!to) { + return `the real database lost ${name} during the capture run`; + } + + if ( + from.size !== to.size || + from.mtimeMs !== to.mtimeMs || + from.ino !== to.ino + ) { + return `the real database file ${name} was modified during the capture run (close IPTVnator and any other process using it, then retry)`; + } + } + + return null; +} diff --git a/tools/release/screenshot-guards.test.mjs b/tools/release/screenshot-guards.test.mjs new file mode 100644 index 000000000..6b80f852c --- /dev/null +++ b/tools/release/screenshot-guards.test.mjs @@ -0,0 +1,575 @@ +import assert from 'node:assert/strict'; +import { + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, describe, it } from 'node:test'; + +import { + buildCaptureEnv, + HOST_RESOLVER_RULES, + networkPolicy, + publishDirectory, + compareDatabaseStates, + evaluateFrameReport, + externalRequestViolations, + isAllowedRequestUrl, + manifestSlugs, + parseSetupStep, + snapshotDatabaseState, + stubbedResponseFor, + validateManifest, + validateReleaseSlug, +} from './screenshot-guards.mjs'; + +const tempDirs = []; + +after(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function validManifest() { + return { + version: 1, + viewport: { width: 1280, height: 720 }, + themes: ['dark', 'light'], + shots: [ + { slug: 'dashboard', title: 'Dashboard', setup: ['open-dashboard'] }, + { + slug: 'vod', + title: 'VOD', + setup: ['open-xtream-vod=Hero Premieres'], + }, + ], + }; +} + +describe('manifest validation', () => { + it('accepts the committed manifest shape', () => { + assert.deepEqual(validateManifest(validManifest()), []); + }); + + it('rejects unknown setup actions, bad slugs and duplicates', () => { + const manifest = validManifest(); + manifest.shots.push( + { slug: 'Bad Slug', title: 'x', setup: ['open-dashboard'] }, + { slug: 'dashboard', title: 'dupe', setup: ['fly-to-the-moon'] } + ); + + const errors = validateManifest(manifest); + + assert.ok(errors.some((error) => /slug must be a lowercase/.test(error))); + assert.ok(errors.some((error) => /duplicate slug/.test(error))); + assert.ok( + errors.some((error) => /unknown setup action "fly-to-the-moon"/.test(error)) + ); + }); + + it('rejects a manifest that does not carry exactly dark and light', () => { + for (const themes of [['dark'], ['dark', 'drak'], [], undefined, 'dark']) { + const manifest = { ...validManifest(), themes }; + + assert.ok( + validateManifest(manifest).some((error) => + /`themes` must be exactly/.test(error) + ), + `should reject themes: ${JSON.stringify(themes)}` + ); + } + }); + + it('accepts dark and light in either order', () => { + const manifest = { ...validManifest(), themes: ['light', 'dark'] }; + + assert.deepEqual(validateManifest(manifest), []); + }); + + it('rejects an empty or missing shots array', () => { + assert.ok( + validateManifest({ version: 1, themes: ['dark', 'light'], shots: [] }) + .length > 0 + ); + assert.ok( + validateManifest({ version: 1, themes: ['dark', 'light'] }).length > 0 + ); + }); + + it('parses setup steps with and without a parameter', () => { + assert.deepEqual(parseSetupStep('open-dashboard'), { + action: 'open-dashboard', + param: null, + }); + assert.deepEqual(parseSetupStep('open-xtream-vod=Hero Premieres'), { + action: 'open-xtream-vod', + param: 'Hero Premieres', + }); + }); + + it('exposes slugs for .changes screenshot validation', () => { + assert.deepEqual( + [...manifestSlugs(validManifest())], + ['dashboard', 'vod'] + ); + }); +}); + +describe('host resolver gate', () => { + it('blocks every host except the local mock', () => { + assert.match(HOST_RESOLVER_RULES, /^MAP \* ~NOTFOUND/); + assert.match(HOST_RESOLVER_RULES, /EXCLUDE localhost/); + assert.match(HOST_RESOLVER_RULES, /EXCLUDE 127\.0\.0\.1/); + }); +}); + +describe('release slug validation', () => { + it('accepts ordinary release slugs', () => { + for (const slug of ['v0-24', 'v0-24.1', 'v-smoke-test', 'v1-0']) { + assert.equal(validateReleaseSlug(slug), null, slug); + } + }); + + it('rejects traversal and separators that would escape the blog tree', () => { + for (const slug of [ + '../../assets', + '..', + 'v0-24/../../etc', + '/etc/passwd', + 'v0-24/nested', + 'V0-24', + '', + ]) { + assert.ok(validateReleaseSlug(slug), `should reject: ${slug}`); + } + }); +}); + +describe('network policy handed to the main process', () => { + it('carries the same data the in-process predicate uses', () => { + const policy = networkPolicy(); + + assert.ok(policy.schemes.includes('file:')); + assert.ok(policy.hosts.includes('127.0.0.1')); + assert.ok(policy.protocols.includes('http:')); + assert.deepEqual(policy.stubPrefixes, [ + 'https://api.github.com/repos/4gray/iptvnator/releases', + ]); + }); + + it('agrees with isAllowedRequestUrl on local and external URLs', () => { + const policy = networkPolicy(); + const isLocal = (url) => { + if (policy.stubPrefixes.some((p) => url.startsWith(p))) return true; + try { + const parsed = new URL(url); + return ( + policy.schemes.includes(parsed.protocol) || + (policy.protocols.includes(parsed.protocol) && + policy.hosts.includes(parsed.hostname)) + ); + } catch { + return policy.schemes.some((s) => url.startsWith(s)); + } + }; + + for (const url of [ + 'http://localhost:3211/a', + 'file:///x/index.html', + 'data:image/png;base64,AAA', + 'blob:file:///abc', + ]) { + assert.equal(isLocal(url), isAllowedRequestUrl(url), url); + } + + for (const url of [ + 'https://image.tmdb.org/p.jpg', + 'https://test-streams.mux.dev/x.m3u8', + 'https://localhost.evil.example/x', + ]) { + assert.equal(isLocal(url), false, url); + assert.equal(isAllowedRequestUrl(url), false, url); + } + }); +}); + +describe('G2 — environment allowlist', () => { + it('keeps only allowlisted variables plus explicit overrides', () => { + const env = buildCaptureEnv( + { + PATH: '/usr/bin', + HOME: '/Users/x', + LC_ALL: 'en_US.UTF-8', + XDG_RUNTIME_DIR: '/run/user/1000', + TMDB_API_KEY: 'leaky-secret', + HTTPS_PROXY: 'http://proxy:8080', + IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1', + AWS_SECRET_ACCESS_KEY: 'nope', + }, + { NODE_ENV: 'test', IPTVNATOR_E2E_DATA_DIR: '/tmp/x' } + ); + + assert.deepEqual(env, { + PATH: '/usr/bin', + HOME: '/Users/x', + LC_ALL: 'en_US.UTF-8', + XDG_RUNTIME_DIR: '/run/user/1000', + NODE_ENV: 'test', + IPTVNATOR_E2E_DATA_DIR: '/tmp/x', + }); + }); + + it('keeps the X11 display credentials Electron needs on Linux', () => { + // Without XAUTHORITY an xauth-based X11 session refuses the + // connection and the capture dies before a window exists. + const env = buildCaptureEnv( + { + DISPLAY: ':99', + XAUTHORITY: '/run/user/1000/gdm/Xauthority', + WAYLAND_DISPLAY: 'wayland-0', + TMDB_API_KEY: 'leaky-secret', + }, + {} + ); + + assert.deepEqual(env, { + DISPLAY: ':99', + XAUTHORITY: '/run/user/1000/gdm/Xauthority', + WAYLAND_DISPLAY: 'wayland-0', + }); + }); + + it('drops undefined values', () => { + assert.deepEqual(buildCaptureEnv({ PATH: undefined }, {}), {}); + }); +}); + +describe('G3 — network gate', () => { + it('allows localhost, loopback and internal schemes', () => { + for (const url of [ + 'http://localhost:3211/player_api.php?username=marketing', + 'http://127.0.0.1:3211/assets/logo.svg', + 'ws://localhost:4200/ng-cli-ws', + 'file:///dist/apps/web/index.html', + 'data:image/png;base64,AAA', + 'blob:file:///abc', + 'about:blank', + 'devtools://devtools/bundled/root.js', + ]) { + assert.equal(isAllowedRequestUrl(url), true, url); + } + }); + + it('blocks everything external', () => { + for (const url of [ + 'https://api.themoviedb.org/3/trending/all/week', + 'https://image.tmdb.org/t/p/w500/x.jpg', + 'http://real-provider.example:8080/live/user/pass/1.m3u8', + 'https://localhost.evil.example/x', // suffix-spoofed hostname + 'http://192.168.1.50/stream.ts', + 'not a url', + ]) { + assert.equal(isAllowedRequestUrl(url), false, url); + } + }); +}); + +describe('G3 — local stubs', () => { + it('stubs the GitHub releases update check with an empty payload', () => { + const stub = stubbedResponseFor( + 'https://api.github.com/repos/4gray/iptvnator/releases?per_page=100' + ); + + assert.deepEqual(stub, { body: '[]', contentType: 'application/json' }); + }); + + it('stubs nothing else', () => { + for (const url of [ + 'https://api.github.com/repos/4gray/iptvnator/issues', + 'https://api.github.com/repos/other/repo/releases', + 'https://api.themoviedb.org/3/trending/all/week', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]) { + assert.equal(stubbedResponseFor(url), null, url); + } + }); +}); + +describe('G4 — frame content assertions', () => { + it('passes a frame with only mock resources and clean text', () => { + const violations = evaluateFrameReport({ + resourceUrls: [ + 'http://localhost:3211/assets/marketing/poster/crimson-skylark.svg', + 'data:image/svg+xml;base64,AAA', + ], + bodyText: 'Crimson Skylark\nHero Premieres\nAurora Local', + }); + + assert.deepEqual(violations, []); + }); + + it('flags external artwork in the frame', () => { + const violations = evaluateFrameReport({ + resourceUrls: ['https://image.tmdb.org/t/p/w500/poster.jpg'], + bodyText: '', + }); + + assert.equal(violations.length, 1); + assert.match(violations[0], /external resource/); + }); + + it('flags credential-shaped URLs, MAC addresses and external m3u8 text', () => { + const violations = evaluateFrameReport({ + resourceUrls: [], + bodyText: [ + 'http://provider.example/get.php?username=real&password=secret', + '00:1A:79:12:34:56', + 'stream at http://cdn.example/live/42.m3u8 is down', + ].join('\n'), + }); + + assert.equal(violations.length, 3); + assert.ok(violations.every((entry) => /credential-shaped/.test(entry))); + }); + + it('does not flag the mock server stream URL', () => { + const violations = evaluateFrameReport({ + resourceUrls: [], + bodyText: 'http://localhost:3211/live/marketing/marketing/52000.m3u8', + }); + + assert.deepEqual(violations, []); + }); +}); + +describe('G1 — real database untouched', () => { + function tempDbDir(files) { + const dir = mkdtempSync(path.join(tmpdir(), 'guard-db-')); + tempDirs.push(dir); + + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + + return dir; + } + + it('reports no violation for an untouched directory', () => { + const dir = tempDbDir({ + 'iptvnator.db': 'main', + 'iptvnator.db-wal': 'wal', + 'iptvnator.db-shm': 'shm', + }); + + assert.equal( + compareDatabaseStates( + snapshotDatabaseState(dir), + snapshotDatabaseState(dir) + ), + null + ); + }); + + it('reports no violation when the directory does not exist', () => { + const missing = snapshotDatabaseState('/definitely/not/here-9d3f'); + + assert.equal(missing.exists, false); + assert.equal(compareDatabaseStates(missing, missing), null); + }); + + it('detects a write that only lands in the WAL sidecar', () => { + // The exact hole this replaced: hashing iptvnator.db alone missed + // writes that SQLite parks in -wal until a checkpoint. + const dir = tempDbDir({ 'iptvnator.db': 'main', 'iptvnator.db-wal': 'w' }); + const before = snapshotDatabaseState(dir); + writeFileSync(path.join(dir, 'iptvnator.db-wal'), 'w+more'); + + assert.match( + compareDatabaseStates(before, snapshotDatabaseState(dir)), + /iptvnator\.db-wal was modified/ + ); + }); + + it('detects a bare touch of the main database', () => { + const dir = tempDbDir({ 'iptvnator.db': 'same-bytes' }); + const before = snapshotDatabaseState(dir); + utimesSync( + path.join(dir, 'iptvnator.db'), + new Date(), + new Date(Date.now() + 5_000) + ); + + assert.match( + compareDatabaseStates(before, snapshotDatabaseState(dir)), + /iptvnator\.db was modified/ + ); + }); + + it('detects added and removed sidecars', () => { + const dir = tempDbDir({ 'iptvnator.db': 'main' }); + const before = snapshotDatabaseState(dir); + writeFileSync(path.join(dir, 'iptvnator.db-wal'), 'new'); + const after = snapshotDatabaseState(dir); + + assert.match(compareDatabaseStates(before, after), /gained iptvnator\.db-wal/); + assert.match(compareDatabaseStates(after, before), /lost iptvnator\.db-wal/); + }); + + it('detects a directory that appears during the run', () => { + const dir = tempDbDir({ 'iptvnator.db': 'x' }); + + assert.match( + compareDatabaseStates({ exists: false, entries: {} }, snapshotDatabaseState(dir)), + /CREATED/ + ); + }); + + it('does not hash the database, so multi-gigabyte files are fine', () => { + // Regression guard: the previous implementation read the whole file + // into memory and swallowed the resulting failure as "absent", + // silently disabling G1 against a 4 GB production database. + const dir = tempDbDir({ 'iptvnator.db': 'x' }); + const snapshot = snapshotDatabaseState(dir); + + assert.equal(snapshot.exists, true); + assert.ok(!('sha256' in snapshot.entries['iptvnator.db'])); + assert.ok(snapshot.entries['iptvnator.db'].ino > 0); + }); +}); + +describe('publishDirectory', () => { + function staged(files) { + const dir = mkdtempSync(path.join(tmpdir(), 'guard-stage-')); + tempDirs.push(dir); + + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + + return dir; + } + + function outputPath() { + const parent = mkdtempSync(path.join(tmpdir(), 'guard-out-')); + tempDirs.push(parent); + + return path.join(parent, 'screenshots'); + } + + it('publishes into a directory that does not exist yet', () => { + const out = outputPath(); + const count = publishDirectory( + staged({ 'a-dark.png': 'A', 'a-light.png': 'B' }), + out, + 'test' + ); + + assert.equal(count, 2); + assert.deepEqual(readdirSync(out).sort(), ['a-dark.png', 'a-light.png']); + }); + + it('replaces a previous set wholesale, leaving no stale files', () => { + const out = outputPath(); + publishDirectory(staged({ 'old.png': 'OLD', 'a.png': 'v1' }), out, 't1'); + publishDirectory(staged({ 'a.png': 'v2' }), out, 't2'); + + assert.deepEqual(readdirSync(out), ['a.png']); + assert.equal(readFileSync(path.join(out, 'a.png'), 'utf8'), 'v2'); + }); + + it('merge mode overlays a partial set without deleting the rest', () => { + // The `--only` / `--theme` path: refreshing one shot must not wipe + // the other screenshots of the release. + const out = outputPath(); + publishDirectory( + staged({ 'a-dark.png': 'A1', 'b-dark.png': 'B1' }), + out, + 't1' + ); + + const count = publishDirectory(staged({ 'a-dark.png': 'A2' }), out, 't2', { + mode: 'merge', + }); + + assert.equal(count, 1); + assert.deepEqual(readdirSync(out).sort(), ['a-dark.png', 'b-dark.png']); + assert.equal(readFileSync(path.join(out, 'a-dark.png'), 'utf8'), 'A2'); + assert.equal(readFileSync(path.join(out, 'b-dark.png'), 'utf8'), 'B1'); + }); + + it('merge mode works when nothing was published before', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'A' }), out, 't', { mode: 'merge' }); + + assert.deepEqual(readdirSync(out), ['a.png']); + }); + + it('leaves no scratch directories behind', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'A' }), out, 'tok'); + + assert.deepEqual(readdirSync(path.dirname(out)), ['screenshots']); + }); + + it('rolls the previous set back when the swap fails', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'original' }), out, 't1'); + + let calls = 0; + const rename = (from, to) => { + calls += 1; + // First call retires the previous set; fail the swap that follows. + if (calls === 2) { + throw new Error('simulated EXDEV'); + } + renameSync(from, to); + }; + + assert.throws( + () => + publishDirectory(staged({ 'a.png': 'new' }), out, 'boom', { + rename, + }), + /simulated EXDEV/ + ); + + assert.equal(calls, 3, 'the rollback rename must have run'); + assert.equal(readFileSync(path.join(out, 'a.png'), 'utf8'), 'original'); + assert.deepEqual(readdirSync(path.dirname(out)), ['screenshots']); + }); +}); + +describe('external request verdict', () => { + it('ignores local and stubbed URLs, reports the rest once', () => { + const violations = externalRequestViolations([ + 'http://localhost:3211/player_api.php', + 'file:///dist/apps/web/index.html', + 'https://api.github.com/repos/4gray/iptvnator/releases', + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]); + + assert.deepEqual(violations, [ + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]); + }); + + it('returns nothing for a fully local run', () => { + assert.deepEqual( + externalRequestViolations([ + 'http://127.0.0.1:3211/assets/logo.svg', + 'data:image/png;base64,AAA', + ]), + [] + ); + }); +}); diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json new file mode 100644 index 000000000..31cef378c --- /dev/null +++ b/tools/release/screenshots.manifest.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "viewport": { "width": 1280, "height": 720 }, + "themes": ["dark", "light"], + "shots": [ + { + "slug": "dashboard", + "title": "Dashboard", + "setup": ["open-dashboard"] + }, + { + "slug": "settings", + "title": "Settings", + "setup": ["open-settings"] + }, + { + "slug": "xtream-vod-details", + "title": "Movie details", + "setup": ["open-xtream-vod=Action & Mystery"] + }, + { + "slug": "xtream-series-season-open", + "title": "Series season view", + "setup": ["open-xtream-series=Urban Drama"] + }, + { + "slug": "m3u-live-groups-two-column", + "title": "Live TV groups", + "setup": ["open-m3u-groups"] + } + ] +}