Merge remote-tracking branch 'origin/master' into agent/shared-marketing-poster-catalog

# Conflicts:
#	tools/release/capture-v020-screenshots.ts
This commit is contained in:
4gray committed 2026-07-26 19:50:53 +02:00
commit e0bf2f7a1a
149 files changed
+10290 -1992

No files matched your search

+24 -1
View File
@@ -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.
+7
View File
@@ -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.
+8
View File
@@ -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.
+11
View File
@@ -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.
+10
View File
@@ -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.
+8
View File
@@ -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.
+9
View File
@@ -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.
+15 -5
View File
@@ -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):
+15 -5
View File
@@ -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):
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+21
View File
@@ -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
+9 -7
View File
@@ -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,
});
+10
View File
@@ -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]>(),
+36 -10
View File
@@ -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,
};
}
+13
View File
@@ -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',
};
+8
View File
@@ -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?):\/\//, '');
}
+41 -5
View File
@@ -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);
}
}
+5
View File
@@ -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": "تفضيلات التطبيق",
+5
View File
@@ -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": "تفضيلات التطبيق",
+5
View File
@@ -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": "Параметры праграмы",
+5
View File
@@ -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",
+5
View File
@@ -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": "Προτιμήσεις εφαρμογής",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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": "アプリ設定",
+5
View File
@@ -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": "앱 환경설정",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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": "Параметры приложения",
+5
View File
@@ -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",
+5
View File
@@ -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": "应用偏好",
+5
View File
@@ -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.
+49 -3
View File
@@ -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
+49 -5
View File
@@ -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
+89 -87
View File
@@ -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)
});
```
+95 -10
View File
@@ -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.
+19 -4
View File
@@ -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:
+3 -2
View File
@@ -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
+1
View File
@@ -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,
},
},
},
},
};
}),
};
}
),
];
@@ -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?.();
}
+1
View File
@@ -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';
+28 -4
View File
@@ -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();
});
});
+140 -10
View File
@@ -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;
}
}
}
+197
View File
@@ -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