mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge remote-tracking branch 'origin/master' into agent/shared-marketing-poster-catalog
# Conflicts: # tools/release/capture-v020-screenshots.ts
This commit is contained in:
commit
e0bf2f7a1a
149 files changed
+10290
-1992
No files matched your search
+24
-1
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 vendorLine truncated
|
||||
- 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:<id>` 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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -75,6 +75,7 @@ type ElectronFixtures = {
|
||||
};
|
||||
|
||||
export type LaunchElectronAppOptions = {
|
||||
args?: readonly string[];
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<Record<string, number>>;
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<void> {
|
||||
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<CancellationIterationResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readMainCaptureStatus(app.electronApp)).databasePending,
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.toBe(0);
|
||||
await waitForTwoAnimationFrames(app.mainWindow);
|
||||
}
|
||||
|
||||
async function waitForOperationSettlement(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((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<BenchmarkConfiguration> {
|
||||
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<void> {
|
||||
const server = createTcpServer();
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.once('error', rejectPromise);
|
||||
server.listen(
|
||||
{ exclusive: true, host: '127.0.0.1', port },
|
||||
resolvePromise
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) =>
|
||||
error ? rejectPromise(error) : resolvePromise()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertRendererCdpTarget(page: Page): Promise<void> {
|
||||
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<string, unknown>)['type'] ===
|
||||
'page' &&
|
||||
(target as Record<string, unknown>)['url'] ===
|
||||
page.url()
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function assertMissing(path: string): Promise<void> {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Performance output already exists: ${path}`);
|
||||
}
|
||||
|
||||
async function startSyntheticServer(
|
||||
fixture: SyntheticM3uFixture
|
||||
): Promise<SyntheticServer> {
|
||||
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<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) =>
|
||||
error ? rejectPromise(error) : resolvePromise()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function writeJson(path: string, value: unknown): Promise<void> {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
@@ -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<void> {
|
||||
await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => {
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type WorkerTransferable = import('node:worker_threads').Transferable;
|
||||
interface CpuProfileHandle {
|
||||
stop(): Promise<unknown>;
|
||||
}
|
||||
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<WorkerCpuUsage>;
|
||||
getHeapSnapshot?(): Promise<NodeJS.ReadableStream>;
|
||||
getHeapStatistics?(): Promise<WorkerHeapStatistics>;
|
||||
on(event: 'message', listener: (message: unknown) => void): this;
|
||||
performance?: {
|
||||
eventLoopUtilization(
|
||||
utilization1?: WorkerElu,
|
||||
utilization2?: WorkerElu
|
||||
): WorkerElu;
|
||||
};
|
||||
postMessage(
|
||||
message: unknown,
|
||||
transferList?: readonly WorkerTransferable[]
|
||||
): void;
|
||||
startCpuProfile?(): Promise<CpuProfileHandle>;
|
||||
terminate(): Promise<number>;
|
||||
}
|
||||
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<void> | null;
|
||||
heapPeak: number;
|
||||
kind: 'database.worker' | 'playlist-refresh.worker';
|
||||
operationId: string | null;
|
||||
playlistId: string | null;
|
||||
postGcHeapUsed: number | null;
|
||||
profileHandle: Promise<CpuProfileHandle> | 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<string, unknown>;
|
||||
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<InstrumentedWorker, WorkerRecord>();
|
||||
const operationWorkers = new Map<string, WorkerRecord>();
|
||||
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<TimelineRecord, 'epochMs'>
|
||||
): 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<void> => {
|
||||
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<void> => {
|
||||
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<number> {
|
||||
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<JsonRecord> =>
|
||||
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<void> => {
|
||||
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<MainCaptureMetrics> => {
|
||||
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<void>((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<void> {
|
||||
await electronApp.evaluate(
|
||||
async (_electron, input) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const api = target[input.stateKey] as {
|
||||
start(options: MainCaptureStartOptions): Promise<void>;
|
||||
};
|
||||
await api.start(input.options);
|
||||
},
|
||||
{ options, stateKey: MAIN_CAPTURE_STATE_KEY }
|
||||
);
|
||||
}
|
||||
|
||||
export async function readMainCaptureStatus(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<MainCaptureStatus> {
|
||||
return electronApp.evaluate(async (_electron, stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const api = target[stateKey] as {
|
||||
status(): MainCaptureStatus;
|
||||
};
|
||||
return api.status();
|
||||
}, MAIN_CAPTURE_STATE_KEY);
|
||||
}
|
||||
|
||||
export async function stopMainCapture(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<MainCaptureMetrics> {
|
||||
return electronApp.evaluate(async (_electron, stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const api = target[stateKey] as {
|
||||
stop(): Promise<MainCaptureMetrics>;
|
||||
};
|
||||
return api.stop();
|
||||
}, MAIN_CAPTURE_STATE_KEY);
|
||||
}
|
||||
@@ -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<number>;
|
||||
stop(): Promise<RendererCaptureMetrics>;
|
||||
waitForCancelClick(): Promise<void>;
|
||||
waitForTerminal(): Promise<void>;
|
||||
}
|
||||
|
||||
interface HeapUsageResult {
|
||||
readonly usedSize: number;
|
||||
}
|
||||
|
||||
interface TraceData {
|
||||
readonly value: readonly unknown[];
|
||||
}
|
||||
|
||||
export async function startRendererCapture(
|
||||
page: Page,
|
||||
options: RendererCaptureStartOptions
|
||||
): Promise<RunningRendererCapture> {
|
||||
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<void> => {
|
||||
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<typeof createWriteStream> | null = null;
|
||||
let traceFirstEvent = true;
|
||||
let resolveTraceComplete: (() => void) | null = null;
|
||||
const traceComplete = new Promise<void>((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<void> {
|
||||
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<string, number>;
|
||||
uiSettledEpochMs: number | null;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
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<string, number>,
|
||||
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<number> {
|
||||
return page.evaluate((stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
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<RendererProbeMetrics> {
|
||||
return page.evaluate((stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
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<string, number>;
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string>(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'),
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean, []>;
|
||||
isFullScreen: jest.Mock<boolean, []>;
|
||||
isMaximized: jest.Mock<boolean, []>;
|
||||
on: jest.Mock<void, [string, () => void]>;
|
||||
webContents: {
|
||||
send: jest.Mock<void, [string, unknown]>;
|
||||
};
|
||||
};
|
||||
|
||||
function createMockStateWindow(): MockStateWindow {
|
||||
return {
|
||||
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
isFullScreen: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
on: jest.fn<void, [string, () => void]>(),
|
||||
webContents: {
|
||||
send: jest.fn<void, [string, unknown]>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,10 @@ import { store } from './services/store.service';
|
||||
type MockMainWindow = {
|
||||
center: jest.Mock<void, []>;
|
||||
getNormalBounds: jest.Mock<object, []>;
|
||||
// Read once by attachWindowStateEvents to seed the tracked window
|
||||
// state; only reached off macOS, where the custom controls exist.
|
||||
isFullScreen: jest.Mock<boolean, []>;
|
||||
isMaximized: jest.Mock<boolean, []>;
|
||||
loadFile: jest.Mock<Promise<void>, [string]>;
|
||||
loadURL: jest.Mock<Promise<void>, [string]>;
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
@@ -72,6 +76,8 @@ function createMockMainWindow(): MockMainWindow {
|
||||
return {
|
||||
center: jest.fn<void, []>(),
|
||||
getNormalBounds: jest.fn<object, []>().mockReturnValue({}),
|
||||
isFullScreen: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
|
||||
@@ -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<ElectronBridgeWindowState>) => {
|
||||
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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<TmdbCacheStats> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 }));
|
||||
@@ -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']],
|
||||
|
||||
@@ -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<number>((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 () => {
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
|
||||
@@ -170,76 +169,126 @@ ipcMain.handle(
|
||||
async (event, payload: PlaylistRefreshPayload) => {
|
||||
const worker = resolvePlaylistRefreshWorker();
|
||||
|
||||
return await new Promise<Playlist>((resolve, reject) => {
|
||||
const cleanup = async (): Promise<void> => {
|
||||
activePlaylistRefreshes.delete(payload.operationId);
|
||||
worker.removeAllListeners();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
};
|
||||
return await new Promise<Playlist | PlaylistRefreshCancelledResult>(
|
||||
(resolve, reject) => {
|
||||
let cleanupPromise: Promise<void> | 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<void> => {
|
||||
cleanupPromise ??= (async () => {
|
||||
activePlaylistRefreshes.delete(payload.operationId);
|
||||
worker.removeAllListeners();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
})();
|
||||
return cleanupPromise;
|
||||
};
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
|
||||
if (message.type === 'ready') {
|
||||
worker.postMessage({
|
||||
type: 'request',
|
||||
payload,
|
||||
});
|
||||
const cancel = async (): Promise<void> => {
|
||||
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<Playlist>;
|
||||
if (response.success && response.result) {
|
||||
resolve(response.result);
|
||||
const activeRefresh: ActivePlaylistRefresh = { cancel };
|
||||
activePlaylistRefreshes.set(payload.operationId, activeRefresh);
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
|
||||
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<Playlist>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<TResult = unknown> {
|
||||
success: boolean;
|
||||
result?: TResult;
|
||||
error?: SerializedWorkerError;
|
||||
performance?: WorkerPerformanceCaptureResult;
|
||||
}
|
||||
|
||||
export type DbWorkerIncomingMessage =
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TResult = unknown> {
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
performance?: WorkerPerformanceCaptureResult;
|
||||
}
|
||||
|
||||
export type PlaylistRefreshWorkerIncomingMessage =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
if (capture) {
|
||||
await new Promise<void>((resolvePromise) =>
|
||||
setTimeout(resolvePromise, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishWorkerPerformanceCapture(
|
||||
capture: WorkerPerformanceCapture | null
|
||||
): Promise<WorkerPerformanceCaptureResult | undefined> {
|
||||
if (!capture) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await new Promise<void>((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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'stalker-mock-server',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/stalker-mock-server',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,12 +118,12 @@ export interface GeneratedPortalData {
|
||||
radioCategories: RawCategory[];
|
||||
channels: Map<string, RawChannel[]>; // categoryId -> channels
|
||||
radio: Map<string, RawRadioStation[]>; // categoryId -> radio stations
|
||||
vod: Map<string, RawVodItem[]>; // categoryId -> items
|
||||
vod: Map<string, RawVodItem[]>; // categoryId -> items
|
||||
/** Preserves the provider-neutral fixture order for the "*" VOD listing. */
|
||||
vodOrder?: RawVodItem[];
|
||||
series: Map<string, RawSeriesItem[]>; // categoryId -> items
|
||||
seasons: Map<string, RawSeason[]>; // seriesItemId -> seasons
|
||||
epg: Map<string, RawEpgProgram[]>; // channelId -> programs
|
||||
seasons: Map<string, RawSeason[]>; // seriesItemId -> seasons
|
||||
epg: Map<string, RawEpgProgram[]>; // 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(', '),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
buildRequestOrigin,
|
||||
resolveMarketingPosterUrls,
|
||||
} from './marketing-poster-url';
|
||||
|
||||
type RequestLike = Parameters<typeof buildRequestOrigin>[0];
|
||||
|
||||
function request(
|
||||
protocol: string,
|
||||
headers: Record<string, string | undefined>
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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?):\/\//, '');
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
const {
|
||||
macAddress,
|
||||
url: _url,
|
||||
...rest
|
||||
} = req.query as Record<string, string>;
|
||||
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`);
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -99,6 +99,47 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
|
||||
@if (cacheError()) {
|
||||
<span class="tmdb-cache__error">
|
||||
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
|
||||
</span>
|
||||
} @else if (cacheStats(); as stats) {
|
||||
{{
|
||||
'SETTINGS.TMDB_CACHE_SIZE'
|
||||
| translate
|
||||
: {
|
||||
entries: stats.entries,
|
||||
size: formatBytes(stats.bytes),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isClearing() || cacheStats()?.entries === 0"
|
||||
(click)="clearCache()"
|
||||
data-test-id="tmdb-clear-cache"
|
||||
>
|
||||
@if (isClearing()) {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
|
||||
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
|
||||
|
||||
const CACHE_SIZE_LABEL = '{{entries}} entries · {{size}}';
|
||||
const CACHE_ERROR_LABEL = 'Could not read the cache';
|
||||
|
||||
describe('SettingsTmdbSectionComponent', () => {
|
||||
let fixture: ComponentFixture<SettingsTmdbSectionComponent>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
|
||||
readonly keyTestState = signal<TmdbKeyTestState>('idle');
|
||||
readonly cacheStats = signal<TmdbCacheStats | null>(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<void> {
|
||||
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<void> {
|
||||
const stats = await this.tmdbCache.getStats();
|
||||
this.cacheError.set(stats === null);
|
||||
this.cacheStats.set(stats);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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": "تفضيلات التطبيق",
|
||||
|
||||
@@ -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": "تفضيلات التطبيق",
|
||||
|
||||
@@ -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": "Параметры праграмы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Προτιμήσεις εφαρμογής",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アプリ設定",
|
||||
|
||||
@@ -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": "앱 환경설정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Параметры приложения",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "应用偏好",
|
||||
|
||||
@@ -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": "應用程式偏好設定",
|
||||
|
||||
@@ -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 <parent>@<version> 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/<slug>.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
|
||||
`<portal-origin>/assets/marketing/poster/<slug>.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)
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -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:<id>` 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:<id>` 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:<tmdbId>|v2' -- details payload row
|
||||
'id:<tmdbId>|season:<n>' -- season payload row
|
||||
'title:<normalized>|year:<y>|v2' -- search resolution row
|
||||
'person:<personId>' -- person payload row
|
||||
'trending:week' -- trending list row
|
||||
'badProviderId:<tmdbId>' -- 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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export const FavoritesActions = createActionGroup({
|
||||
events: {
|
||||
'Update Favorites': props<{ channel: Channel }>(),
|
||||
'Set Favorites': props<{ channelIds: string[] }>(),
|
||||
'Hydrate Favorites': props<{ channelIds: string[] }>(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
+48
-2
@@ -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<Playlist>();
|
||||
const secondResponse = new Subject<Playlist>();
|
||||
@@ -225,7 +271,7 @@ describe('M3uWorkspaceRouteSession', () => {
|
||||
[ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })],
|
||||
]);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
FavoritesActions.setFavorites({
|
||||
FavoritesActions.hydrateFavorites({
|
||||
channelIds: [NEXT_CHANNEL.url],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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: [] })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
: (playerSettings.player ?? null)
|
||||
"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="!!playerSettings.showCaptions"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
|
||||
@@ -117,7 +117,6 @@ class StubWebPlayerViewComponent {
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
|
||||
@@ -367,7 +367,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
/** Selected video player options */
|
||||
playerSettings: Partial<Settings> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<TmdbTvDetails> {
|
||||
return this.request<TmdbTvDetails>(
|
||||
`/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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, TmdbCacheEntry>();
|
||||
|
||||
/**
|
||||
* 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<Promise<unknown>>();
|
||||
|
||||
/** Set for the duration of {@link clear}; writes queue behind it */
|
||||
private clearInFlight: Promise<unknown> | 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<void> {
|
||||
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<TmdbCacheStats | null> {
|
||||
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<number | null> {
|
||||
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<number | null> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number>();
|
||||
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 } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user