From b4ec68c1fa33ee5ccbabf6aa74484857725f6593 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:19:02 +0200 Subject: [PATCH 01/12] feat(release): manifest-driven screenshot capture with fail-closed mock-data guards (#1261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the release-notes pipeline (#1256 format+generator, #1257 CI gate): release screenshots become reproducible and provably mock-only. The v0.20 capture script was single-use (hard-coded slugs, paths, hero) and fail-open: a lost IPTVNATOR_E2E_DATA_DIR silently fell back to the user's real ~/.iptvnator database, `...process.env` leaked ambient TMDB keys and proxies, nothing gated network access, and no frame content was ever validated. Each hole leaks real playlists, credentials, or copyrighted artwork into published screenshots without a single signal. New pipeline: - tools/release/screenshots.manifest.json — declarative shots (slug, title, named setup steps, themes). Adding a feature shot = one manifest entry. - capture-release-screenshots.ts — orchestrator; output goes to apps/website/public/blog//screenshots/-.png, release slug derived from package.json (or --release), --only/--theme filters. - capture-app-driver.ts / capture-navigation.ts — launch, seeding, theme, and the named-action vocabulary; actions are order-independent (every portal action starts from the dashboard). - screenshot-guards.mjs — the fail-closed policy, pure and unit-tested: G1 the real database is snapshotted (sha256+mtime) before launch and must be byte-identical after; the isolated DB must actually exist G2 the app receives an allowlisted environment, never ...process.env G3 deny-by-default network gate; known app-level calls (GitHub update check) are answered by local stubs; any other blocked request fails the run — a silently-blocked TMDB call would leave a frame that looks broken rather than unsafe G4 every frame is scanned before capture: external img/background URLs, credential-shaped text, MAC addresses, non-localhost m3u8 references G5 TMDB enrichment asserted disabled via the renderer's IndexedDB Any violation deletes every frame captured in the run and exits non-zero. The guards paid for themselves on the first live run: G3 caught the mock server redirecting stream endpoints to a public demo HLS (test-streams.mux.dev) — meaning earlier hand-run captures could embed third-party video frames. The M3U shot now deliberately captures the groups layout without starting playback. `.changes` validation now cross-checks `screenshot:` slugs against the manifest, so a note cannot reference an image the capture run never produces. Verified end-to-end: 10/10 shots (5 slugs × dark/light) captured against dist build + xtream-mock-server, frames visually inspected (fictional titles/artwork only), guard-violation paths exercised live. 67 unit tests in release-tools, lint green, script files within the repo size limit. Co-authored-by: Claude Opus 5 --- .changes/README.md | 25 +- .claude/skills/release-cut/SKILL.md | 20 +- .codex/skills/release-cut/SKILL.md | 20 +- package.json | 1 + tools/release/build-release-notes.mjs | 20 + tools/release/capture-app-driver.ts | 361 ++++++++++++ tools/release/capture-navigation.ts | 250 ++++++++ tools/release/capture-network-gate.ts | 122 ++++ tools/release/capture-release-screenshots.ts | 358 ++++++++++++ tools/release/capture-tmdb-check.ts | 56 ++ tools/release/project.json | 15 +- tools/release/screenshot-guards.mjs | 504 ++++++++++++++++ tools/release/screenshot-guards.test.mjs | 575 +++++++++++++++++++ tools/release/screenshots.manifest.json | 32 ++ 14 files changed, 2344 insertions(+), 15 deletions(-) create mode 100644 tools/release/capture-app-driver.ts create mode 100644 tools/release/capture-navigation.ts create mode 100644 tools/release/capture-network-gate.ts create mode 100644 tools/release/capture-release-screenshots.ts create mode 100644 tools/release/capture-tmdb-check.ts create mode 100644 tools/release/screenshot-guards.mjs create mode 100644 tools/release/screenshot-guards.test.mjs create mode 100644 tools/release/screenshots.manifest.json diff --git a/.changes/README.md b/.changes/README.md index 2e529ab0f..a2a8454b6 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -30,7 +30,7 @@ of the current season, watch progress, and click-to-play inline. | `type` | yes | `breaking`, `feature`, `fix`, `perf`, or `internal` | | `area` | yes | lowercase slug, same as the conventional-commit scope | | `issues` | no | issue numbers this closes — `[1187]` or `1187` | -| `screenshot` | no | slug from the release screenshot manifest | +| `screenshot` | no | slug from `tools/release/screenshots.manifest.json` | There is **no version field**. The release version is chosen deliberately at release time, not derived from these files. @@ -84,3 +84,26 @@ The website publishes **one post per minor version** (`v0-18` … `v0-22`), and release screenshots live under the matching `blog/v0-24/` directory. A patch release therefore edits the existing post rather than generating a new one, so `--format blog` refuses to overwrite unless you pass `--force`. + +## Screenshots + +`pnpm run release:screenshots` captures every manifest shot in dark and light +against the built app plus the Xtream mock server — never a real account. +The run is fail-closed: it proves the real `~/.iptvnator/databases` directory +(including the SQLite WAL sidecars, checked after Electron exits) was not +touched, launches the app with an allowlisted environment, records and blocks +all non-localhost traffic, scans every frame for external resources and +credential-shaped text, and asserts TMDB enrichment stays disabled. Frames are +staged outside the repository and published only once every shot and every +guard has passed. + +Adding a shot for a new feature = one entry in +`tools/release/screenshots.manifest.json` (plus, if navigation is new, one +named action in `tools/release/capture-navigation.ts`). + +```bash +pnpm nx run electron-backend:build-e2e # once, before capturing +pnpm run release:screenshots # all shots, both themes +pnpm run release:screenshots -- --only dashboard --theme dark +pnpm run release:screenshots -- --release v0-24 +``` diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 2007a7795..62e07edd5 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -42,11 +42,21 @@ is not optional. for a patch release, edit the existing post (the scaffold refuses to overwrite without `--force`). -5. **Screenshots** — only from the release capture script against the mock - servers (`tools/release/`), never from a real playlist or account: real - streams, logos, and TMDB artwork are copyrighted, and credentials must - never reach a published image. Output goes to - `apps/website/public/blog/v0-XX/screenshots/`. +5. **Screenshots** — only from the fail-closed capture script against the + mock servers, never from a real playlist or account: real streams, logos, + and TMDB artwork are copyrighted, and credentials must never reach a + published image. + + ```bash + pnpm nx run electron-backend:build-e2e # once + pnpm run release:screenshots # all manifest shots, dark+light + ``` + + Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature + to showcase = new entry in `tools/release/screenshots.manifest.json` + (slug must match the note's `screenshot:` field). The run aborts and + deletes its frames on any guard violation (real-DB touch, external + request, credential-shaped text in frame, TMDB active). 6. **Consume the notes** (the only destructive step): diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 2007a7795..62e07edd5 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -42,11 +42,21 @@ is not optional. for a patch release, edit the existing post (the scaffold refuses to overwrite without `--force`). -5. **Screenshots** — only from the release capture script against the mock - servers (`tools/release/`), never from a real playlist or account: real - streams, logos, and TMDB artwork are copyrighted, and credentials must - never reach a published image. Output goes to - `apps/website/public/blog/v0-XX/screenshots/`. +5. **Screenshots** — only from the fail-closed capture script against the + mock servers, never from a real playlist or account: real streams, logos, + and TMDB artwork are copyrighted, and credentials must never reach a + published image. + + ```bash + pnpm nx run electron-backend:build-e2e # once + pnpm run release:screenshots # all manifest shots, dark+light + ``` + + Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature + to showcase = new entry in `tools/release/screenshots.manifest.json` + (slug must match the note's `screenshot:` field). The run aborts and + deletes its frames on any guard violation (real-DB touch, external + request, credential-shaped text in frame, TMDB active). 6. **Consume the notes** (the only destructive step): diff --git a/package.json b/package.json index 872879829..7f4879c18 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "release:notes:github": "node tools/release/build-release-notes.mjs --format github", "release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog", "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", + "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" }, diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs index 7f7a27de1..83745334b 100644 --- a/tools/release/build-release-notes.mjs +++ b/tools/release/build-release-notes.mjs @@ -19,6 +19,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { loadNotes } from './release-notes.mjs'; +import { manifestSlugs } from './screenshot-guards.mjs'; import { releaseSlug, renderBlogScaffold, @@ -232,6 +233,25 @@ function main() { const notesDir = path.resolve(workspaceRoot, options.dir); const { notes, errors } = loadNotes(notesDir); + // `screenshot:` slugs must exist in the capture manifest, otherwise the + // blog scaffold would reference images the capture run never produces. + const slugs = manifestSlugs( + JSON.parse( + readFileSync( + path.join(workspaceRoot, 'tools/release/screenshots.manifest.json'), + 'utf8' + ) + ) + ); + + for (const note of notes) { + if (note.screenshot && !slugs.has(note.screenshot)) { + errors.push( + `${path.basename(note.sourcePath)}: \`screenshot: ${note.screenshot}\` is not a slug in tools/release/screenshots.manifest.json` + ); + } + } + if (errors.length > 0) { console.error('Invalid release notes:\n'); for (const error of errors) { diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts new file mode 100644 index 000000000..bd643d07f --- /dev/null +++ b/tools/release/capture-app-driver.ts @@ -0,0 +1,361 @@ +/** + * App driving for capture-release-screenshots.ts: launch, readiness, demo + * seeding, theme switching, and the named setup actions the manifest refers + * to. Mechanics proven in the v0.20 capture run, generalized behind action + * names. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from '@playwright/test'; + +import { registerPlaylistId, requirePlaylistId } from './capture-navigation'; + +export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211'; +export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo'; +export const M3U_FIXTURE_TITLE = 'release-demo'; + +/** Synthetic categories that only the marketing fixture generator produces. */ +const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama']; + + +/* ------------------------------------------------------------------ */ +/* Fixtures */ +/* ------------------------------------------------------------------ */ + +/** Entirely synthetic channels; streams and logos point at the mock. */ +export function writeM3uFixture(dataDir: string): string { + const channels = [ + ['Newsroom', 'Aurora Local', 'aurora-local'], + ['Newsroom', 'Civic Pulse', 'civic-pulse'], + ['Sports', 'Fieldside One', 'fieldside-one'], + ['Sports', 'Motion Arena', 'motion-arena'], + ['Kids', 'Horizon Kids', 'horizon-kids'], + ['Kids', 'Story Lantern', 'story-lantern'], + ['Culture', 'Atlas Culture', 'atlas-culture'], + ['Culture', 'Night Music', 'night-music'], + ]; + const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`; + const lines = ['#EXTM3U']; + + channels.forEach(([group, title, slug], index) => { + lines.push( + `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`, + stream + ); + }); + + const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`); + writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + return filePath; +} + +export async function ensureXtreamMockServer( + workspaceRoot: string +): Promise { + const healthUrl = `${XTREAM_MOCK_ORIGIN}/health`; + + // Reusing whatever answers on the port is not enough: every guard treats + // localhost as trusted, so an unrelated local server or proxy could feed + // real catalog data and artwork straight into published screenshots. + // Require the marketing fixtures this capture is built around. + if (await isHealthy(healthUrl)) { + await assertMockServerIdentity(); + return undefined; + } + + const child = spawn( + path.join(workspaceRoot, 'node_modules/.bin/tsx'), + ['apps/xtream-mock-server/src/main.ts'], + { + cwd: workspaceRoot, + env: { ...process.env, NODE_ENV: 'development', PORT: '3211' }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + child.stderr?.on('data', (chunk) => + process.stderr.write(`[xtream-mock] ${chunk}`) + ); + + const deadline = Date.now() + 20_000; + + while (Date.now() < deadline) { + if (await isHealthy(healthUrl)) { + return child; + } + await sleep(500); + } + + child.kill('SIGTERM'); + throw new Error(`xtream-mock-server did not become healthy at ${healthUrl}`); +} + +/** + * Confirms the service on the mock port is our fixture server: it must serve + * the marketing catalog with the exact synthetic titles the shots rely on. + */ +async function assertMockServerIdentity(): Promise { + const response = await fetch( + `${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=get_vod_categories` + ).catch(() => null); + + if (!response?.ok) { + throw new Error( + `Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.` + ); + } + + const categories = (await response.json().catch(() => null)) as + | { category_name?: string }[] + | null; + const names = new Set( + (categories ?? []).map((entry) => entry.category_name) + ); + + for (const expected of MOCK_FIXTURE_CATEGORIES) { + if (!names.has(expected)) { + throw new Error( + `The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing category "${expected}"). Refusing to capture screenshots from unknown data.` + ); + } + } +} + +async function isHealthy(url: string): Promise { + try { + return (await fetch(url)).ok; + } catch { + return false; + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/* ------------------------------------------------------------------ */ +/* Launch and readiness */ +/* ------------------------------------------------------------------ */ + +export async function launchApp( + electronMainPath: string, + env: Record, + hostResolverRules: string +): Promise { + // The resolver switch is the only part of the network gate with no + // install-timing window: it takes effect before Electron runs a single + // line of app code, so startup traffic cannot slip through ahead of the + // session hook. + return electron.launch({ + args: [ + electronMainPath, + `--host-resolver-rules=${hostResolverRules}`, + ], + env, + }); +} + +export async function findMainWindow(app: ElectronApplication): Promise { + await sleep(1500); + + for (const candidate of app.windows()) { + if (!(await candidate.title()).includes('DevTools')) { + return candidate; + } + } + + return app.firstWindow(); +} + +export async function sizeWindow( + app: ElectronApplication, + viewport: { width: number; height: number } +): Promise { + await app.evaluate(({ BrowserWindow }, size) => { + const win = BrowserWindow.getAllWindows().find( + (candidate) => !candidate.webContents.getTitle().includes('DevTools') + ); + win?.setSize(size.width, size.height); + win?.center(); + }, viewport); +} + +export async function waitForAppReady(page: Page): Promise { + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('app-root', { timeout: 45_000 }); + await page.waitForFunction( + () => + (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > + 0, + { timeout: 45_000 } + ); +} + +/* ------------------------------------------------------------------ */ +/* Seeding */ +/* ------------------------------------------------------------------ */ + +export async function seedDemoData(page: Page, m3uPath: string): Promise { + await addXtreamPortal(page); + await addM3uPlaylist(page, m3uPath); + await seedDashboardActivity(page); +} + +/** + * The dashboard hero and rails only render with favorites/recent activity. + * Seed a handful of mock titles through the Electron DB bridge; backdrops + * point at the mock server, keeping G3/G4 satisfied. + */ +async function seedDashboardActivity(page: Page): Promise { + const playlistId = requirePlaylistId('xtreams'); + const backdrop = (title: string) => + `${XTREAM_MOCK_ORIGIN}/assets/marketing/backdrop/${title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-')}.svg?size=${encodeURIComponent('1280x720')}`; + const items = [ + { xtreamId: 62000, type: 'movie', backdropUrl: backdrop('Crimson Skylark'), recent: true }, + { xtreamId: 62001, type: 'movie', backdropUrl: backdrop('The Voltage Guard'), recent: true }, + { xtreamId: 62002, type: 'movie', backdropUrl: backdrop('Midnight Mantle'), recent: false }, + { xtreamId: 72000, type: 'series', backdropUrl: backdrop('Skyline Sentinels'), recent: true }, + { xtreamId: 72001, type: 'series', backdropUrl: backdrop('The Aegis Club'), recent: false }, + ] as const; + + await page.evaluate( + async ({ items, playlistId }) => { + const bridge = ( + window as typeof window & { + electron?: { + dbAddFavorite?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; + dbAddRecentItem?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; + dbGetContentByXtreamId?: ( + xtreamId: number, + playlistId: string, + contentType?: 'live' | 'movie' | 'series' + ) => Promise<{ id: number } | null>; + }; + } + ).electron; + + if (!bridge?.dbGetContentByXtreamId) { + throw new Error('Electron database bridge is unavailable.'); + } + + for (const item of items) { + const content = await bridge.dbGetContentByXtreamId( + item.xtreamId, + playlistId, + item.type + ); + + if (!content?.id) { + throw new Error( + `Could not find imported Xtream content ${item.xtreamId}.` + ); + } + + await bridge.dbAddFavorite?.(content.id, playlistId, item.backdropUrl); + + if (item.recent) { + await bridge.dbAddRecentItem?.(content.id, playlistId, item.backdropUrl); + } + } + }, + { items, playlistId } + ); +} + +async function addXtreamPortal(page: Page): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + await clickDialogOption(dialog, /xtream credentials/i); + await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE); + await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN); + await dialog.locator('#username').fill('marketing'); + await dialog.locator('#password').fill('marketing'); + await dialog + .getByRole('button', { name: /^(add|add playlist)$/i }) + .last() + .click(); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { + timeout: 45_000, + }); + registerPlaylistId('xtreams', idFromUrl(page.url(), 'xtreams')); + await page + .locator('.category-content-layout, app-content-card') + .first() + .waitFor({ state: 'visible', timeout: 45_000 }); +} + +async function addM3uPlaylist(page: Page, m3uPath: string): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + // Unanchored: the radio's accessible name concatenates title + subtitle. + await clickDialogOption(dialog, /m3u file/i); + + const fileInput = dialog.locator('input[type="file"][name="playlist"]'); + await fileInput.setInputFiles(m3uPath); + await dialog + .getByRole('button', { name: /add playlist/i }) + .last() + .click({ timeout: 15_000 }); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { + timeout: 45_000, + }); + registerPlaylistId('playlists', idFromUrl(page.url(), 'playlists')); + await page + .locator('[data-test-id="channel-item"]') + .first() + .waitFor({ state: 'visible', timeout: 60_000 }); +} + +async function openAddPlaylistDialog(page: Page): Promise { + await page.getByRole('button', { name: /add playlist/i }).first().click(); + await page + .locator('mat-dialog-container') + .last() + .waitFor({ state: 'visible', timeout: 15_000 }); +} + +async function clickDialogOption( + dialog: ReturnType, + label: RegExp +): Promise { + // The add-playlist dialog has changed shape across releases: source + // methods were tabs, then plain buttons, now a radio group. + for (const role of ['radio', 'tab', 'button'] as const) { + const option = dialog.getByRole(role, { name: label }).first(); + + if ((await option.count()) > 0) { + await option.click(); + return; + } + } + + throw new Error(`Dialog option matching ${label} not found`); +} + + +function idFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { + // `provider` is a closed union, but build the pattern from a literal + // anyway so no future caller can inject regex syntax through it. + const pattern = + provider === 'playlists' + ? /\/workspace\/playlists\/([^/]+)\// + : /\/workspace\/xtreams\/([^/]+)\//; + const match = url.match(pattern); + + if (!match) { + throw new Error(`Could not extract ${provider} id from ${url}`); + } + + return match[1]; +} diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts new file mode 100644 index 000000000..9a8f033ed --- /dev/null +++ b/tools/release/capture-navigation.ts @@ -0,0 +1,250 @@ +/** + * Named setup actions for capture-release-screenshots.ts — the vocabulary + * that screenshots.manifest.json steps refer to — plus theme switching and + * the playlist-id registry the actions navigate with. + */ + +import type { Page } from '@playwright/test'; + +let m3uPlaylistId: string | undefined; +let xtreamPlaylistId: string | undefined; + +export function registerPlaylistId( + provider: 'playlists' | 'xtreams', + id: string +): void { + if (provider === 'playlists') { + m3uPlaylistId = id; + } else { + xtreamPlaylistId = id; + } +} + +export function requirePlaylistId( + provider: 'playlists' | 'xtreams' +): string { + return requireId(provider); +} + +function requireId(provider: 'playlists' | 'xtreams'): string { + const id = provider === 'playlists' ? m3uPlaylistId : xtreamPlaylistId; + + if (!id) { + throw new Error(`No captured ${provider} playlist id — seeding failed?`); + } + + return id; +} + +/* ------------------------------------------------------------------ */ +/* Theme */ +/* ------------------------------------------------------------------ */ + +export async function applyTheme( + page: Page, + theme: 'dark' | 'light' +): Promise { + await runAction(page, 'open-settings', null); + const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; + const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); + + await themeButton.scrollIntoViewIfNeeded(); + await themeButton.click(); + + const saveButton = page.locator('[data-test-id="save-settings"]').first(); + + if (await saveButton.isEnabled()) { + await saveButton.click(); + await settleUi(page); + } + + await page.waitForFunction( + (expectedTheme) => + document.body.classList.contains('dark-theme') === + (expectedTheme === 'dark'), + theme, + { timeout: 10_000 } + ); +} + +/* ------------------------------------------------------------------ */ +/* Named setup actions */ +/* ------------------------------------------------------------------ */ + +export async function runAction( + page: Page, + action: string, + param: string | null +): Promise { + switch (action) { + case 'open-settings': { + await page.locator('a[href$="/workspace/settings"]').first().click(); + await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); + await page + .locator('[data-test-id="settings-container"]') + .waitFor({ state: 'visible', timeout: 15_000 }); + return; + } + case 'open-dashboard': { + await page + .locator('a.brand[href$="/workspace/dashboard"]') + .first() + .click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await page + .locator('[data-test-id="dashboard-hero"]') + .waitFor({ state: 'visible', timeout: 30_000 }); + await settleUi(page); + return; + } + case 'open-xtream-vod': { + await openXtreamSection(page, 'vod', param ?? 'Action & Mystery'); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, + { timeout: 30_000 } + ); + await page + .locator('app-content-hero') + .waitFor({ state: 'visible', timeout: 30_000 }); + await page.waitForTimeout(700); + return; + } + case 'open-xtream-series': { + await openXtreamSection(page, 'series', param ?? 'Urban Drama'); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, + { timeout: 30_000 } + ); + await page + .locator('app-season-container') + .waitFor({ state: 'visible', timeout: 30_000 }); + + // Season tabs auto-select a season; click the first pill only + // when no episodes rendered on their own. + const episode = page + .locator('.episode-card, .episode-list-item') + .first(); + + if (!(await episode.isVisible().catch(() => false))) { + await page + .locator('.season-tabs__pill, [data-testid="season-dropdown"]') + .first() + .click(); + } + + await episode.waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForTimeout(700); + return; + } + case 'open-m3u-groups': { + const playlistId = requireId('playlists'); + + await goHome(page); + await page + .locator(`a[href*="/workspace/playlists/${playlistId}"]`) + .first() + .click(); + await page.waitForURL( + (url) => url.href.includes(`/workspace/playlists/${playlistId}/`), + { timeout: 20_000 } + ); + await clickHrefSuffix( + page, + `/workspace/playlists/${playlistId}/groups` + ); + await page + .locator('.group-nav-item') + .first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.locator('.group-nav-item').first().click(); + // Deliberately no channel click: starting playback would pull a + // real HLS stream (the mock redirects to a public demo stream), + // and third-party video frames must never enter a release shot. + await page + .locator('[data-test-id="channel-item"]') + .first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForTimeout(500); + return; + } + default: + throw new Error(`Unknown setup action: ${action}`); + } +} + +/** Returns to the dashboard via the always-visible brand link. */ +async function goHome(page: Page): Promise { + if (/\/workspace\/dashboard/.test(page.url())) { + return; + } + + await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await settleUi(page); +} + +async function openXtreamSection( + page: Page, + section: 'vod' | 'series', + category: string +): Promise { + // Manifest steps must be order-independent, so every portal action + // starts from the dashboard, whose sources rail links into the portal. + await goHome(page); + await clickHrefSuffix( + page, + `/workspace/xtreams/${requireId('xtreams')}/vod` + ); + + if (section !== 'vod') { + await clickHrefSuffix( + page, + `/workspace/xtreams/${requireId('xtreams')}/${section}` + ); + } + + const item = page + .locator('app-workspace-context-panel .category-item') + .filter({ hasText: category }) + .first(); + + await item.waitFor({ state: 'visible', timeout: 30_000 }); + await item.click(); + await page.waitForTimeout(600); + + const card = page.locator('.category-content-layout mat-card').first(); + await card.waitFor({ state: 'visible', timeout: 30_000 }); + await card.click(); +} + +async function clickHrefSuffix(page: Page, suffix: string): Promise { + await page.locator(`a[href$="${suffix}"]`).first().click(); + // Predicate rather than a RegExp built from the suffix: the value carries + // playlist ids and path separators, and hand-escaping only some + // metacharacters is how incomplete-sanitization bugs are born. + await page.waitForURL((url) => url.href.includes(suffix), { + timeout: 20_000, + }); +} + +export async function settleUi(page: Page): Promise { + await page + .locator('.mat-mdc-snack-bar-container') + .first() + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => undefined); + // Park the cursor so no nav item keeps its hover tooltip in frame. + await page.mouse.move(640, 700); + await page.evaluate(() => { + document + .querySelectorAll( + '.mat-mdc-snack-bar-container, simple-snack-bar, .mat-mdc-tooltip, .cdk-describedby-message-container' + ) + .forEach((element) => { + (element.closest('.cdk-overlay-pane') ?? element).remove(); + }); + }); + await page.waitForTimeout(250); +} + + diff --git a/tools/release/capture-network-gate.ts b/tools/release/capture-network-gate.ts new file mode 100644 index 000000000..6540e56e3 --- /dev/null +++ b/tools/release/capture-network-gate.ts @@ -0,0 +1,122 @@ +/** + * The Electron-side half of G3: a request gate in the main process covering + * both Chromium's network stack and Node's `fetch`. + * + * Known and unavoidable timing window: this can only be installed once + * `electron.launch()` resolves, and Playwright releases Electron's ready + * event before that — so a Node-stack request fired in the first + * milliseconds of `app.whenReady()` would escape it. It cannot be closed + * from the test side: Playwright deletes `NODE_OPTIONS` unconditionally + * (`playwright-core/lib/server/electron/electron.js`), which rules out a + * `--require` preload, and it exposes no pre-ready hook. Closing it entirely + * would mean holding renderer startup from inside `main.ts` — production code + * bent around a screenshot tool. + * + * What covers that window instead: + * - Chromium-stack traffic (renderer, main-process `net`) is blocked from + * process start by `--host-resolver-rules`, which has no window at all; + * - the only startup Node `fetch` in the app, the update check, returns + * early on `!app.isPackaged`, and the capture always runs unpackaged. + */ + +import type { ElectronApplication } from '@playwright/test'; + +type NetworkPolicy = { + schemes: string[]; + hosts: string[]; + protocols: string[]; + stubPrefixes: string[]; +}; + +/** + * Records and blocks at the Electron session level, from before the renderer + * boots. Blocking here as well as in the page route matters because + * `page.route` only covers renderer traffic: anything the main process fetches + * (updater, telemetry, a future feature) would otherwise reach the network and + * only be reported afterwards — too late to be a fail-closed boundary. + * + * Stubbed URLs are allowed through so the page-level route can fulfill them + * locally; every other non-local URL is cancelled outright. + */ +export async function installRequestRecorder( + app: ElectronApplication, + policy: NetworkPolicy +): Promise { + // The body is serialized into the Electron main process, where esbuild's + // `__name` helper does not exist — so it must contain no *named* inner + // function (`const isLocal = …` or `function isLocal()`), only anonymous + // callbacks. A named one throws `__name is not defined` at install time. + await app.evaluate(({ session }, appliedPolicy) => { + const store: string[] = []; + const scope = globalThis as unknown as { + __captureRequests: string[]; + __captureIsLocal: (url: string) => boolean; + fetch: typeof fetch; + }; + + scope.__captureRequests = store; + + // Assigned to a member expression on purpose: esbuild's keepNames + // wraps functions bound to an identifier with a `__name` helper that + // does not exist in this serialized context. + scope.__captureIsLocal = (url: string) => { + if (appliedPolicy.stubPrefixes.some((p) => url.startsWith(p))) { + return true; + } + + try { + const parsed = new URL(url); + + return ( + appliedPolicy.schemes.includes(parsed.protocol) || + (appliedPolicy.protocols.includes(parsed.protocol) && + appliedPolicy.hosts.includes(parsed.hostname)) + ); + } catch { + return appliedPolicy.schemes.some((s) => url.startsWith(s)); + } + }; + + session.defaultSession.webRequest.onBeforeRequest( + (details, callback) => { + store.push(details.url); + callback({ cancel: !scope.__captureIsLocal(details.url) }); + } + ); + + // Chromium's network stack — and therefore both the resolver switch + // and the webRequest hook — never sees main-process `fetch`, which + // runs on Node's stack. The app update service uses exactly that, so + // wrap it: record every URL and reject non-local ones outright. + const originalFetch = scope.fetch; + + scope.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + + store.push(url); + + if (!scope.__captureIsLocal(url)) { + return Promise.reject( + new Error(`capture network gate blocked ${url}`) + ); + } + + return originalFetch(input, init); + }; + }, policy); +} + +export async function drainRecordedRequests( + app: ElectronApplication +): Promise { + return app.evaluate( + () => + (globalThis as unknown as { __captureRequests?: string[] }) + .__captureRequests ?? [] + ); +} diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts new file mode 100644 index 000000000..83f7d6a6a --- /dev/null +++ b/tools/release/capture-release-screenshots.ts @@ -0,0 +1,358 @@ +/** + * Release screenshot capture, manifest-driven and fail-closed. + * + * pnpm release:screenshots # release slug from package.json + * pnpm release:screenshots --release v0-24 # explicit + * pnpm release:screenshots --only dashboard --theme dark + * + * Reads tools/release/screenshots.manifest.json and writes + * apps/website/public/blog//screenshots/-.png against + * dist builds + the xtream mock server. Guards (screenshot-guards.mjs): + * + * G1 the real ~/.iptvnator/databases directory — including the SQLite WAL + * sidecars, compared after Electron exits and checkpoints — is proven + * untouched + * G2 the app gets an allowlisted environment, never ...process.env + * G3 a main-process recorder (installed before the renderer boots) plus a + * page-level deny-by-default route; any external attempt fails the run + * G4 every frame is checked for external resources / credential text + * G5 TMDB stays disabled (fresh profile default, asserted via IndexedDB) + * + * Frames are staged outside the repository and published only after every + * shot and every guard has passed, so a failure can neither leave unsafe + * frames behind nor destroy previously committed release assets. + */ + +import { accessSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import type { Page } from '@playwright/test'; + +import { + buildCaptureEnv, + compareDatabaseStates, + evaluateFrameReport, + externalRequestViolations, + HOST_RESOLVER_RULES, + isAllowedRequestUrl, + networkPolicy, + parseSetupStep, + publishDirectory, + snapshotDatabaseState, + stubbedResponseFor, + validateManifest, + validateReleaseSlug, +} from './screenshot-guards.mjs'; +import * as driver from './capture-app-driver'; +import { applyTheme, runAction, settleUi } from './capture-navigation'; +import { assertTmdbDisabled } from './capture-tmdb-check'; +import { + drainRecordedRequests, + installRequestRecorder, +} from './capture-network-gate'; + +type Theme = 'dark' | 'light'; + +const workspaceRoot = process.cwd(); +const manifestPath = path.join( + workspaceRoot, + 'tools/release/screenshots.manifest.json' +); +const electronMainPath = path.join( + workspaceRoot, + 'dist/apps/electron-backend/main.js' +); +const realDbDir = path.join(homedir(), '.iptvnator/databases'); + +const args = process.argv.slice(2); +const flag = (name: string): string | null => { + const index = args.indexOf(`--${name}`); + + return index !== -1 ? (args[index + 1] ?? null) : null; +}; + +const blockedRequests: string[] = []; + +async function main(): Promise { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const manifestErrors = validateManifest(manifest); + + if (manifestErrors.length > 0) { + throw new Error(`Invalid manifest:\n ${manifestErrors.join('\n ')}`); + } + + const release = + flag('release') ?? `v${readAppVersion().split('.').slice(0, 2).join('-')}`; + const releaseError = validateReleaseSlug(release); + + if (releaseError) { + throw new Error(`--release rejected: ${releaseError}`); + } + + const only = flag('only'); + const themeFilter = flag('theme'); + const shots = manifest.shots.filter( + (shot: { slug: string }) => !only || shot.slug === only + ); + + if (shots.length === 0) { + throw new Error(`--only ${only} matches no manifest slug`); + } + + // Without this an erased cast would accept `--theme --only`, and every + // non-`dark` value silently renders as light into a misnamed file. + if (themeFilter && !manifest.themes.includes(themeFilter)) { + throw new Error( + `--theme "${themeFilter}" is not one of ${manifest.themes.join(', ')}` + ); + } + + const themes: Theme[] = themeFilter + ? [themeFilter as Theme] + : manifest.themes; + const blogRoot = path.join(workspaceRoot, 'apps/website/public/blog'); + const outputRoot = path.join(blogRoot, release, 'screenshots'); + + // Belt and braces: the slug is validated above, but assert the resolved + // path really lands inside the blog tree before anything deletes there. + assertSync( + path.resolve(outputRoot).startsWith(`${path.resolve(blogRoot)}${path.sep}`), + `refusing to publish outside the blog tree: ${outputRoot}` + ); + + assertBuiltRuntime(); + + // G1: snapshot the real database directory BEFORE anything launches. + const dbBefore = snapshotDatabaseState(realDbDir); + + // Frames are staged outside the repo and published only once every shot + // and every guard has passed, so a late failure can never destroy the + // release assets an earlier run already committed. + const stagingDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-shots-')); + const mockServer = await driver.ensureXtreamMockServer(workspaceRoot); + const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-')); + let app: Awaited> | undefined; + let recordedRequests: string[] = []; + let captured = 0; + let primaryError: unknown; + + try { + // G2: constructed environment — nothing ambient crosses over. + app = await driver.launchApp( + electronMainPath, + buildCaptureEnv(process.env, { + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }), + HOST_RESOLVER_RULES + ); + + // G3, first layer: a main-process gate, installed before the renderer + // can boot. It both records and blocks, because page-level routing + // exists only once a page handle is obtained and never covers what the + // main process itself fetches. + await installRequestRecorder(app, networkPolicy()); + + const page = await driver.findMainWindow(app); + + // G3, second layer: page-level deny-by-default, which also blocks. + await page.route('**/*', async (route) => { + const url = route.request().url(); + + if (isAllowedRequestUrl(url)) { + await route.continue(); + return; + } + + // Known app-level calls are answered locally so the run stays + // hermetic without failing on legitimate app behavior. + const stub = stubbedResponseFor(url); + + if (stub) { + await route.fulfill({ + body: stub.body, + contentType: stub.contentType, + }); + return; + } + + blockedRequests.push(url); + await route.abort(); + }); + + await driver.sizeWindow(app, manifest.viewport); + await driver.waitForAppReady(page); + await assertTmdbDisabled(page); // G5 + await driver.seedDemoData(page, driver.writeM3uFixture(dataDir)); + + for (const theme of themes) { + await applyTheme(page, theme); + + for (const shot of shots) { + for (const step of shot.setup) { + const { action, param } = parseSetupStep(String(step)); + await runAction(page, action, param); + } + + await captureShot(page, stagingDir, shot.slug, theme); + captured += 1; + } + } + + recordedRequests = await drainRecordedRequests(app); + + // A recorder that observes nothing is indistinguishable from a + // passing run, which is how a guard silently stops guarding. Assert + // it saw the renderer's own document load rather than merely "some" + // traffic: seeding requests alone would prove nothing about whether + // the hook existed before the renderer started fetching. + assertSync( + recordedRequests.some((url) => /\/index\.html?($|[?#])/.test(url)), + `G3 failed: the request recorder never saw the renderer document load (${recordedRequests.length} URL(s) recorded) — it was installed too late to cover startup` + ); + + // G3: an attempted external request means a fixture is wrong, not + // that we got away with it. + const external = externalRequestViolations([ + ...blockedRequests, + ...recordedRequests, + ]); + + if (external.length > 0) { + throw new Error( + `G3 failed: ${external.length} external request(s) were attempted:\n ${external + .slice(0, 15) + .join('\n ')}` + ); + } + + assertSync( + snapshotDatabaseState(path.join(dataDir, 'databases')).exists, + 'G1 failed: the isolated database was never created — the app did not honor IPTVNATOR_E2E_DATA_DIR' + ); + } catch (error) { + // Captured rather than rethrown: a failing run is exactly when a lost + // isolation override is most likely, so G1 must still be evaluated. + primaryError = error; + } finally { + // Close before the G1 comparison: SQLite runs in WAL mode, so writes + // may sit in -wal until the worker shuts down and checkpoints. + await app?.close().catch(() => undefined); + mockServer?.kill('SIGTERM'); + rmSync(dataDir, { recursive: true, force: true }); + } + + const dbViolation = compareDatabaseStates( + dbBefore, + snapshotDatabaseState(realDbDir) + ); + + if (dbViolation) { + rmSync(stagingDir, { recursive: true, force: true }); + throw new Error( + `G1 failed: ${dbViolation}${ + primaryError instanceof Error + ? `\n(raised while handling: ${primaryError.message})` + : '' + }` + ); + } + + if (primaryError) { + rmSync(stagingDir, { recursive: true, force: true }); + throw primaryError; + } + + // A filtered run refreshes a subset, so it must overlay rather than + // replace: publishing only the captured frames would delete every other + // screenshot of the release. + publishDirectory(stagingDir, outputRoot, String(process.pid), { + mode: only || themeFilter ? 'merge' : 'replace', + }); + rmSync(stagingDir, { recursive: true, force: true }); + console.log( + `Captured ${captured} screenshot(s) into ${path.relative(workspaceRoot, outputRoot)}` + ); + console.log( + `Guards passed: ${new Set(recordedRequests).size} distinct request(s) observed, all local.` + ); +} + +async function captureShot( + page: Page, + outputRoot: string, + slug: string, + theme: Theme +): Promise { + await settleUi(page); + + // G4: inspect the frame before trusting it. + const report = await page.evaluate(() => { + const urls = new Set(); + + document.querySelectorAll('img[src]').forEach((img) => { + urls.add((img as HTMLImageElement).src); + }); + document.querySelectorAll('*').forEach((element) => { + const background = getComputedStyle(element).backgroundImage; + const match = background?.match(/url\("?([^")]+)"?\)/); + + if (match) { + urls.add(match[1]); + } + }); + + return { + resourceUrls: [...urls], + bodyText: document.body.innerText, + }; + }); + + const violations = evaluateFrameReport(report); + + if (violations.length > 0) { + throw new Error( + `G4 failed on ${slug} (${theme}):\n ${violations.join('\n ')}` + ); + } + + await page.screenshot({ + path: path.join(outputRoot, `${slug}-${theme}.png`), + type: 'png', + }); + console.log(` ✓ ${slug} (${theme})`); +} + +function assertBuiltRuntime(): void { + for (const required of [ + electronMainPath, + path.join(workspaceRoot, 'dist/apps/web/index.html'), + ]) { + try { + accessSync(required); + } catch { + throw new Error( + `Built runtime missing: ${required}\nBuild it first: pnpm nx run electron-backend:build-e2e` + ); + } + } +} + +function readAppVersion(): string { + return JSON.parse( + readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') + ).version; +} + +function assertSync(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tools/release/capture-tmdb-check.ts b/tools/release/capture-tmdb-check.ts new file mode 100644 index 000000000..8f0df00b6 --- /dev/null +++ b/tools/release/capture-tmdb-check.ts @@ -0,0 +1,56 @@ +/** + * G5: proof that TMDB enrichment stays off for the capture profile, so no + * licensed poster or still can reach a published screenshot. + */ + +import type { Page } from '@playwright/test'; + +/** + * G5: settings live in the renderer's IndexedDB (ngx-pwa StorageMap). A + * fresh profile has no entry, which means the TMDB defaults (disabled, + * empty key) apply. Read-only assertion — if a future change flips the + * default or seeds a key, the run stops before a licensed poster can render. + */ +export async function assertTmdbDisabled(page: Page): Promise { + const tmdb = await page.evaluate( + () => + new Promise<{ enabled?: boolean; apiKey?: string } | null>( + (resolve) => { + const request = indexedDB.open('ngStorage'); + + request.onerror = () => resolve(null); + request.onsuccess = () => { + const db = request.result; + + if (!db.objectStoreNames.contains('localStorage')) { + resolve(null); + return; + } + + const get = db + .transaction('localStorage') + .objectStore('localStorage') + .get('settings'); + + get.onerror = () => resolve(null); + get.onsuccess = () => + resolve( + (get.result as { tmdb?: { enabled?: boolean; apiKey?: string } }) + ?.tmdb ?? null + ); + }; + } + ) + ); + + assertSync( + !tmdb?.enabled && !tmdb?.apiKey, + `G5 failed: TMDB enrichment is active in the capture profile (${JSON.stringify(tmdb)})` + ); +} + +function assertSync(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/tools/release/project.json b/tools/release/project.json index 5bc632856..ed024a440 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -12,11 +12,14 @@ "{workspaceRoot}/tools/release/release-notes-render.mjs", "{workspaceRoot}/tools/release/extract-changelog-section.mjs", "{workspaceRoot}/tools/release/check-release-note-gate.mjs", + "{workspaceRoot}/tools/release/screenshot-guards.mjs", + "{workspaceRoot}/tools/release/screenshots.manifest.json", "{workspaceRoot}/tools/release/release-notes.test.mjs", - "{workspaceRoot}/tools/release/release-note-gate.test.mjs" + "{workspaceRoot}/tools/release/release-note-gate.test.mjs", + "{workspaceRoot}/tools/release/screenshot-guards.test.mjs" ], "options": { - "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs", + "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/screenshot-guards.test.mjs", "cwd": "{workspaceRoot}" } }, @@ -27,8 +30,12 @@ "{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint/**/*" ], - "command": "eslint \"tools/release/*.mjs\"" + "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\"" } }, - "tags": ["scope:tools", "domain:release", "type:tool"] + "tags": [ + "scope:tools", + "domain:release", + "type:tool" + ] } diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs new file mode 100644 index 000000000..ae29b4950 --- /dev/null +++ b/tools/release/screenshot-guards.mjs @@ -0,0 +1,504 @@ +/** + * Fail-closed guards for the release screenshot capture + * (capture-release-screenshots.ts). Published screenshots must never contain + * a real playlist, credential, stream, or third-party artwork — these + * helpers make that a property of the run, not a hope. + * + * Pure (or fs-only) so the policy is unit-tested; the Playwright driver just + * wires them in. + */ + +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; + +/* ------------------------------------------------------------------ */ +/* Manifest */ +/* ------------------------------------------------------------------ */ + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +/** The capture and the blog scaffold both assume exactly these themes. */ +export const SUPPORTED_THEMES = ['dark', 'light']; + +/** + * Chromium switch applied at process start, so it has no install-timing + * window at all: every hostname fails to resolve except the local mock. It + * covers Chromium-stack traffic (renderer plus the main process `net` + * module); requests made through Node's own stack are not affected, which is + * why the session hook and the recorded-URL verdict still exist. + */ +export const HOST_RESOLVER_RULES = + 'MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1'; + +/** Setup actions the driver implements. Manifest steps must match. */ +export const KNOWN_ACTIONS = [ + 'open-dashboard', + 'open-settings', + 'open-xtream-vod', + 'open-xtream-series', + 'open-m3u-groups', +]; + +/** @param {string} step e.g. `open-xtream-vod=Hero Premieres` */ +export function parseSetupStep(step) { + const separator = step.indexOf('='); + const action = separator === -1 ? step : step.slice(0, separator); + const param = separator === -1 ? null : step.slice(separator + 1); + + return { action, param }; +} + +/** + * @param {object} manifest parsed screenshots.manifest.json + * @returns {string[]} problems; empty when valid + */ +export function validateManifest(manifest) { + const errors = []; + + if (manifest?.version !== 1) { + errors.push('manifest `version` must be 1'); + } + + // The blog scaffold always references both `-dark.png` and `-light.png`, + // and a full run publishes in replace mode — a manifest missing a theme + // would silently delete the previous set and leave the post with broken + // images. + const themes = manifest?.themes; + + if ( + !Array.isArray(themes) || + themes.length !== SUPPORTED_THEMES.length || + SUPPORTED_THEMES.some((theme) => !themes.includes(theme)) + ) { + errors.push( + `manifest \`themes\` must be exactly ${SUPPORTED_THEMES.join(' and ')}, got ${JSON.stringify(themes)}` + ); + } + + const shots = Array.isArray(manifest?.shots) ? manifest.shots : null; + + if (!shots || shots.length === 0) { + return [...errors, 'manifest `shots` must be a non-empty array']; + } + + const seen = new Set(); + + for (const shot of shots) { + const label = shot?.slug ?? ''; + + if (!shot?.slug || !SLUG_PATTERN.test(shot.slug)) { + errors.push(`shot "${label}": slug must be a lowercase slug`); + } + + if (seen.has(shot?.slug)) { + errors.push(`shot "${label}": duplicate slug`); + } + + seen.add(shot?.slug); + + if (!Array.isArray(shot?.setup) || shot.setup.length === 0) { + errors.push(`shot "${label}": setup must be a non-empty array`); + continue; + } + + for (const step of shot.setup) { + const { action } = parseSetupStep(String(step)); + + if (!KNOWN_ACTIONS.includes(action)) { + errors.push( + `shot "${label}": unknown setup action "${action}" (expected ${KNOWN_ACTIONS.join(', ')})` + ); + } + } + } + + return errors; +} + +/** + * A release slug names a directory under apps/website/public/blog, which a + * full run replaces — including a recursive delete of what was there. Reject + * anything that could escape that tree or resolve somewhere unexpected. + * + * @param {string} release + * @returns {string | null} error message, or null when the slug is safe + */ +export function validateReleaseSlug(release) { + if (!release) { + return 'release slug is empty'; + } + + if (!/^[a-z0-9][a-z0-9.-]*$/.test(release) || release.includes('..')) { + return `release slug "${release}" must be a lowercase slug without path separators (for example v0-24)`; + } + + return null; +} + +/** @returns {Set} all slugs, for `.changes/` screenshot validation */ +export function manifestSlugs(manifest) { + return new Set((manifest?.shots ?? []).map((shot) => shot.slug)); +} + +/* ------------------------------------------------------------------ */ +/* G2 — environment allowlist */ +/* ------------------------------------------------------------------ */ + +const ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'SHELL', + 'TMPDIR', + 'TZ', + 'LANG', + 'DISPLAY', + 'WAYLAND_DISPLAY', + // X11 sessions authenticate through an xauth cookie file; without it the + // Electron child cannot open the display and the run dies before a window + // exists. It grants display access, not application credentials. + 'XAUTHORITY', +]; +const ENV_ALLOWED_PREFIXES = ['LC_', 'XDG_']; + +/** + * The capture app gets a constructed environment, never `...process.env`: + * ambient variables are how TMDB keys, proxies, and experiment flags leak + * into a run that must be hermetic. + * + * @param {Record} baseEnv + * @param {Record} overrides explicit run configuration + * @returns {Record} + */ +export function buildCaptureEnv(baseEnv, overrides) { + const env = {}; + + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined) { + continue; + } + + if ( + ENV_ALLOWLIST.includes(key) || + ENV_ALLOWED_PREFIXES.some((prefix) => key.startsWith(prefix)) + ) { + env[key] = value; + } + } + + return { ...env, ...overrides }; +} + +/* ------------------------------------------------------------------ */ +/* G3 — network gate */ +/* ------------------------------------------------------------------ */ + +/** Single source of truth for what may load during a capture run. */ +export const ALLOWED_SCHEMES = [ + 'file:', + 'data:', + 'blob:', + 'about:', + 'chrome:', + 'devtools:', +]; +export const ALLOWED_HOSTS = ['localhost', '127.0.0.1']; +export const ALLOWED_NETWORK_PROTOCOLS = ['http:', 'ws:']; + +/** + * Deny by default. Only local mock traffic and the app's own bundle may load; + * anything else (TMDB, logo CDNs, real streams) is blocked and — because the + * caller fails on a non-empty list — fatal, since a silently blocked request + * produces a frame that merely looks broken instead of unsafe. + * + * @param {string} url + * @returns {boolean} + */ +export function isAllowedRequestUrl(url) { + try { + const { hostname, protocol } = new URL(url); + + if (ALLOWED_SCHEMES.includes(protocol)) { + return true; + } + + return ( + ALLOWED_NETWORK_PROTOCOLS.includes(protocol) && + ALLOWED_HOSTS.includes(hostname) + ); + } catch { + // `blob:file:///…` and similar opaque forms do not parse as URLs. + return ALLOWED_SCHEMES.some((scheme) => url.startsWith(scheme)); + } +} + +/** + * App-level requests that are legitimate in production but must not leave + * the machine during a capture run. The driver fulfills them locally with + * an empty payload instead of letting them hit the network — a deterministic + * stub, not an allowlist hole. Keep this list minimal and exact. + * + * @param {string} url + * @returns {{ body: string, contentType: string } | null} stub response + */ +export const STUB_URL_PREFIXES = [ + // Settings "release notes" / update check. + 'https://api.github.com/repos/4gray/iptvnator/releases', +]; + +export function stubbedResponseFor(url) { + if (STUB_URL_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return { body: '[]', contentType: 'application/json' }; + } + + return null; +} + +/** + * Data handed to the main-process request hook, which cannot import this + * module. Keeping the values here means the two predicates cannot disagree + * about what is local even though the check itself is written twice. + * + * @returns {{ schemes: string[], hosts: string[], protocols: string[], stubPrefixes: string[] }} + */ +export function networkPolicy() { + return { + schemes: ALLOWED_SCHEMES, + hosts: ALLOWED_HOSTS, + protocols: ALLOWED_NETWORK_PROTOCOLS, + stubPrefixes: STUB_URL_PREFIXES, + }; +} + +/** + * Publishes a staged frame set over the release directory as one swap. + * + * Copy-then-rename rather than moving file by file: staging lives in the + * system temp directory, which may be a different filesystem (rename would + * fail with EXDEV), and a per-file loop that fails midway would leave the + * release holding a mix of new and old screenshots. Files are copied into a + * sibling of the target — same filesystem, so the directory swap is atomic — + * and the previous set is only deleted once the new one is in place. + * + * `mode` decides what happens to shots the staging set does not contain: + * a full run replaces the directory so slugs dropped from the manifest do not + * linger, while a filtered run (`--only`, `--theme`) must overlay its frames + * onto the existing set — otherwise refreshing one shot would delete every + * other screenshot of the release. + * + * @param {string} stagingDir + * @param {string} outputRoot + * @param {string} token unique suffix for the scratch directories + * @param {{ mode?: 'replace' | 'merge', rename?: (from: string, to: string) => void }} [options] + * `rename` is a seam for exercising the rollback path, which the filesystem + * will not fail on demand + * @returns {number} number of published files + */ +export function publishDirectory( + stagingDir, + outputRoot, + token, + { mode = 'replace', rename = renameSync } = {} +) { + const incoming = `${outputRoot}.incoming-${token}`; + const retired = `${outputRoot}.retired-${token}`; + + mkdirSync(path.dirname(outputRoot), { recursive: true }); + rmSync(incoming, { recursive: true, force: true }); + mkdirSync(incoming, { recursive: true }); + + if (mode === 'merge' && existsSync(outputRoot)) { + for (const name of readdirSync(outputRoot)) { + const source = path.join(outputRoot, name); + + if (statSync(source).isFile()) { + copyFileSync(source, path.join(incoming, name)); + } + } + } + + const names = readdirSync(stagingDir); + + for (const name of names) { + copyFileSync(path.join(stagingDir, name), path.join(incoming, name)); + } + + const hadPrevious = existsSync(outputRoot); + + if (hadPrevious) { + rename(outputRoot, retired); + } + + try { + rename(incoming, outputRoot); + } catch (error) { + if (hadPrevious) { + rename(retired, outputRoot); + } + + rmSync(incoming, { recursive: true, force: true }); + throw error; + } + + rmSync(retired, { recursive: true, force: true }); + + return names.length; +} + +/** + * Verdict over every request URL observed during a run — both the ones the + * page-level route blocked and the ones the main-process recorder saw during + * startup, before page interception exists. + * + * @param {string[]} urls + * @returns {string[]} unique offending URLs; empty when the run stayed local + */ +export function externalRequestViolations(urls) { + return [...new Set(urls)].filter( + (url) => !isAllowedRequestUrl(url) && !stubbedResponseFor(url) + ); +} + +/* ------------------------------------------------------------------ */ +/* G4 — frame content assertions */ +/* ------------------------------------------------------------------ */ + +const CREDENTIAL_TEXT_PATTERNS = [ + /https?:\/\/[^\s"']*(?:username|password)=/i, + /\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b/i, // MAC address (Stalker identity) + /https?:\/\/(?!localhost|127\.0\.0\.1)[^\s"']+\.m3u8?\b/i, +]; + +/** + * Evaluated against a DOM report collected right before each screenshot: + * every image/background URL and the visible text. External resources or + * credential-shaped text fail the shot. + * + * @param {{ resourceUrls: string[], bodyText: string }} report + * @returns {string[]} violations; empty when the frame is safe + */ +export function evaluateFrameReport(report) { + const violations = []; + + for (const url of report.resourceUrls) { + if (!isAllowedRequestUrl(url)) { + violations.push(`external resource in frame: ${url}`); + } + } + + for (const pattern of CREDENTIAL_TEXT_PATTERNS) { + const match = report.bodyText.match(pattern); + + if (match) { + violations.push( + `credential-shaped text visible in frame: "${match[0].slice(0, 80)}"` + ); + } + } + + return violations; +} + +/* ------------------------------------------------------------------ */ +/* G1 — real database untouched */ +/* ------------------------------------------------------------------ */ + +/** + * Snapshots every file in the real database directory — `iptvnator.db` plus + * its SQLite WAL sidecars (`-wal`, `-shm`), where writes live until a + * checkpoint, so a mutation cannot hide in them. + * + * Identity is size + mtime + inode, deliberately not a content hash: the + * production database is multi-gigabyte, and the previous `readFileSync` + + * sha256 approach threw on it and was swallowed into "file does not exist" — + * which silently disabled this guard entirely. Only ENOENT means absent; any + * other error propagates. + * + * @param {string} directory + * @returns {{ exists: boolean, entries: Record }} + */ +export function snapshotDatabaseState(directory) { + let names; + + try { + names = readdirSync(directory); + } catch (error) { + if (error.code === 'ENOENT') { + return { exists: false, entries: {} }; + } + + throw error; + } + + const entries = {}; + + for (const name of names.sort()) { + try { + const stats = statSync(path.join(directory, name)); + + if (stats.isFile()) { + entries[name] = { + size: stats.size, + mtimeMs: stats.mtimeMs, + ino: stats.ino, + }; + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + return { exists: true, entries }; +} + +/** + * @returns {string | null} violation message, or null when unchanged + */ +export function compareDatabaseStates(before, after) { + if (before.exists !== after.exists) { + return before.exists + ? 'the real database directory disappeared during the capture run' + : 'the real database directory was CREATED during the capture run'; + } + + if (!before.exists) { + return null; + } + + const names = new Set([ + ...Object.keys(before.entries), + ...Object.keys(after.entries), + ]); + + for (const name of [...names].sort()) { + const from = before.entries[name]; + const to = after.entries[name]; + + if (!from) { + return `the real database gained ${name} during the capture run`; + } + + if (!to) { + return `the real database lost ${name} during the capture run`; + } + + if ( + from.size !== to.size || + from.mtimeMs !== to.mtimeMs || + from.ino !== to.ino + ) { + return `the real database file ${name} was modified during the capture run (close IPTVnator and any other process using it, then retry)`; + } + } + + return null; +} diff --git a/tools/release/screenshot-guards.test.mjs b/tools/release/screenshot-guards.test.mjs new file mode 100644 index 000000000..6b80f852c --- /dev/null +++ b/tools/release/screenshot-guards.test.mjs @@ -0,0 +1,575 @@ +import assert from 'node:assert/strict'; +import { + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, describe, it } from 'node:test'; + +import { + buildCaptureEnv, + HOST_RESOLVER_RULES, + networkPolicy, + publishDirectory, + compareDatabaseStates, + evaluateFrameReport, + externalRequestViolations, + isAllowedRequestUrl, + manifestSlugs, + parseSetupStep, + snapshotDatabaseState, + stubbedResponseFor, + validateManifest, + validateReleaseSlug, +} from './screenshot-guards.mjs'; + +const tempDirs = []; + +after(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function validManifest() { + return { + version: 1, + viewport: { width: 1280, height: 720 }, + themes: ['dark', 'light'], + shots: [ + { slug: 'dashboard', title: 'Dashboard', setup: ['open-dashboard'] }, + { + slug: 'vod', + title: 'VOD', + setup: ['open-xtream-vod=Hero Premieres'], + }, + ], + }; +} + +describe('manifest validation', () => { + it('accepts the committed manifest shape', () => { + assert.deepEqual(validateManifest(validManifest()), []); + }); + + it('rejects unknown setup actions, bad slugs and duplicates', () => { + const manifest = validManifest(); + manifest.shots.push( + { slug: 'Bad Slug', title: 'x', setup: ['open-dashboard'] }, + { slug: 'dashboard', title: 'dupe', setup: ['fly-to-the-moon'] } + ); + + const errors = validateManifest(manifest); + + assert.ok(errors.some((error) => /slug must be a lowercase/.test(error))); + assert.ok(errors.some((error) => /duplicate slug/.test(error))); + assert.ok( + errors.some((error) => /unknown setup action "fly-to-the-moon"/.test(error)) + ); + }); + + it('rejects a manifest that does not carry exactly dark and light', () => { + for (const themes of [['dark'], ['dark', 'drak'], [], undefined, 'dark']) { + const manifest = { ...validManifest(), themes }; + + assert.ok( + validateManifest(manifest).some((error) => + /`themes` must be exactly/.test(error) + ), + `should reject themes: ${JSON.stringify(themes)}` + ); + } + }); + + it('accepts dark and light in either order', () => { + const manifest = { ...validManifest(), themes: ['light', 'dark'] }; + + assert.deepEqual(validateManifest(manifest), []); + }); + + it('rejects an empty or missing shots array', () => { + assert.ok( + validateManifest({ version: 1, themes: ['dark', 'light'], shots: [] }) + .length > 0 + ); + assert.ok( + validateManifest({ version: 1, themes: ['dark', 'light'] }).length > 0 + ); + }); + + it('parses setup steps with and without a parameter', () => { + assert.deepEqual(parseSetupStep('open-dashboard'), { + action: 'open-dashboard', + param: null, + }); + assert.deepEqual(parseSetupStep('open-xtream-vod=Hero Premieres'), { + action: 'open-xtream-vod', + param: 'Hero Premieres', + }); + }); + + it('exposes slugs for .changes screenshot validation', () => { + assert.deepEqual( + [...manifestSlugs(validManifest())], + ['dashboard', 'vod'] + ); + }); +}); + +describe('host resolver gate', () => { + it('blocks every host except the local mock', () => { + assert.match(HOST_RESOLVER_RULES, /^MAP \* ~NOTFOUND/); + assert.match(HOST_RESOLVER_RULES, /EXCLUDE localhost/); + assert.match(HOST_RESOLVER_RULES, /EXCLUDE 127\.0\.0\.1/); + }); +}); + +describe('release slug validation', () => { + it('accepts ordinary release slugs', () => { + for (const slug of ['v0-24', 'v0-24.1', 'v-smoke-test', 'v1-0']) { + assert.equal(validateReleaseSlug(slug), null, slug); + } + }); + + it('rejects traversal and separators that would escape the blog tree', () => { + for (const slug of [ + '../../assets', + '..', + 'v0-24/../../etc', + '/etc/passwd', + 'v0-24/nested', + 'V0-24', + '', + ]) { + assert.ok(validateReleaseSlug(slug), `should reject: ${slug}`); + } + }); +}); + +describe('network policy handed to the main process', () => { + it('carries the same data the in-process predicate uses', () => { + const policy = networkPolicy(); + + assert.ok(policy.schemes.includes('file:')); + assert.ok(policy.hosts.includes('127.0.0.1')); + assert.ok(policy.protocols.includes('http:')); + assert.deepEqual(policy.stubPrefixes, [ + 'https://api.github.com/repos/4gray/iptvnator/releases', + ]); + }); + + it('agrees with isAllowedRequestUrl on local and external URLs', () => { + const policy = networkPolicy(); + const isLocal = (url) => { + if (policy.stubPrefixes.some((p) => url.startsWith(p))) return true; + try { + const parsed = new URL(url); + return ( + policy.schemes.includes(parsed.protocol) || + (policy.protocols.includes(parsed.protocol) && + policy.hosts.includes(parsed.hostname)) + ); + } catch { + return policy.schemes.some((s) => url.startsWith(s)); + } + }; + + for (const url of [ + 'http://localhost:3211/a', + 'file:///x/index.html', + 'data:image/png;base64,AAA', + 'blob:file:///abc', + ]) { + assert.equal(isLocal(url), isAllowedRequestUrl(url), url); + } + + for (const url of [ + 'https://image.tmdb.org/p.jpg', + 'https://test-streams.mux.dev/x.m3u8', + 'https://localhost.evil.example/x', + ]) { + assert.equal(isLocal(url), false, url); + assert.equal(isAllowedRequestUrl(url), false, url); + } + }); +}); + +describe('G2 — environment allowlist', () => { + it('keeps only allowlisted variables plus explicit overrides', () => { + const env = buildCaptureEnv( + { + PATH: '/usr/bin', + HOME: '/Users/x', + LC_ALL: 'en_US.UTF-8', + XDG_RUNTIME_DIR: '/run/user/1000', + TMDB_API_KEY: 'leaky-secret', + HTTPS_PROXY: 'http://proxy:8080', + IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1', + AWS_SECRET_ACCESS_KEY: 'nope', + }, + { NODE_ENV: 'test', IPTVNATOR_E2E_DATA_DIR: '/tmp/x' } + ); + + assert.deepEqual(env, { + PATH: '/usr/bin', + HOME: '/Users/x', + LC_ALL: 'en_US.UTF-8', + XDG_RUNTIME_DIR: '/run/user/1000', + NODE_ENV: 'test', + IPTVNATOR_E2E_DATA_DIR: '/tmp/x', + }); + }); + + it('keeps the X11 display credentials Electron needs on Linux', () => { + // Without XAUTHORITY an xauth-based X11 session refuses the + // connection and the capture dies before a window exists. + const env = buildCaptureEnv( + { + DISPLAY: ':99', + XAUTHORITY: '/run/user/1000/gdm/Xauthority', + WAYLAND_DISPLAY: 'wayland-0', + TMDB_API_KEY: 'leaky-secret', + }, + {} + ); + + assert.deepEqual(env, { + DISPLAY: ':99', + XAUTHORITY: '/run/user/1000/gdm/Xauthority', + WAYLAND_DISPLAY: 'wayland-0', + }); + }); + + it('drops undefined values', () => { + assert.deepEqual(buildCaptureEnv({ PATH: undefined }, {}), {}); + }); +}); + +describe('G3 — network gate', () => { + it('allows localhost, loopback and internal schemes', () => { + for (const url of [ + 'http://localhost:3211/player_api.php?username=marketing', + 'http://127.0.0.1:3211/assets/logo.svg', + 'ws://localhost:4200/ng-cli-ws', + 'file:///dist/apps/web/index.html', + 'data:image/png;base64,AAA', + 'blob:file:///abc', + 'about:blank', + 'devtools://devtools/bundled/root.js', + ]) { + assert.equal(isAllowedRequestUrl(url), true, url); + } + }); + + it('blocks everything external', () => { + for (const url of [ + 'https://api.themoviedb.org/3/trending/all/week', + 'https://image.tmdb.org/t/p/w500/x.jpg', + 'http://real-provider.example:8080/live/user/pass/1.m3u8', + 'https://localhost.evil.example/x', // suffix-spoofed hostname + 'http://192.168.1.50/stream.ts', + 'not a url', + ]) { + assert.equal(isAllowedRequestUrl(url), false, url); + } + }); +}); + +describe('G3 — local stubs', () => { + it('stubs the GitHub releases update check with an empty payload', () => { + const stub = stubbedResponseFor( + 'https://api.github.com/repos/4gray/iptvnator/releases?per_page=100' + ); + + assert.deepEqual(stub, { body: '[]', contentType: 'application/json' }); + }); + + it('stubs nothing else', () => { + for (const url of [ + 'https://api.github.com/repos/4gray/iptvnator/issues', + 'https://api.github.com/repos/other/repo/releases', + 'https://api.themoviedb.org/3/trending/all/week', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]) { + assert.equal(stubbedResponseFor(url), null, url); + } + }); +}); + +describe('G4 — frame content assertions', () => { + it('passes a frame with only mock resources and clean text', () => { + const violations = evaluateFrameReport({ + resourceUrls: [ + 'http://localhost:3211/assets/marketing/poster/crimson-skylark.svg', + 'data:image/svg+xml;base64,AAA', + ], + bodyText: 'Crimson Skylark\nHero Premieres\nAurora Local', + }); + + assert.deepEqual(violations, []); + }); + + it('flags external artwork in the frame', () => { + const violations = evaluateFrameReport({ + resourceUrls: ['https://image.tmdb.org/t/p/w500/poster.jpg'], + bodyText: '', + }); + + assert.equal(violations.length, 1); + assert.match(violations[0], /external resource/); + }); + + it('flags credential-shaped URLs, MAC addresses and external m3u8 text', () => { + const violations = evaluateFrameReport({ + resourceUrls: [], + bodyText: [ + 'http://provider.example/get.php?username=real&password=secret', + '00:1A:79:12:34:56', + 'stream at http://cdn.example/live/42.m3u8 is down', + ].join('\n'), + }); + + assert.equal(violations.length, 3); + assert.ok(violations.every((entry) => /credential-shaped/.test(entry))); + }); + + it('does not flag the mock server stream URL', () => { + const violations = evaluateFrameReport({ + resourceUrls: [], + bodyText: 'http://localhost:3211/live/marketing/marketing/52000.m3u8', + }); + + assert.deepEqual(violations, []); + }); +}); + +describe('G1 — real database untouched', () => { + function tempDbDir(files) { + const dir = mkdtempSync(path.join(tmpdir(), 'guard-db-')); + tempDirs.push(dir); + + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + + return dir; + } + + it('reports no violation for an untouched directory', () => { + const dir = tempDbDir({ + 'iptvnator.db': 'main', + 'iptvnator.db-wal': 'wal', + 'iptvnator.db-shm': 'shm', + }); + + assert.equal( + compareDatabaseStates( + snapshotDatabaseState(dir), + snapshotDatabaseState(dir) + ), + null + ); + }); + + it('reports no violation when the directory does not exist', () => { + const missing = snapshotDatabaseState('/definitely/not/here-9d3f'); + + assert.equal(missing.exists, false); + assert.equal(compareDatabaseStates(missing, missing), null); + }); + + it('detects a write that only lands in the WAL sidecar', () => { + // The exact hole this replaced: hashing iptvnator.db alone missed + // writes that SQLite parks in -wal until a checkpoint. + const dir = tempDbDir({ 'iptvnator.db': 'main', 'iptvnator.db-wal': 'w' }); + const before = snapshotDatabaseState(dir); + writeFileSync(path.join(dir, 'iptvnator.db-wal'), 'w+more'); + + assert.match( + compareDatabaseStates(before, snapshotDatabaseState(dir)), + /iptvnator\.db-wal was modified/ + ); + }); + + it('detects a bare touch of the main database', () => { + const dir = tempDbDir({ 'iptvnator.db': 'same-bytes' }); + const before = snapshotDatabaseState(dir); + utimesSync( + path.join(dir, 'iptvnator.db'), + new Date(), + new Date(Date.now() + 5_000) + ); + + assert.match( + compareDatabaseStates(before, snapshotDatabaseState(dir)), + /iptvnator\.db was modified/ + ); + }); + + it('detects added and removed sidecars', () => { + const dir = tempDbDir({ 'iptvnator.db': 'main' }); + const before = snapshotDatabaseState(dir); + writeFileSync(path.join(dir, 'iptvnator.db-wal'), 'new'); + const after = snapshotDatabaseState(dir); + + assert.match(compareDatabaseStates(before, after), /gained iptvnator\.db-wal/); + assert.match(compareDatabaseStates(after, before), /lost iptvnator\.db-wal/); + }); + + it('detects a directory that appears during the run', () => { + const dir = tempDbDir({ 'iptvnator.db': 'x' }); + + assert.match( + compareDatabaseStates({ exists: false, entries: {} }, snapshotDatabaseState(dir)), + /CREATED/ + ); + }); + + it('does not hash the database, so multi-gigabyte files are fine', () => { + // Regression guard: the previous implementation read the whole file + // into memory and swallowed the resulting failure as "absent", + // silently disabling G1 against a 4 GB production database. + const dir = tempDbDir({ 'iptvnator.db': 'x' }); + const snapshot = snapshotDatabaseState(dir); + + assert.equal(snapshot.exists, true); + assert.ok(!('sha256' in snapshot.entries['iptvnator.db'])); + assert.ok(snapshot.entries['iptvnator.db'].ino > 0); + }); +}); + +describe('publishDirectory', () => { + function staged(files) { + const dir = mkdtempSync(path.join(tmpdir(), 'guard-stage-')); + tempDirs.push(dir); + + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + + return dir; + } + + function outputPath() { + const parent = mkdtempSync(path.join(tmpdir(), 'guard-out-')); + tempDirs.push(parent); + + return path.join(parent, 'screenshots'); + } + + it('publishes into a directory that does not exist yet', () => { + const out = outputPath(); + const count = publishDirectory( + staged({ 'a-dark.png': 'A', 'a-light.png': 'B' }), + out, + 'test' + ); + + assert.equal(count, 2); + assert.deepEqual(readdirSync(out).sort(), ['a-dark.png', 'a-light.png']); + }); + + it('replaces a previous set wholesale, leaving no stale files', () => { + const out = outputPath(); + publishDirectory(staged({ 'old.png': 'OLD', 'a.png': 'v1' }), out, 't1'); + publishDirectory(staged({ 'a.png': 'v2' }), out, 't2'); + + assert.deepEqual(readdirSync(out), ['a.png']); + assert.equal(readFileSync(path.join(out, 'a.png'), 'utf8'), 'v2'); + }); + + it('merge mode overlays a partial set without deleting the rest', () => { + // The `--only` / `--theme` path: refreshing one shot must not wipe + // the other screenshots of the release. + const out = outputPath(); + publishDirectory( + staged({ 'a-dark.png': 'A1', 'b-dark.png': 'B1' }), + out, + 't1' + ); + + const count = publishDirectory(staged({ 'a-dark.png': 'A2' }), out, 't2', { + mode: 'merge', + }); + + assert.equal(count, 1); + assert.deepEqual(readdirSync(out).sort(), ['a-dark.png', 'b-dark.png']); + assert.equal(readFileSync(path.join(out, 'a-dark.png'), 'utf8'), 'A2'); + assert.equal(readFileSync(path.join(out, 'b-dark.png'), 'utf8'), 'B1'); + }); + + it('merge mode works when nothing was published before', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'A' }), out, 't', { mode: 'merge' }); + + assert.deepEqual(readdirSync(out), ['a.png']); + }); + + it('leaves no scratch directories behind', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'A' }), out, 'tok'); + + assert.deepEqual(readdirSync(path.dirname(out)), ['screenshots']); + }); + + it('rolls the previous set back when the swap fails', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'original' }), out, 't1'); + + let calls = 0; + const rename = (from, to) => { + calls += 1; + // First call retires the previous set; fail the swap that follows. + if (calls === 2) { + throw new Error('simulated EXDEV'); + } + renameSync(from, to); + }; + + assert.throws( + () => + publishDirectory(staged({ 'a.png': 'new' }), out, 'boom', { + rename, + }), + /simulated EXDEV/ + ); + + assert.equal(calls, 3, 'the rollback rename must have run'); + assert.equal(readFileSync(path.join(out, 'a.png'), 'utf8'), 'original'); + assert.deepEqual(readdirSync(path.dirname(out)), ['screenshots']); + }); +}); + +describe('external request verdict', () => { + it('ignores local and stubbed URLs, reports the rest once', () => { + const violations = externalRequestViolations([ + 'http://localhost:3211/player_api.php', + 'file:///dist/apps/web/index.html', + 'https://api.github.com/repos/4gray/iptvnator/releases', + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]); + + assert.deepEqual(violations, [ + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]); + }); + + it('returns nothing for a fully local run', () => { + assert.deepEqual( + externalRequestViolations([ + 'http://127.0.0.1:3211/assets/logo.svg', + 'data:image/png;base64,AAA', + ]), + [] + ); + }); +}); diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json new file mode 100644 index 000000000..31cef378c --- /dev/null +++ b/tools/release/screenshots.manifest.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "viewport": { "width": 1280, "height": 720 }, + "themes": ["dark", "light"], + "shots": [ + { + "slug": "dashboard", + "title": "Dashboard", + "setup": ["open-dashboard"] + }, + { + "slug": "settings", + "title": "Settings", + "setup": ["open-settings"] + }, + { + "slug": "xtream-vod-details", + "title": "Movie details", + "setup": ["open-xtream-vod=Action & Mystery"] + }, + { + "slug": "xtream-series-season-open", + "title": "Series season view", + "setup": ["open-xtream-series=Urban Drama"] + }, + { + "slug": "m3u-live-groups-two-column", + "title": "Live TV groups", + "setup": ["open-m3u-groups"] + } + ] +} From d76b2d2a570bf60f044dafc439e67adcc84f3ec4 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:34:17 +0200 Subject: [PATCH 02/12] fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment (#1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment Providers ship dead and stale tmdb_id values, and enrich() trusted them unconditionally: parseProviderTmdbId(query.tmdbId) ?? await resolveIdBySearch(...) A garbage-but-integer id short-circuited the title search entirely. The details fetch then 404'd, the outer catch swallowed it, and the item was left permanently unenriched — no plot, no cast, no artwork — for a title the search would have matched. Failed detail fetches cache nothing, so the wasted request repeated on every re-open. The stale-but-valid case was worse: it never threw, nothing sanity-checked the resolved title, and we confidently rendered another film's metadata. enrich() now treats the provider id as a hint. If it fails to resolve, or resolves to something whose title matches none of the search variants we would have queried, the confidence-gated title search gets its turn — and proven-bad ids are negative-cached (7d, language-independent row) so the 404 is not repeated forever. Deliberately NOT a hard rejection on title mismatch: TMDB returns titles in the REQUEST language, so a Russian provider title legitimately fails the name check against an en-US payload. A mismatch only lets the search compete; when the search finds nothing confident, the provider payload is kept. The change can therefore only add enrichment, never remove it. Extracts the search resolution and the bad-id cache into TmdbIdResolverService — tmdb-enrichment.service.ts was at 290 lines against the 300-line target, and the resolver is independently testable. Tests: new tmdb-enrichment.service.spec.ts covers the happy path issuing exactly one details call and no search, 404 fallback, stale-id override, the keep-the-payload safety property, bad-id skip, and the no-match case; matcher spec covers detailsMatchProviderTitle and the namespaced cache key. Refs docs/architecture/tmdb-roadmap.md A1. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): only blame a provider id when TMDB confirms it does not exist Review found the bad-id negative cache too eager in two ways, both of which could deny enrichment to items whose id was fine. 1. Any failure recorded the verdict. A 401, 429, 5xx or an offline blip would mark a perfectly valid id as dead for seven days, so after the service recovered — or the user fixed their API key — titles that the search cannot resolve confidently stayed unenriched until the marker expired. TmdbApiService now throws a typed TmdbApiError carrying the status, and only a confirmed 404 is recorded. 2. Title mismatches were recorded too. That id EXISTS; it is merely wrong for this item. The row is keyed by id alone and shared across playlists, so a stale mapping on one item disabled the direct lookup for every other item that legitimately used the same id. Mismatches are no longer cached at all — the search verdict is cached anyway, so the repeat cost is a single details fetch. Documents the row kind in the cache contract, which listed only two of the (now six) lookup_key shapes. Tests: 404 records, 429 does not, network error does not, mismatch does not. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): keep provider details when the competing search fails detailsForProviderId only runs the search to see whether it can beat a title-mismatched provider payload. A throw from that best-effort search (offline, rate limit, 5xx) propagated to enrich()'s outer catch and threw away details we already had — the searched-details fetch right below it was already tolerant. Fail to the details in hand instead. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): decide a suspect provider id on evidence, not on the title The title check alone was both too weak and too dangerous. Too weak: normalizeTitle strips trailing years, so "Blade Runner 2049" carrying the 1982 film's id matched and the wrong film was rendered — exactly the stale-id case this was meant to catch. Too dangerous: an ALL-CAPS leading token reads as a language tag, so "IT - Chapter Two" normalizes to "chapter two". The correct payload failed the name check, and a year-less search for "chapter two" would confidently return the 1979 film and overwrite it. Master trusted the provider id here and got it right. assessProviderId weighs both signals: title or year agrees means use the details; both years known and incompatible means the search may take over; a title-only mismatch is inconclusive and keeps the details. The search branch now always has a year, so its own gate corroborates whatever it returns instead of matching on name alone. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): do not search after a transient provider-id failure enrich() reads a null from detailsForProviderId as "the id is unusable, try the search". A 401/429/5xx/offline failure gave it that null, so an outage turned into a second request that would fail too — and if it did come back, a title match replaced a provider id that was probably fine. Only a 404 falls through to the search now; everything else rethrows and leaves the id retryable. Co-Authored-By: Claude Opus 4.8 * docs(tmdb): add the release note for the provider-id fix Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changes/tmdb-broken-provider-id.md | 10 + CLAUDE.md | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 46 ++- libs/services/src/lib/tmdb/index.ts | 1 + .../services/src/lib/tmdb/tmdb-api.service.ts | 23 +- .../lib/tmdb/tmdb-enrichment.service.spec.ts | 294 ++++++++++++++++++ .../src/lib/tmdb/tmdb-enrichment.service.ts | 187 +++++------ .../src/lib/tmdb/tmdb-id-resolver.service.ts | 158 ++++++++++ .../src/lib/tmdb/tmdb-matcher.spec.ts | 57 ++++ libs/services/src/lib/tmdb/tmdb-matcher.ts | 99 ++++++ .../src/lib/tmdb/tmdb-provider-id.spec.ts | 92 ++++++ 11 files changed, 869 insertions(+), 100 deletions(-) create mode 100644 .changes/tmdb-broken-provider-id.md create mode 100644 libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts diff --git a/.changes/tmdb-broken-provider-id.md b/.changes/tmdb-broken-provider-id.md new file mode 100644 index 000000000..cbc5b6cf4 --- /dev/null +++ b/.changes/tmdb-broken-provider-id.md @@ -0,0 +1,10 @@ +--- +type: fix +area: tmdb +--- + +Movies whose provider ships a dead or wrong TMDB id are enriched again. The +id is weighed against the title and release year: a dead one falls back to +the title search, a stale one that clearly points at another film loses to +it, and a working id is no longer thrown away just because the provider +spells the title differently. diff --git a/CLAUDE.md b/CLAUDE.md index ae30b0305..f699a20a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -836,7 +836,7 @@ engine` (restart required) or - 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 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 +- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) - Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - 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`) diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index e78dd4bd9..49a329d9f 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -83,8 +83,29 @@ in the settings section validates the API key against `/configuration`. Wrong metadata is worse than no metadata, so id resolution is conservative: 1. If the provider returns a usable `tmdb_id` (Xtream VOD info often does), - it is trusted fully and no search runs. Series have no show-level - `tmdb_id`, so they always go through search. + its details are fetched directly and normally used as-is. Series have no + show-level `tmdb_id`, so they always go through search. The id is a + strong hint rather than gospel — panels ship dead and stale ones — so + the payload it returns is weighed against the provider item + (`assessProviderId`): + - a matching title **or** a compatible release year (±1; for series, + any earlier premiere) → **corroborated**, use it; + - both years known and incompatible → **contradicted**, the stale-id + signature ("Blade Runner 2049" carrying the 1982 film's id): the + title search may take over, and does so only if it finds a confident + match of its own; + - title differs with no year to arbitrate → **inconclusive**, keep the + details. TMDB returns titles in the request language and + normalization strips stylized prefixes ("IT - Chapter Two" → + "chapter two"), so a name mismatch alone says more about our inputs + than about the id. + + A 404 is the one hard verdict: the id is recorded as dead + (`badProviderId:` row), skipped next time, and the title search + takes over. Transient failures (auth, rate limit, 5xx, offline) neither + disable the id nor trigger a search — that request would hit the same + outage, and a title match that did come back would be weaker evidence + than the id already in hand. 2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized title. Normalization strips bracketed tags, quality markers (`4K`, `1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics, @@ -247,14 +268,18 @@ Filmography has two scopes: ## Cache -Single table with two row kinds discriminated by `lookup_key` prefix: +Single table with several row kinds discriminated by the `lookup_key` +prefix: ``` tmdb_metadata ( media_type 'movie' | 'tv' | 'person', lookup_key 'id:|v2' -- details payload row + 'id:|season:' -- season payload row 'title:|year:|v2' -- search resolution row 'person:' -- person payload row + 'trending:week' -- trending list row + 'badProviderId:' -- id confirmed 404 by TMDB language TEXT, -- TMDB language code tmdb_id INTEGER, -- NULL on a search row = negative cache payload TEXT, -- raw JSON details, NULL for search rows @@ -343,3 +368,18 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache heroes additionally show the tracked "S{n}·E{n}" badge from the playback position (no TMDB involved); the watch-progress bar is limited to movie/series heroes. + +### `badProviderId:` rows + +Providers ship `tmdb_id` values that do not exist. A failed details fetch +caches nothing, so without a marker the same 404 is re-issued on every +detail open, forever. These rows record that verdict: `tmdb_id` NULL, +language `any` (a dead id is dead in every language), read with the +negative-match TTL. + +Only a **confirmed 404** is recorded. Transient failures (401, 429, 5xx, +offline) leave no marker — they say nothing about the id. Neither does a +title mismatch: that id exists and may be correct for a *different* item, +and since the row is keyed by id alone and shared across playlists, +recording per-item mismatches here would deny the direct lookup to every +other item that legitimately uses the same id. diff --git a/libs/services/src/lib/tmdb/index.ts b/libs/services/src/lib/tmdb/index.ts index cd28e0596..0b94b3c1f 100644 --- a/libs/services/src/lib/tmdb/index.ts +++ b/libs/services/src/lib/tmdb/index.ts @@ -2,6 +2,7 @@ export * from './tmdb-api.service'; export * from './tmdb-cache.service'; export * from './tmdb-config'; export * from './tmdb-enrichment.service'; +export * from './tmdb-id-resolver.service'; export * from './tmdb-episode-merge'; export * from './tmdb-matcher'; export * from './tmdb-merge'; diff --git a/libs/services/src/lib/tmdb/tmdb-api.service.ts b/libs/services/src/lib/tmdb/tmdb-api.service.ts index 4d7144591..8aacd83af 100644 --- a/libs/services/src/lib/tmdb/tmdb-api.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-api.service.ts @@ -14,6 +14,25 @@ import { * both the Electron renderer and the PWA can call it without a proxy. * Supports classic v3 keys (query param) and v4 read tokens (Bearer). */ +/** + * A non-2xx TMDB response. Carries the status so callers can tell a + * definitive verdict (404 — this id does not exist) apart from a + * transient one (401/429/5xx, offline), which must stay retryable. + */ +export class TmdbApiError extends Error { + constructor( + readonly status: number, + statusText = '' + ) { + super(`TMDB request failed: ${status} ${statusText}`.trim()); + this.name = 'TmdbApiError'; + } +} + +export function isTmdbNotFound(error: unknown): boolean { + return error instanceof TmdbApiError && error.status === 404; +} + @Injectable({ providedIn: 'root' }) export class TmdbApiService { async searchMovie( @@ -155,9 +174,7 @@ export class TmdbApiService { }); if (!response.ok) { - throw new Error( - `TMDB request failed: ${response.status} ${response.statusText}` - ); + throw new TmdbApiError(response.status, response.statusText); } return (await response.json()) as T; diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts new file mode 100644 index 000000000..6d5ecae41 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts @@ -0,0 +1,294 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { TmdbApiError, TmdbApiService } from './tmdb-api.service'; +import { TmdbCacheService } from './tmdb-cache.service'; +import { TmdbEnrichmentService } from './tmdb-enrichment.service'; +import { TmdbIdResolverService } from './tmdb-id-resolver.service'; +import { TmdbPersonService } from './tmdb-person.service'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbSeasonService } from './tmdb-season.service'; +import { TmdbTrendingService } from './tmdb-trending.service'; +import { TmdbMovieDetails } from './tmdb.types'; + +/** + * Regression coverage for provider-supplied `tmdb_id` handling. Panels + * ship dead and stale ids; before this, a dead one short-circuited the + * title search (leaving the item permanently unenriched) and a stale one + * silently rendered another film's metadata. + */ +describe('TmdbEnrichmentService — provider tmdb_id handling', () => { + const matrix: TmdbMovieDetails = { + id: 603, + title: 'The Matrix', + original_title: 'The Matrix', + overview: 'Plot', + videos: { results: [{ key: 'k', site: 'YouTube', type: 'Trailer' }] }, + }; + const unrelated: TmdbMovieDetails = { + id: 999, + title: 'Completely Different Film', + original_title: 'Completely Different Film', + release_date: '1979-12-14', + overview: 'Other plot', + videos: { results: [{ key: 'x', site: 'YouTube', type: 'Trailer' }] }, + }; + + let getMovieDetails: jest.Mock; + let resolveBySearch: jest.Mock; + let isKnownBadProviderId: jest.Mock; + let rememberBadProviderId: jest.Mock; + + // The services Jest target has no @angular/core/testing — build the + // service in a plain injection context instead of TestBed. + function createService(): TmdbEnrichmentService { + const injector = Injector.create({ + providers: [ + { + provide: TmdbRuntimeService, + useValue: { + isEnabled: () => true, + apiKey: () => 'key', + language: () => 'en-US', + appLanguage: () => 'en', + }, + }, + { + provide: TmdbApiService, + useValue: { getMovieDetails, getTvDetails: jest.fn() }, + }, + { + provide: TmdbCacheService, + useValue: { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + isFresh: () => false, + }, + }, + { + provide: TmdbIdResolverService, + useValue: { + resolveBySearch, + isKnownBadProviderId, + rememberBadProviderId, + }, + }, + { provide: TmdbPersonService, useValue: {} }, + { provide: TmdbSeasonService, useValue: {} }, + { provide: TmdbTrendingService, useValue: {} }, + ], + }); + return runInInjectionContext( + injector, + () => new TmdbEnrichmentService() + ); + } + + beforeEach(() => { + getMovieDetails = jest.fn().mockResolvedValue(matrix); + resolveBySearch = jest.fn().mockResolvedValue(null); + isKnownBadProviderId = jest.fn().mockResolvedValue(false); + rememberBadProviderId = jest.fn().mockResolvedValue(undefined); + }); + + it('uses a valid provider id without searching at all', async () => { + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 603, + title: 'The Matrix', + }); + + expect(details?.id).toBe(603); + expect(getMovieDetails).toHaveBeenCalledTimes(1); + expect(resolveBySearch).not.toHaveBeenCalled(); + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('falls back to the title search when the provider id 404s', async () => { + getMovieDetails + .mockRejectedValueOnce(new TmdbApiError(404, 'Not Found')) + .mockResolvedValueOnce(matrix); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 123456789, + title: 'The Matrix', + }); + + // Previously this returned null: the dead id skipped the search + expect(details?.id).toBe(603); + expect(resolveBySearch).toHaveBeenCalledTimes(1); + expect(rememberBadProviderId).toHaveBeenCalledWith( + 'movie', + 123456789 + ); + }); + + it('prefers a confident search match over a stale provider id', async () => { + // The id resolves, but to a 1979 film while our item is from 1999 + getMovieDetails + .mockResolvedValueOnce(unrelated) + .mockResolvedValueOnce(matrix); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 999, + title: 'The Matrix', + year: 1999, + }); + + expect(details?.id).toBe(603); + // The id EXISTS — it is just wrong for this item. The bad-id row is + // keyed by id alone and shared across playlists, so recording a + // per-item mismatch would deny the direct lookup to every other + // item that legitimately uses the same id. + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('does not blame the id for a transient failure', async () => { + // Rate limit, bad key, 5xx or offline: the id may be perfectly + // fine, so it must stay retryable rather than be disabled for days + getMovieDetails.mockRejectedValue(new TmdbApiError(429, 'Too Many')); + resolveBySearch.mockResolvedValue(null); + const service = createService(); + + await service.enrichMovie({ tmdbId: 603, title: 'The Matrix' }); + + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('does not search after a transient provider-id failure', async () => { + // The search would hit the same outage, and a title match that + // does come back is weaker evidence than the id we already have + getMovieDetails.mockRejectedValue(new TmdbApiError(503, 'Down')); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 603, + title: 'The Matrix', + }); + + expect(details).toBeNull(); + expect(resolveBySearch).not.toHaveBeenCalled(); + }); + + it('does not blame the id for a network error', async () => { + getMovieDetails.mockRejectedValue(new Error('offline')); + resolveBySearch.mockResolvedValue(null); + const service = createService(); + + await service.enrichMovie({ tmdbId: 603, title: 'The Matrix' }); + + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('keeps the provider payload when the title differs but the search finds nothing', async () => { + // A localized provider title legitimately fails the name check — + // TMDB returns titles in the REQUEST language. Never trade real + // metadata for none on suspicion alone. + getMovieDetails.mockResolvedValue(unrelated); + resolveBySearch.mockResolvedValue(null); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 999, + title: 'Ирония судьбы', + year: 1979, + }); + + expect(details?.id).toBe(999); + expect(rememberBadProviderId).not.toHaveBeenCalled(); + }); + + it('never lets a name mismatch alone unseat a working provider id', async () => { + // "IT - Chapter Two" normalizes to "chapter two" (the ALL-CAPS "IT" + // reads as a language tag), so the correct payload fails the name + // check. With no year to arbitrate, a search for "chapter two" + // would confidently return the 1979 film of that name. + getMovieDetails.mockResolvedValue({ + id: 474350, + title: 'It Chapter Two', + original_title: 'It Chapter Two', + overview: 'Plot', + }); + resolveBySearch.mockResolvedValue(30619); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 474350, + title: 'IT - Chapter Two', + }); + + expect(details?.id).toBe(474350); + expect(resolveBySearch).not.toHaveBeenCalled(); + }); + + it('catches a stale id that shares the title but not the year', async () => { + // The scraper case: "Blade Runner 2049" carrying the 1982 film's id. + // Both titles normalize to "blade runner", so only the year sees it. + getMovieDetails + .mockResolvedValueOnce({ + id: 78, + title: 'Blade Runner', + release_date: '1982-06-25', + }) + .mockResolvedValueOnce({ + id: 335984, + title: 'Blade Runner 2049', + release_date: '2017-10-04', + }); + resolveBySearch.mockResolvedValue(335984); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 78, + title: 'Blade Runner 2049', + year: 2017, + }); + + expect(details?.id).toBe(335984); + }); + + it('keeps the provider payload when the competing search throws', async () => { + // Same reasoning as above, but the search never returns a verdict: + // TMDB is offline or rate-limiting. Details in hand beat nothing. + getMovieDetails.mockResolvedValue(unrelated); + resolveBySearch.mockRejectedValue(new TmdbApiError(503, 'Down')); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 999, + title: 'Ирония судьбы', + year: 1999, + }); + + expect(details?.id).toBe(999); + }); + + it('skips a provider id already known to be bad', async () => { + isKnownBadProviderId.mockResolvedValue(true); + resolveBySearch.mockResolvedValue(603); + const service = createService(); + + const details = await service.enrichMovie({ + tmdbId: 123456789, + title: 'The Matrix', + }); + + expect(details?.id).toBe(603); + // One details call for the searched id — none for the dead one + expect(getMovieDetails).toHaveBeenCalledTimes(1); + expect(getMovieDetails).toHaveBeenCalledWith(603, 'en-US', 'key'); + }); + + it('returns null when there is no provider id and no search match', async () => { + const service = createService(); + + await expect( + service.enrichMovie({ title: 'Unknown Thing' }) + ).resolves.toBeNull(); + expect(getMovieDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index bfed5f41b..882f8a8b3 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -1,21 +1,14 @@ import { Injectable, inject } from '@angular/core'; import { TmdbMediaType } from '@iptvnator/shared/interfaces'; -import { TmdbApiService } from './tmdb-api.service'; +import { TmdbApiService, isTmdbNotFound } from './tmdb-api.service'; import { TmdbCacheService } from './tmdb-cache.service'; +import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config'; import { - TMDB_DETAILS_CACHE_TTL_MS, - TMDB_MATCH_CACHE_TTL_MS, - TMDB_NEGATIVE_MATCH_CACHE_TTL_MS, - tmdbSearchLanguageForTitle, -} from './tmdb-config'; -import { + assessProviderId, buildDetailsLookupKey, - buildSearchLookupKey, - buildSearchTitleVariants, - extractYear, parseProviderTmdbId, - pickConfidentMatch, } from './tmdb-matcher'; +import { TmdbIdResolverService } from './tmdb-id-resolver.service'; import { detailsFallbackLanguage, fillDetailsFromFallback, @@ -53,6 +46,7 @@ export class TmdbEnrichmentService { private readonly person = inject(TmdbPersonService); private readonly season = inject(TmdbSeasonService); private readonly trending = inject(TmdbTrendingService); + private readonly idResolver = inject(TmdbIdResolverService); isEnabled(): boolean { return this.runtime.isEnabled(); @@ -107,15 +101,31 @@ export class TmdbEnrichmentService { } try { - const tmdbId = - parseProviderTmdbId(query.tmdbId) ?? - (await this.resolveIdBySearch(mediaType, query)); - - if (tmdbId === null) { - return null; + const providerId = parseProviderTmdbId(query.tmdbId); + if ( + providerId !== null && + !(await this.idResolver.isKnownBadProviderId( + mediaType, + providerId + )) + ) { + const details = await this.detailsForProviderId( + mediaType, + providerId, + query + ); + if (details) { + return details; + } } - return await this.getDetails(mediaType, tmdbId); + const searchedId = await this.idResolver.resolveBySearch( + mediaType, + query + ); + return searchedId === null + ? null + : await this.getDetails(mediaType, searchedId); } catch (error) { console.warn(`TMDB ${mediaType} enrichment failed:`, error); return null; @@ -123,88 +133,79 @@ export class TmdbEnrichmentService { } /** - * Resolve a title/year to a TMDB id via /search with the confidence - * gate. Both hits and misses are cached; misses use a shorter TTL. + * Details for a provider-supplied `tmdb_id`, or `null` when the id is + * unusable and the caller should fall back to the title search. + * + * Providers ship broken ids. A dead one used to short-circuit the + * search and leave the item permanently unenriched; a stale-but-valid + * one silently rendered another title's plot and cast. Both cases now + * defer to the (confidence-gated) search — but only on evidence the id + * is wrong, and only when the search actually produces something, so a + * localized or stylized title never costs the user their metadata. */ - private async resolveIdBySearch( + private async detailsForProviderId( mediaType: TmdbMediaType, + providerId: number, query: TmdbEnrichmentQuery - ): Promise { - // Try the original title, the display title, then language-prefix- - // stripped fallbacks; the first confident match wins. - const variants = buildSearchTitleVariants( - query.title, - query.originalTitle - ); - if (variants.length === 0) { - return null; - } - - const year = query.year ?? extractYear(null, query.title); - const cacheLanguage = tmdbSearchLanguageForTitle( - variants[0], - this.runtime.appLanguage() - ); - const lookupKey = buildSearchLookupKey(variants[0], year); - - const cached = await this.cache.get( - mediaType, - lookupKey, - cacheLanguage - ); - const ttl = - cached?.tmdbId !== null && cached?.tmdbId !== undefined - ? TMDB_MATCH_CACHE_TTL_MS - : TMDB_NEGATIVE_MATCH_CACHE_TTL_MS; - if (this.cache.isFresh(cached, ttl)) { - return cached?.tmdbId ?? null; - } - - let match = null; - for (const variant of variants) { - // Cyrillic (and other non-app-script) titles search in their - // own language so TMDB returns comparable titles — see - // tmdbSearchLanguageForTitle. Search by title only: TMDB's - // year params filter strictly; the ±1/season tolerance lives - // in pickConfidentMatch instead. - const language = tmdbSearchLanguageForTitle( - variant, - this.runtime.appLanguage() - ); - const results = - mediaType === 'movie' - ? await this.api.searchMovie( - variant, - null, - language, - this.runtime.apiKey() - ) - : await this.api.searchTv( - variant, - null, - language, - this.runtime.apiKey() - ); - - match = pickConfidentMatch( - results, - { title: variant, year }, - mediaType - ); - if (match) { - break; + ): Promise { + let details: TmdbDetails | null; + try { + details = await this.getDetails(mediaType, providerId); + } catch (error) { + // Only a 404 proves the id is dead. Auth, rate-limit, 5xx and + // offline failures are transient — remembering those would + // disable a perfectly good id until the marker expires. + if (isTmdbNotFound(error)) { + console.warn( + `TMDB ${mediaType} provider id ${providerId} does not exist, falling back to search:`, + error + ); + await this.idResolver.rememberBadProviderId( + mediaType, + providerId + ); + return null; } + + // Rethrow instead: `enrich` reads a null here as "try the + // search", and searching during an outage or a rate limit just + // adds a request that will fail too — or, worse, succeeds and + // swaps a strong id for a title match. The id stays good for + // the next attempt. + throw error; } - await this.cache.set({ - mediaType, - lookupKey, - language: cacheLanguage, - tmdbId: match?.id ?? null, - payload: null, - }); + if (!details || assessProviderId(details, query, mediaType) !== 'contradicted') { + return details; + } - return match?.id ?? null; + // The id resolves to a title whose release year contradicts the + // provider's — the stale-id signature. Let the search try to beat + // it; keep these details if it cannot. + // + // Deliberately NOT remembered: the id exists and may be correct for + // a DIFFERENT item. The bad-id cache is keyed by id alone and shared + // across playlists, so recording a per-item mismatch there would + // deny the direct lookup to every other item that legitimately uses + // the same id. The search verdict is cached anyway, so the repeat + // cost is one details fetch. + // + // The search is best-effort here: offline, rate-limited or 5xx must + // fall back to the details we already have, not discard them. Its + // own gate needs a year, which this branch guarantees we have, so a + // result that comes back is corroborated rather than merely named + // alike. + const searchedId = await this.idResolver + .resolveBySearch(mediaType, query) + .catch(() => null); + if (searchedId === null || searchedId === providerId) { + return details; + } + + const searched = await this.getDetails(mediaType, searchedId).catch( + () => null + ); + return searched ?? details; } private async getDetails( diff --git a/libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts b/libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts new file mode 100644 index 000000000..d8e90051a --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-id-resolver.service.ts @@ -0,0 +1,158 @@ +import { Injectable, inject } from '@angular/core'; +import { TmdbMediaType } from '@iptvnator/shared/interfaces'; +import { TmdbApiService } from './tmdb-api.service'; +import { TmdbCacheService } from './tmdb-cache.service'; +import { + TMDB_MATCH_CACHE_TTL_MS, + TMDB_NEGATIVE_MATCH_CACHE_TTL_MS, + tmdbSearchLanguageForTitle, +} from './tmdb-config'; +import { + buildBadProviderIdLookupKey, + buildSearchLookupKey, + buildSearchTitleVariants, + extractYear, + pickConfidentMatch, +} from './tmdb-matcher'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbEnrichmentQuery } from './tmdb.types'; + +/** + * "This id is wrong" verdicts are about the provider's data, not about a + * translation, so they are cached under one language-independent row. + */ +const BAD_ID_CACHE_LANGUAGE = 'any'; + +/** + * Resolves a provider item to a TMDB id: the confidence-gated title search + * plus the negative cache for provider-supplied ids we have proven wrong. + * Extracted from the enrichment orchestrator so both concerns stay + * testable and the facade keeps its size in check. + */ +@Injectable({ providedIn: 'root' }) +export class TmdbIdResolverService { + private readonly runtime = inject(TmdbRuntimeService); + private readonly api = inject(TmdbApiService); + private readonly cache = inject(TmdbCacheService); + + /** + * Resolve a title/year to a TMDB id via /search with the confidence + * gate. Both hits and misses are cached; misses use a shorter TTL. + */ + async resolveBySearch( + mediaType: TmdbMediaType, + query: TmdbEnrichmentQuery + ): Promise { + // Try the original title, the display title, then language-prefix- + // stripped fallbacks; the first confident match wins. + const variants = buildSearchTitleVariants( + query.title, + query.originalTitle + ); + if (variants.length === 0) { + return null; + } + + const year = query.year ?? extractYear(null, query.title); + const cacheLanguage = tmdbSearchLanguageForTitle( + variants[0], + this.runtime.appLanguage() + ); + const lookupKey = buildSearchLookupKey(variants[0], year); + + const cached = await this.cache.get( + mediaType, + lookupKey, + cacheLanguage + ); + const ttl = + cached?.tmdbId !== null && cached?.tmdbId !== undefined + ? TMDB_MATCH_CACHE_TTL_MS + : TMDB_NEGATIVE_MATCH_CACHE_TTL_MS; + if (this.cache.isFresh(cached, ttl)) { + return cached?.tmdbId ?? null; + } + + let match = null; + for (const variant of variants) { + // Cyrillic (and other non-app-script) titles search in their + // own language so TMDB returns comparable titles — see + // tmdbSearchLanguageForTitle. Search by title only: TMDB's + // year params filter strictly; the ±1/season tolerance lives + // in pickConfidentMatch instead. + const language = tmdbSearchLanguageForTitle( + variant, + this.runtime.appLanguage() + ); + const results = + mediaType === 'movie' + ? await this.api.searchMovie( + variant, + null, + language, + this.runtime.apiKey() + ) + : await this.api.searchTv( + variant, + null, + language, + this.runtime.apiKey() + ); + + match = pickConfidentMatch( + results, + { title: variant, year }, + mediaType + ); + if (match) { + break; + } + } + + await this.cache.set({ + mediaType, + lookupKey, + language: cacheLanguage, + tmdbId: match?.id ?? null, + payload: null, + }); + + return match?.id ?? null; + } + + /** + * True when this id is known NOT TO EXIST on TMDB. Without this a dead + * id costs one wasted 404 on every single detail open, forever — + * failed detail fetches cache nothing. + * + * The verdict is deliberately about the ID, not about the item that + * supplied it: the row is keyed by id alone and shared across + * playlists, so only "TMDB returned 404 for this id" may be recorded + * here. A per-item mismatch is never cached — see + * `TmdbEnrichmentService.detailsForProviderId`. + */ + async isKnownBadProviderId( + mediaType: TmdbMediaType, + tmdbId: number + ): Promise { + const cached = await this.cache.get( + mediaType, + buildBadProviderIdLookupKey(tmdbId), + BAD_ID_CACHE_LANGUAGE + ); + return this.cache.isFresh(cached, TMDB_NEGATIVE_MATCH_CACHE_TTL_MS); + } + + async rememberBadProviderId( + mediaType: TmdbMediaType, + tmdbId: number + ): Promise { + await this.cache.set({ + mediaType, + lookupKey: buildBadProviderIdLookupKey(tmdbId), + language: BAD_ID_CACHE_LANGUAGE, + tmdbId: null, + payload: null, + }); + } +} diff --git a/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts b/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts index f61f7ce86..7a3b737a1 100644 --- a/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts @@ -1,6 +1,8 @@ import { + buildBadProviderIdLookupKey, buildDetailsLookupKey, buildSearchLookupKey, + detailsMatchProviderTitle, buildSearchTitleVariants, extractYear, normalizeTitle, @@ -295,3 +297,58 @@ describe('pickConfidentMatch', () => { ).toBeNull(); }); }); + +describe('buildBadProviderIdLookupKey', () => { + it('namespaces the id so it cannot collide with a details row', () => { + expect(buildBadProviderIdLookupKey(603)).toBe('badProviderId:603'); + expect(buildBadProviderIdLookupKey(603)).not.toBe( + buildDetailsLookupKey(603) + ); + }); +}); + +describe('detailsMatchProviderTitle', () => { + const details = { + title: 'The Matrix', + original_title: 'The Matrix', + }; + + it('accepts a match on a noisy provider title', () => { + expect( + detailsMatchProviderTitle(details, { + title: 'EN - The Matrix (1999) 1080p', + }) + ).toBe(true); + }); + + it('accepts a match against the original title only', () => { + expect( + detailsMatchProviderTitle( + { title: 'Die Hard', original_title: 'Stirb langsam' }, + { title: 'Stirb langsam' } + ) + ).toBe(true); + }); + + it('matches series payloads through name/original_name', () => { + expect( + detailsMatchProviderTitle( + { name: 'The Boys', original_name: 'The Boys' }, + { title: 'The Boys s05' } + ) + ).toBe(true); + }); + + it('reports a mismatch for an unrelated title', () => { + expect( + detailsMatchProviderTitle(details, { title: 'Blade Runner' }) + ).toBe(false); + }); + + it('never reports a mismatch when there is nothing to compare', () => { + expect(detailsMatchProviderTitle(details, { title: '' })).toBe(true); + expect(detailsMatchProviderTitle({}, { title: 'The Matrix' })).toBe( + false + ); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-matcher.ts b/libs/services/src/lib/tmdb/tmdb-matcher.ts index 2c60cca56..390e373a9 100644 --- a/libs/services/src/lib/tmdb/tmdb-matcher.ts +++ b/libs/services/src/lib/tmdb/tmdb-matcher.ts @@ -82,6 +82,11 @@ export function buildDetailsLookupKey(tmdbId: number): string { return `id:${tmdbId}|v2`; } +/** Negative-cache key for a provider tmdb_id we have proven wrong */ +export function buildBadProviderIdLookupKey(tmdbId: number): string { + return `badProviderId:${tmdbId}`; +} + /** Provider tmdb_id fields arrive as number, numeric string, or garbage */ export function parseProviderTmdbId( tmdbId: number | string | null | undefined @@ -90,6 +95,100 @@ export function parseProviderTmdbId( return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } +/** + * What the payload behind a provider `tmdb_id` says about that id. + * + * - `corroborated`: title or release year agrees; use the details. + * - `contradicted`: both years are known and they disagree — the classic + * stale id ("Blade Runner 2049" carrying the 1982 film's id). Only this + * verdict is strong enough to let the title search take over. + * - `inconclusive`: the title differs and there is no year to arbitrate + * with. Keep the details: TMDB returns titles in the REQUEST language, + * and normalization strips stylized prefixes ("IT - Chapter Two"), so a + * name mismatch alone says more about our own inputs than about the id. + */ +export type ProviderIdVerdict = + | 'corroborated' + | 'contradicted' + | 'inconclusive'; + +/** The same tolerance the search gate uses, applied in reverse */ +function yearsAgree( + providerYear: number, + detailsYear: number, + mediaType: TmdbMediaType +): boolean { + if (Math.abs(detailsYear - providerYear) <= 1) { + return true; + } + // Series: portals report the current season's year while TMDB reports + // the premiere, so a show that started earlier still agrees. + return mediaType === 'tv' && detailsYear < providerYear; +} + +export function assessProviderId( + details: { + title?: string; + original_title?: string; + release_date?: string; + name?: string; + original_name?: string; + first_air_date?: string; + }, + query: { title?: string | null; originalTitle?: string | null; year?: number | null }, + mediaType: TmdbMediaType +): ProviderIdVerdict { + // Same effective year the search would use, so the two agree on what + // "the provider's year" means + const providerYear = query.year ?? extractYear(null, query.title); + const detailsYear = extractYear( + mediaType === 'movie' ? details.release_date : details.first_air_date + ); + + if (providerYear !== null && detailsYear !== null) { + return yearsAgree(providerYear, detailsYear, mediaType) + ? 'corroborated' + : 'contradicted'; + } + + return detailsMatchProviderTitle(details, query) + ? 'corroborated' + : 'inconclusive'; +} + +/** + * Does a details payload plausibly describe the item we asked about? + * Title-only signal — see {@link assessProviderId} for the verdict callers + * should act on. + */ +export function detailsMatchProviderTitle( + details: { + title?: string; + original_title?: string; + name?: string; + original_name?: string; + }, + query: { title?: string | null; originalTitle?: string | null } +): boolean { + const variants = new Set( + buildSearchTitleVariants(query.title, query.originalTitle) + ); + if (variants.size === 0) { + // Nothing to compare against — never call that a mismatch + return true; + } + + return [ + details.title, + details.original_title, + details.name, + details.original_name, + ].some((title) => { + const normalized = normalizeTitle(title); + return normalized !== '' && variants.has(normalized); + }); +} + function resultTitles(result: TmdbSearchResult, mediaType: TmdbMediaType) { return mediaType === 'movie' ? [result.title, result.original_title] diff --git a/libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts b/libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts new file mode 100644 index 000000000..99c199191 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-provider-id.spec.ts @@ -0,0 +1,92 @@ +import { assessProviderId } from './tmdb-matcher'; + +describe('assessProviderId', () => { + it('corroborates on a matching year even when the title reads wrong', () => { + // "IT - Chapter Two" normalizes to "chapter two" because the + // ALL-CAPS "IT" looks like a language tag + expect( + assessProviderId( + { + title: 'It Chapter Two', + release_date: '2019-09-04', + }, + { title: 'IT - Chapter Two', year: 2019 }, + 'movie' + ) + ).toBe('corroborated'); + }); + + it('is inconclusive when the title differs and no year can arbitrate', () => { + // Never 'contradicted': normalization artifacts and localized + // titles both land here, and the id is usually fine + expect( + assessProviderId( + { title: 'It Chapter Two' }, + { title: 'IT - Chapter Two' }, + 'movie' + ) + ).toBe('inconclusive'); + }); + + it('contradicts a same-title id from another year', () => { + // Both normalize to "blade runner" — only the year sees the swap + expect( + assessProviderId( + { title: 'Blade Runner', release_date: '1982-06-25' }, + { title: 'Blade Runner 2049', year: 2017 }, + 'movie' + ) + ).toBe('contradicted'); + }); + + it('reads the year out of the provider title when there is no field', () => { + expect( + assessProviderId( + { title: 'The Lion King', release_date: '1994-06-24' }, + { title: 'The Lion King 2019' }, + 'movie' + ) + ).toBe('contradicted'); + }); + + it('allows a one-year drift between provider and TMDB dates', () => { + expect( + assessProviderId( + { title: 'Some Film', release_date: '2018-12-28' }, + { title: 'Totally Other Name', year: 2019 }, + 'movie' + ) + ).toBe('corroborated'); + }); + + it('accepts a series that premiered before the reported season year', () => { + // Portals report the current season's year, TMDB the premiere + expect( + assessProviderId( + { name: 'The Boys', first_air_date: '2019-07-25' }, + { title: 'The Boys', year: 2026 }, + 'tv' + ) + ).toBe('corroborated'); + }); + + it('contradicts a series that started after the reported year', () => { + expect( + assessProviderId( + { name: 'Something Else', first_air_date: '2024-01-01' }, + { title: 'The Boys', year: 2019 }, + 'tv' + ) + ).toBe('contradicted'); + }); + + it('corroborates on the title when neither side has a year', () => { + expect( + assessProviderId( + { title: 'The Matrix' }, + { title: 'The Matrix' }, + 'movie' + ) + ).toBe('corroborated'); + }); +}); From 0b967d66d48d43e7b714c15fbf2f243ea0f8eb04 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:13:02 +0200 Subject: [PATCH 03/12] feat(tmdb): metadata cache panel with a clear button in settings (#1244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tmdb): metadata cache panel with a clear button in settings Adds "Metadata cache — N entries · X MB" with a Clear button to Settings > Metadata (TMDB), next to the API key it belongs to. Three things it is good for: dropping stale or wrong metadata so the next open refetches it, seeing what the cache actually costs on disk, and reclaiming rows that a lookup-key version bump has orphaned — a bump makes rows unreachable, not deleted, so nothing else would ever collect them. Sizing the cache is a full table scan (LENGTH() on TEXT counts characters, so the SUM casts to BLOB to get bytes), which is why stats load lazily and only once the TMDB section is the active one rather than on every settings open. Clearing is always safe: enrichment refetches on demand, so the only cost is the next few requests. Works in both environments — the PWA has no bridge, so the service reports and clears its session-scoped in-memory map instead. i18n: 4 keys across all 19 locales via the tools/i18n workflow; placeholder integrity verified. Contract fixtures updated for both the preload bridge and the DB-worker payload shapes. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): make cache clearing durable and stop reporting failures as empty Four review findings, all real: - A metadata write already in flight when the user cleared would land afterwards and silently restore what they removed. Writes now carry the generation they started in; a write that outlives a clear is dropped (PWA) or undone (Electron). - The PWA byte count used String.length, i.e. UTF-16 code units, so localized payloads under-reported and disagreed with the SQLite BLOB byte count. TextEncoder now measures actual bytes. - A failed stats read returned a valid zero-entry result, so the panel claimed an empty cache and disabled Clear while rows were still there. getStats/clear now return null on failure and the panel says so instead of inventing state. - No behavioural coverage existed for either side. Tests: SQL ops (entry/byte reporting, empty table, missing row, delete count) and the service (encoded bytes, clear count, and a write racing a clear). Co-Authored-By: Claude Opus 4.8 * fix(tmdb): make the cache clear precise and version skew visible Review follow-ups on the cache panel: - A write that was in flight when the user cleared used to trigger a second full-table clear once it landed, which also deleted anything written in between. clear() now waits for the writes issued before it and lets the single clear take them; later writes survive. - An Electron shell without the maintenance ops fell through to the renderer map, which is always empty there — it reported an empty cache and disabled the Clear button while SQLite was full. Both operations now report unsupported instead. - Component coverage for the panel (deferred scan, clear + re-read, failed clear, failed read) and Electron-path service coverage. - The canonical IPC and settings sections of the enrichment doc, plus the matching CLAUDE.md lines, now list the maintenance ops. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): drop Promise.allSettled from the cache clear The web target compiles against lib es2018, so allSettled broke the Windows frontend build (TS2550). The pending writes swallow their own errors, so a plain Promise.all over neutralized promises does the job. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): keep a synchronous bridge throw inside the cache write Moving the write into a tracked promise dropped the try/catch that used to cover the call itself, so a bridge that threw synchronously would escape set(). Wrap it in an async IIFE, which turns that back into a rejection the same handler swallows. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): retry the cache size read when the section is reopened The effect skipped the read once cacheError was set, so one transient IPC failure left the panel showing "could not read the cache" for the life of the settings page — and the only enabled control that could shift it was the destructive Clear button. Gate on the stats signal alone: reopening the section retries. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): queue writes that start while the cache is being cleared Awaiting the in-flight writes closed one side of the race and left the other open: a set() that started during that wait dispatched its IPC immediately, was absent from the snapshot, and could reach SQLite just before the delete — so a row written after the user clicked Clear was removed anyway. clear() now holds its own promise for the whole operation and set() waits on it, which puts such a write on the far side of the delete. Rows are stamped when they are dispatched rather than when set() was called, since a write may have waited. Covered by a test that fails without the guard. Co-Authored-By: Claude Opus 4.8 * docs(tmdb): add the release note for the cache panel Co-Authored-By: Claude Opus 4.8 * test(tmdb): cover the cache panel with an Electron E2E The panel drives IPC and SQLite, and nothing exercised that path end to end. The new test seeds a row through the preload bridge — enrichment itself needs a TMDB key that CI does not have — then opens the section, asserts the reported size, clears, and reads the database back to confirm the row is gone rather than merely hidden. Verified both ways: dropping the DELETE from clearTmdbMetadata fails it. Settings nav buttons gained a data-test-id so the section can be opened without matching translated labels. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changes/tmdb-cache-panel.md | 8 + CLAUDE.md | 4 +- apps/electron-backend-e2e/src/settings.e2e.ts | 51 ++++++ .../src/app/api/main.preload.spec-data.ts | 12 ++ .../src/app/api/main.preload.ts | 2 + .../operations/tmdb.operations.spec.ts | 53 ++++++- .../database/operations/tmdb.operations.ts | 42 ++++- .../src/app/events/database/tmdb.events.ts | 4 + .../database/worker-ipc-contract.spec-data.ts | 10 ++ .../src/app/workers/database-worker.types.ts | 2 + .../src/app/workers/database.worker.ts | 8 + .../settings-tmdb-section.component.html | 41 +++++ .../settings-tmdb-section.component.spec.ts | 143 +++++++++++++++++ .../settings-tmdb-section.component.ts | 84 +++++++++- apps/web/src/assets/i18n/ar.json | 5 + apps/web/src/assets/i18n/ary.json | 5 + apps/web/src/assets/i18n/by.json | 5 + apps/web/src/assets/i18n/de.json | 5 + apps/web/src/assets/i18n/el.json | 5 + apps/web/src/assets/i18n/en.json | 5 + apps/web/src/assets/i18n/es.json | 5 + apps/web/src/assets/i18n/fr.json | 5 + apps/web/src/assets/i18n/hu.json | 5 + apps/web/src/assets/i18n/it.json | 5 + apps/web/src/assets/i18n/ja.json | 5 + apps/web/src/assets/i18n/ko.json | 5 + apps/web/src/assets/i18n/nl.json | 5 + apps/web/src/assets/i18n/pl.json | 5 + apps/web/src/assets/i18n/pt.json | 5 + apps/web/src/assets/i18n/ru.json | 5 + apps/web/src/assets/i18n/tr.json | 5 + apps/web/src/assets/i18n/zh.json | 5 + apps/web/src/assets/i18n/zhtw.json | 5 + docs/architecture/tmdb-metadata-enrichment.md | 43 ++++- .../src/lib/tmdb/tmdb-cache.service.spec.ts | 141 ++++++++++++++++ .../src/lib/tmdb/tmdb-cache.service.ts | 150 ++++++++++++++++-- .../src/lib/electron-api.interface.ts | 11 +- .../interfaces/src/lib/tmdb.interface.ts | 7 + ...kspace-settings-context-panel.component.ts | 1 + 39 files changed, 888 insertions(+), 24 deletions(-) create mode 100644 .changes/tmdb-cache-panel.md create mode 100644 apps/web/src/app/settings/settings-tmdb-section.component.spec.ts diff --git a/.changes/tmdb-cache-panel.md b/.changes/tmdb-cache-panel.md new file mode 100644 index 000000000..0156ea055 --- /dev/null +++ b/.changes/tmdb-cache-panel.md @@ -0,0 +1,8 @@ +--- +type: feature +area: tmdb +--- + +Settings → Metadata (TMDB) now shows how many entries the metadata cache +holds and how much space they take, with a button to empty it. Clearing +costs nothing but the next few lookups — enrichment refetches on demand. diff --git a/CLAUDE.md b/CLAUDE.md index f699a20a9..96363b01a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -835,10 +835,10 @@ engine` (restart required) or - 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 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`) +- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) -- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`, plus `DB_GET_TMDB_CACHE_STATS` / `DB_CLEAR_TMDB_METADATA` behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 03ccc34b3..c803ab8ed 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -604,6 +604,57 @@ test.describe('Electron Settings', () => { await closeElectronApp(app); } }); + + test('@settings @electron @persistence sizes and clears the TMDB metadata cache', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + // Seeded through the same preload bridge the enrichment service + // uses, so this exercises real IPC -> DB worker -> SQLite. + // Enrichment itself cannot run here: it needs a TMDB API key, + // which builds outside the release pipeline do not carry. + await app.mainWindow.evaluate(async () => { + await window.electron.dbSetTmdbMetadata({ + mediaType: 'movie', + lookupKey: 'id:603|v2', + language: 'en-US', + tmdbId: 603, + payload: JSON.stringify({ id: 603, title: 'The Matrix' }), + fetchedAt: new Date().toISOString(), + }); + }); + + await openSettings(app.mainWindow); + await app.mainWindow.getByTestId('settings-section-tmdb').click(); + + // Sizing is deferred until this section is the active one + await expect( + app.mainWindow.getByTestId('tmdb-cache-size') + ).toHaveText(/\b1 entries/); + + const clearButton = + app.mainWindow.getByTestId('tmdb-clear-cache'); + await expect(clearButton).toBeEnabled(); + await clearButton.click(); + + // Re-read reports an empty cache, so there is nothing to clear + await expect(clearButton).toBeDisabled(); + await expect( + app.mainWindow.getByTestId('tmdb-cache-size') + ).toHaveText(/\b0 entries/); + + // ...and the row is gone from the database, not just the panel + const remaining = await app.mainWindow.evaluate(() => + window.electron.dbGetTmdbMetadata('movie', 'id:603|v2', 'en-US') + ); + expect(remaining).toBeNull(); + } finally { + await closeElectronApp(app); + } + }); + }); async function selectSettingsOption( diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 3b8b5cfab..effaa9c1d 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -370,6 +370,18 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_SET_TMDB_METADATA', forwardedArgs: [tmdbCacheEntry], }, + { + method: 'dbGetTmdbCacheStats', + args: [], + channel: 'DB_GET_TMDB_CACHE_STATS', + forwardedArgs: [], + }, + { + method: 'dbClearTmdbMetadata', + args: [], + channel: 'DB_CLEAR_TMDB_METADATA', + forwardedArgs: [], + }, { method: 'dbMatchTitles', args: [[['The Matrix', 'Inception']][0]], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 771a48359..bd078b223 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -788,6 +788,8 @@ const electronApi: ElectronBridgeApi = { ), dbSetTmdbMetadata: (entry: TmdbCacheEntry) => ipcRenderer.invoke('DB_SET_TMDB_METADATA', entry), + dbGetTmdbCacheStats: () => ipcRenderer.invoke('DB_GET_TMDB_CACHE_STATS'), + dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'), dbMatchTitles: (titles: string[]) => ipcRenderer.invoke('DB_MATCH_TITLES', titles), // Playback Positions diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts index 8b1e6a3d2..94ded9e3e 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts @@ -1,6 +1,11 @@ import type { TmdbCacheEntry } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; -import { getTmdbMetadata, setTmdbMetadata } from './tmdb.operations'; +import { + clearTmdbMetadata, + getTmdbCacheStats, + getTmdbMetadata, + setTmdbMetadata, +} from './tmdb.operations'; function createSelectMock(rows: unknown[]) { const limit = jest.fn().mockResolvedValue(rows); @@ -105,3 +110,49 @@ describe('tmdb.operations', () => { ); }); }); + +describe('tmdb cache maintenance', () => { + function createStatsDb(rows: unknown[]) { + const all = jest.fn().mockResolvedValue(rows); + const run = jest.fn().mockResolvedValue(undefined); + return { db: { all, run } as unknown as AppDatabase, all, run }; + } + + it('reports entry count and payload bytes', async () => { + const { db } = createStatsDb([{ entries: 42, bytes: 123456 }]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 42, + bytes: 123456, + }); + }); + + it('reports zeroes for an empty table', async () => { + // COALESCE keeps SUM(NULL) from surfacing as NaN + const { db } = createStatsDb([{ entries: 0, bytes: 0 }]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('survives a driver returning no stats row at all', async () => { + const { db } = createStatsDb([]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('deletes every row and reports how many there were', async () => { + const { db, run } = createStatsDb([{ entries: 7, bytes: 900 }]); + + await expect(clearTmdbMetadata(db)).resolves.toEqual({ + success: true, + deleted: 7, + }); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts index 75fc67865..ae0a37c47 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts @@ -1,6 +1,10 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; -import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import type { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; export async function getTmdbMetadata( @@ -67,3 +71,37 @@ export async function setTmdbMetadata( return { success: true }; } + +/** + * Row count and payload size for the settings panel. `LENGTH()` on TEXT + * counts CHARACTERS, so the cast to BLOB is what makes this bytes. + * Negative-match rows have a NULL payload; SUM skips them. + */ +export async function getTmdbCacheStats( + db: AppDatabase +): Promise { + const rows = (await db.all(sql` + SELECT + COUNT(*) AS entries, + COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS bytes + FROM tmdb_metadata + `)) as { entries: number; bytes: number }[]; + + return { + entries: Number(rows[0]?.entries ?? 0), + bytes: Number(rows[0]?.bytes ?? 0), + }; +} + +/** + * Drops the whole cache. Enrichment simply refetches on demand, so this + * is always safe. Also the escape hatch when a lookup-key version bump + * orphans rows: a bump makes them unreachable, not deleted. + */ +export async function clearTmdbMetadata( + db: AppDatabase +): Promise<{ success: boolean; deleted: number }> { + const { entries } = await getTmdbCacheStats(db); + await db.run(sql`DELETE FROM tmdb_metadata`); + return { success: true, deleted: entries }; +} diff --git a/apps/electron-backend/src/app/events/database/tmdb.events.ts b/apps/electron-backend/src/app/events/database/tmdb.events.ts index 19b6b84da..796ab78bc 100644 --- a/apps/electron-backend/src/app/events/database/tmdb.events.ts +++ b/apps/electron-backend/src/app/events/database/tmdb.events.ts @@ -19,4 +19,8 @@ handleWorkerRequest('DB_SET_TMDB_METADATA', (entry: TmdbCacheEntry) => ({ entry, })); +handleWorkerRequest('DB_GET_TMDB_CACHE_STATS', () => ({})); + +handleWorkerRequest('DB_CLEAR_TMDB_METADATA', () => ({})); + handleWorkerRequest('DB_MATCH_TITLES', (titles: string[]) => ({ titles })); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 6fb9e60f8..f03e24b3f 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -323,6 +323,16 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [tmdbCacheEntry], payload: { entry: tmdbCacheEntry }, }, + { + operation: 'DB_GET_TMDB_CACHE_STATS', + args: [], + payload: {}, + }, + { + operation: 'DB_CLEAR_TMDB_METADATA', + args: [], + payload: {}, + }, { operation: 'DB_MATCH_TITLES', args: [['The Matrix', 'Inception']], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 7b1f21df6..15d8783b3 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -51,6 +51,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; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 3cfd67144..e702baa59 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -79,6 +79,8 @@ import { } from '../database/operations/recently-viewed.operations'; import { matchTitles } from '../database/operations/title-match.operations'; import { + clearTmdbMetadata, + getTmdbCacheStats, getTmdbMetadata, setTmdbMetadata, } from '../database/operations/tmdb.operations'; @@ -614,6 +616,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); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index 09e3a73e6..99e5fa6d5 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -99,6 +99,47 @@ } +
+
+

{{ 'SETTINGS.TMDB_CACHE' | translate }}

+

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

+

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

+
+
+ +
+
+
{ + let fixture: ComponentFixture; + let getStats: jest.Mock; + let clear: jest.Mock; + + const createForm = () => + new FormGroup({ + tmdb: new FormGroup({ + enabled: new FormControl(true), + apiKey: new FormControl(''), + }), + }); + + const queryByTestId = (testId: string): HTMLElement | null => + fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`); + + const clearButton = () => + queryByTestId('tmdb-clear-cache') as HTMLButtonElement; + + /** The stats effect only fires once the TMDB section is the active one */ + const activate = async (section = 'tmdb') => { + fixture.componentRef.setInput('activeSection', section); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(async () => { + getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 }); + clear = jest.fn().mockResolvedValue(42); + + await TestBed.configureTestingModule({ + imports: [ + SettingsTmdbSectionComponent, + NoopAnimationsModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: TmdbCacheService, + useValue: { getStats, clear }, + }, + { + provide: TmdbApiService, + useValue: { validateApiKey: jest.fn() }, + }, + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation( + 'en', + { + SETTINGS: { + TMDB_CACHE_SIZE: CACHE_SIZE_LABEL, + TMDB_CACHE_ERROR: CACHE_ERROR_LABEL, + }, + }, + true + ); + translate.use('en'); + + fixture = TestBed.createComponent(SettingsTmdbSectionComponent); + fixture.componentRef.setInput('form', createForm()); + fixture.componentRef.setInput('activeSection', 'general'); + fixture.detectChanges(); + }); + + it('defers the full-table scan until the section is opened', async () => { + expect(getStats).not.toHaveBeenCalled(); + + await activate(); + + expect(getStats).toHaveBeenCalledTimes(1); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); + }); + + it('clears the cache and re-reads the size', async () => { + await activate(); + getStats.mockResolvedValue({ entries: 0, bytes: 0 }); + + clearButton().click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(getStats).toHaveBeenCalledTimes(2); + // Nothing left to clear — the button stops offering it + expect(clearButton().disabled).toBe(true); + }); + + it('surfaces a failed clear instead of claiming an empty cache', async () => { + await activate(); + clear.mockResolvedValue(null); + + clearButton().click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + // Still actionable: the rows are there, the user can retry + expect(clearButton().disabled).toBe(false); + }); + + it('surfaces a failed size read the same way', async () => { + getStats.mockResolvedValue(null); + + await activate(); + + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + }); + + it('retries the size read next time the section is opened', async () => { + getStats.mockResolvedValueOnce(null); + await activate(); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + + await activate('general'); + await activate(); + + // Otherwise a single transient failure sticks for the life of the + // page, and only the destructive Clear button can shift it + expect(getStats).toHaveBeenCalledTimes(2); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); + }); +}); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 593364775..e7a6f7e3c 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -1,4 +1,12 @@ -import { Component, inject, input, signal, ViewEncapsulation } from '@angular/core'; +import { + Component, + effect, + inject, + input, + signal, + untracked, + ViewEncapsulation, +} from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -7,7 +15,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslateModule } from '@ngx-translate/core'; -import { TmdbApiService } from '@iptvnator/services'; +import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; +import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @@ -59,16 +68,46 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; .tmdb-key-test__result--error { color: #f44336; } + + .tmdb-cache__size { + font-variant-numeric: tabular-nums; + } + + .tmdb-cache__error { + color: #f44336; + } `, ], }) export class SettingsTmdbSectionComponent { private readonly tmdbApi = inject(TmdbApiService); + private readonly tmdbCache = inject(TmdbCacheService); readonly form = input.required(); readonly activeSection = input.required(); readonly keyTestState = signal('idle'); + readonly cacheStats = signal(null); + readonly cacheError = signal(false); + readonly isClearing = signal(false); + + constructor() { + // Sizing the cache is a full table scan, so it waits until the + // user is actually looking at this section. + effect(() => { + if (this.activeSection() !== 'tmdb') { + return; + } + untracked(() => { + // A failed read leaves both signals in their "unknown" + // state, so reopening the section retries rather than + // showing the error until the page is rebuilt. + if (this.cacheStats() === null) { + void this.refreshCacheStats(); + } + }); + }); + } get enteredApiKey(): string { return (this.form().value.tmdb?.apiKey ?? '').trim(); @@ -84,4 +123,45 @@ export class SettingsTmdbSectionComponent { const isValid = await this.tmdbApi.validateApiKey(apiKey); this.keyTestState.set(isValid ? 'success' : 'error'); } + + async clearCache(): Promise { + if (this.isClearing()) { + return; + } + this.isClearing.set(true); + try { + const deleted = await this.tmdbCache.clear(); + if (deleted === null) { + // Do not claim an empty cache we failed to empty + this.cacheError.set(true); + this.cacheStats.set(null); + return; + } + await this.refreshCacheStats(); + } finally { + this.isClearing.set(false); + } + } + + /** "12.4 MB" — cache payloads are JSON, so decimal units read right */ + formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; + } + + private async refreshCacheStats(): Promise { + const stats = await this.tmdbCache.getStats(); + this.cacheError.set(stats === null); + this.cacheStats.set(stats); + } + } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b7681d351..9c2ef982f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "التحقق من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح يعمل — استجاب TMDB بنجاح", "TMDB_TEST_FAILED": "فشل التحقق — رفض TMDB المفتاح أو تعذر الوصول إليه", + "TMDB_CACHE": "ذاكرة التخزين المؤقت للبيانات الوصفية", + "TMDB_CACHE_DESCRIPTION": "تُحفظ البيانات الوصفية المنزّلة محلياً لتفتح صفحات التفاصيل فوراً. مسحها يجعل التطبيق يجلب بيانات جديدة عند الزيارة التالية.", + "TMDB_CACHE_SIZE": "{{entries}} عنصر · {{size}}", + "TMDB_CACHE_CLEAR": "مسح الذاكرة المؤقتة", + "TMDB_CACHE_ERROR": "تعذّر قراءة الذاكرة المؤقتة — حاول مجدداً", "TMDB_CREDITS": "البيانات الوصفية", "TMDB_ATTRIBUTION": "يستخدم هذا المنتج TMDB API ولكنه غير معتمد أو مصدّق من قِبل TMDB.", "APP_PREFERENCES": "تفضيلات التطبيق", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 1034eafee..6f8a3ca06 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "تأكد من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح خدام — TMDB جاوب مزيان", "TMDB_TEST_FAILED": "التحقق ما نجحش — TMDB رفض المفتاح ولا ما يمكنش الوصول ليه", + "TMDB_CACHE": "كاش ديال البيانات الوصفية", + "TMDB_CACHE_DESCRIPTION": "البيانات الوصفية اللي تحملات كتبقى محفوظة محلياً باش صفحات التفاصيل تتفتح دغيا. إلا مسحتيها، التطبيق غايجيب بيانات جديدة فالمرة الجاية.", + "TMDB_CACHE_SIZE": "{{entries}} عنصر · {{size}}", + "TMDB_CACHE_CLEAR": "مسح الكاش", + "TMDB_CACHE_ERROR": "ماقدرناش نقراو الكاش — عاود جرب", "TMDB_CREDITS": "الميتاداتا", "TMDB_ATTRIBUTION": "هاد المنتج كيستعمل TMDB API ولكن ماشي معتمد ولا مصادق عليه من طرف TMDB.", "APP_PREFERENCES": "تفضيلات التطبيق", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5485a3871..12ef0bfb5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Праверыць ключ", "TMDB_TEST_SUCCESS": "Ключ працуе — TMDB паспяхова адказаў", "TMDB_TEST_FAILED": "Праверка не ўдалася — TMDB адхіліў ключ або недаступны", + "TMDB_CACHE": "Кэш метаданых", + "TMDB_CACHE_DESCRIPTION": "Спампаваныя метаданыя захоўваюцца лакальна, каб старонкі дэталяў адкрываліся імгненна. Пасля ачысткі праграма загрузіць свежыя даныя пры наступным адкрыцці.", + "TMDB_CACHE_SIZE": "{{entries}} запісаў · {{size}}", + "TMDB_CACHE_CLEAR": "Ачысціць кэш", + "TMDB_CACHE_ERROR": "Не ўдалося прачытаць кэш — паспрабуйце зноў", "TMDB_CREDITS": "Метаданыя", "TMDB_ATTRIBUTION": "Гэты прадукт выкарыстоўвае TMDB API, але не ўхвалены і не сертыфікаваны TMDB.", "APP_PREFERENCES": "Параметры праграмы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 6f55673e3..8786a0736 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Schlüssel prüfen", "TMDB_TEST_SUCCESS": "Der Schlüssel funktioniert — TMDB hat erfolgreich geantwortet", "TMDB_TEST_FAILED": "Prüfung fehlgeschlagen — TMDB hat den Schlüssel abgelehnt oder ist nicht erreichbar", + "TMDB_CACHE": "Metadaten-Cache", + "TMDB_CACHE_DESCRIPTION": "Heruntergeladene Metadaten werden lokal gespeichert, damit Detailseiten sofort öffnen. Nach dem Leeren lädt die App beim nächsten Besuch frische Daten.", + "TMDB_CACHE_SIZE": "{{entries}} Einträge · {{size}}", + "TMDB_CACHE_CLEAR": "Cache leeren", + "TMDB_CACHE_ERROR": "Cache konnte nicht gelesen werden — erneut versuchen", "TMDB_CREDITS": "Metadaten", "TMDB_ATTRIBUTION": "Dieses Produkt verwendet die TMDB API, wird jedoch nicht von TMDB unterstützt oder zertifiziert.", "APP_PREFERENCES": "App-Einstellungen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f0ef6ff16..8ca4913ce 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Έλεγχος κλειδιού", "TMDB_TEST_SUCCESS": "Το κλειδί λειτουργεί — το TMDB απάντησε με επιτυχία", "TMDB_TEST_FAILED": "Ο έλεγχος απέτυχε — το TMDB απέρριψε το κλειδί ή δεν είναι προσβάσιμο", + "TMDB_CACHE": "Προσωρινή μνήμη μεταδεδομένων", + "TMDB_CACHE_DESCRIPTION": "Τα ληφθέντα μεταδεδομένα αποθηκεύονται τοπικά ώστε οι σελίδες λεπτομερειών να ανοίγουν αμέσως. Η εκκαθάριση αναγκάζει την εφαρμογή να κατεβάσει νέα δεδομένα.", + "TMDB_CACHE_SIZE": "{{entries}} εγγραφές · {{size}}", + "TMDB_CACHE_CLEAR": "Εκκαθάριση", + "TMDB_CACHE_ERROR": "Αδυναμία ανάγνωσης της μνήμης — δοκιμάστε ξανά", "TMDB_CREDITS": "Μεταδεδομένα", "TMDB_ATTRIBUTION": "Αυτό το προϊόν χρησιμοποιεί το TMDB API, αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB.", "APP_PREFERENCES": "Προτιμήσεις εφαρμογής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ded4da1d5..130b118e9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Check key", "TMDB_TEST_SUCCESS": "The key works — TMDB responded successfully", "TMDB_TEST_FAILED": "Check failed — TMDB rejected the key or is unreachable", + "TMDB_CACHE": "Metadata cache", + "TMDB_CACHE_DESCRIPTION": "Downloaded metadata is stored locally so detail pages open instantly. Clearing it makes the app fetch fresh data on the next visit.", + "TMDB_CACHE_SIZE": "{{entries}} entries · {{size}}", + "TMDB_CACHE_CLEAR": "Clear cache", + "TMDB_CACHE_ERROR": "Could not read the cache — try again", "TMDB_CREDITS": "Metadata", "TMDB_ATTRIBUTION": "This product uses the TMDB API but is not endorsed or certified by TMDB.", "APP_PREFERENCES": "App preferences", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 8dc779349..256f10042 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Comprobar clave", "TMDB_TEST_SUCCESS": "La clave funciona — TMDB respondió correctamente", "TMDB_TEST_FAILED": "La comprobación falló — TMDB rechazó la clave o no está disponible", + "TMDB_CACHE": "Caché de metadatos", + "TMDB_CACHE_DESCRIPTION": "Los metadatos descargados se guardan localmente para que las páginas de detalles se abran al instante. Al vaciarla, la app descargará datos nuevos en la próxima visita.", + "TMDB_CACHE_SIZE": "{{entries}} entradas · {{size}}", + "TMDB_CACHE_CLEAR": "Vaciar caché", + "TMDB_CACHE_ERROR": "No se pudo leer la caché: inténtalo de nuevo", "TMDB_CREDITS": "Metadatos", "TMDB_ATTRIBUTION": "Este producto utiliza la API de TMDB, pero no está avalado ni certificado por TMDB.", "APP_PREFERENCES": "Preferencias de la app", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d1a2cbe55..cb86e2daa 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Vérifier la clé", "TMDB_TEST_SUCCESS": "La clé fonctionne — TMDB a répondu avec succès", "TMDB_TEST_FAILED": "Échec de la vérification — TMDB a rejeté la clé ou est injoignable", + "TMDB_CACHE": "Cache des métadonnées", + "TMDB_CACHE_DESCRIPTION": "Les métadonnées téléchargées sont stockées localement pour que les pages de détails s'ouvrent instantanément. Après le vidage, l'application récupérera des données fraîches.", + "TMDB_CACHE_SIZE": "{{entries}} entrées · {{size}}", + "TMDB_CACHE_CLEAR": "Vider le cache", + "TMDB_CACHE_ERROR": "Impossible de lire le cache — réessayez", "TMDB_CREDITS": "Métadonnées", "TMDB_ATTRIBUTION": "Ce produit utilise l'API TMDB mais n'est ni approuvé ni certifié par TMDB.", "APP_PREFERENCES": "Préférences de l'application", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 929b2936d..83183c4a4 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Kulcs ellenőrzése", "TMDB_TEST_SUCCESS": "A kulcs működik – a TMDB sikeresen válaszolt", "TMDB_TEST_FAILED": "Az ellenőrzés sikertelen – a TMDB elutasította a kulcsot, vagy nem érhető el", + "TMDB_CACHE": "Metaadat-gyorsítótár", + "TMDB_CACHE_DESCRIPTION": "A letöltött metaadatok helyben tárolódnak, így a részletek azonnal megnyílnak. Ürítés után az alkalmazás friss adatokat tölt le.", + "TMDB_CACHE_SIZE": "{{entries}} bejegyzés · {{size}}", + "TMDB_CACHE_CLEAR": "Gyorsítótár ürítése", + "TMDB_CACHE_ERROR": "A gyorsítótár nem olvasható — próbálja újra", "TMDB_CREDITS": "Metaadatok", "TMDB_ATTRIBUTION": "Ez a termék a TMDB API-ját használja, de a TMDB nem hagyta jóvá és nem tanúsította.", "APP_PREFERENCES": "Az alkalmazás beállításai", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 809ac023a..a57ea5146 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Verifica chiave", "TMDB_TEST_SUCCESS": "La chiave funziona — TMDB ha risposto correttamente", "TMDB_TEST_FAILED": "Verifica non riuscita — TMDB ha rifiutato la chiave o non è raggiungibile", + "TMDB_CACHE": "Cache dei metadati", + "TMDB_CACHE_DESCRIPTION": "I metadati scaricati vengono salvati localmente perché le pagine dei dettagli si aprano subito. Svuotandola, l'app scaricherà dati aggiornati alla prossima visita.", + "TMDB_CACHE_SIZE": "{{entries}} voci · {{size}}", + "TMDB_CACHE_CLEAR": "Svuota cache", + "TMDB_CACHE_ERROR": "Impossibile leggere la cache — riprova", "TMDB_CREDITS": "Metadati", "TMDB_ATTRIBUTION": "Questo prodotto utilizza l'API di TMDB ma non è approvato né certificato da TMDB.", "APP_PREFERENCES": "Preferenze app", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 31fbfd08f..ccbdfeddb 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "キーを確認", "TMDB_TEST_SUCCESS": "キーは有効です — TMDB が正常に応答しました", "TMDB_TEST_FAILED": "確認に失敗しました — TMDB がキーを拒否したか、接続できません", + "TMDB_CACHE": "メタデータキャッシュ", + "TMDB_CACHE_DESCRIPTION": "ダウンロードしたメタデータをローカルに保存し、詳細ページを即座に表示します。消去すると次回アクセス時に再取得します。", + "TMDB_CACHE_SIZE": "{{entries}} 件 · {{size}}", + "TMDB_CACHE_CLEAR": "キャッシュを消去", + "TMDB_CACHE_ERROR": "キャッシュを読み取れません — 再試行してください", "TMDB_CREDITS": "メタデータ", "TMDB_ATTRIBUTION": "本製品は TMDB API を使用していますが、TMDB による承認または認証を受けたものではありません。", "APP_PREFERENCES": "アプリ設定", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ba89e55b2..1c7351c29 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "키 확인", "TMDB_TEST_SUCCESS": "키가 작동합니다 — TMDB가 정상적으로 응답했습니다", "TMDB_TEST_FAILED": "확인에 실패했습니다 — TMDB가 키를 거부했거나 연결할 수 없습니다", + "TMDB_CACHE": "메타데이터 캐시", + "TMDB_CACHE_DESCRIPTION": "내려받은 메타데이터를 로컬에 저장해 상세 페이지가 즉시 열립니다. 지우면 다음 방문 시 새로 가져옵니다.", + "TMDB_CACHE_SIZE": "{{entries}}개 · {{size}}", + "TMDB_CACHE_CLEAR": "캐시 지우기", + "TMDB_CACHE_ERROR": "캐시를 읽지 못했습니다 — 다시 시도하세요", "TMDB_CREDITS": "메타데이터", "TMDB_ATTRIBUTION": "이 제품은 TMDB API를 사용하지만 TMDB의 보증이나 인증을 받지 않았습니다.", "APP_PREFERENCES": "앱 환경설정", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8743e9b09..c28d9c7e3 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Sleutel controleren", "TMDB_TEST_SUCCESS": "De sleutel werkt — TMDB heeft succesvol gereageerd", "TMDB_TEST_FAILED": "Controle mislukt — TMDB heeft de sleutel geweigerd of is niet bereikbaar", + "TMDB_CACHE": "Metadata-cache", + "TMDB_CACHE_DESCRIPTION": "Gedownloade metadata wordt lokaal opgeslagen zodat detailpagina's direct openen. Na het wissen haalt de app bij het volgende bezoek verse gegevens op.", + "TMDB_CACHE_SIZE": "{{entries}} items · {{size}}", + "TMDB_CACHE_CLEAR": "Cache wissen", + "TMDB_CACHE_ERROR": "Cache kon niet worden gelezen — probeer opnieuw", "TMDB_CREDITS": "Metadata", "TMDB_ATTRIBUTION": "Dit product maakt gebruik van de TMDB API, maar wordt niet onderschreven of gecertificeerd door TMDB.", "APP_PREFERENCES": "App-voorkeuren", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 9919dc02d..46f5db3c9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Sprawdź klucz", "TMDB_TEST_SUCCESS": "Klucz działa — TMDB odpowiedziało poprawnie", "TMDB_TEST_FAILED": "Sprawdzenie nie powiodło się — TMDB odrzuciło klucz lub jest niedostępne", + "TMDB_CACHE": "Pamięć podręczna metadanych", + "TMDB_CACHE_DESCRIPTION": "Pobrane metadane są zapisywane lokalnie, dzięki czemu strony szczegółów otwierają się natychmiast. Po wyczyszczeniu aplikacja pobierze świeże dane.", + "TMDB_CACHE_SIZE": "{{entries}} wpisów · {{size}}", + "TMDB_CACHE_CLEAR": "Wyczyść pamięć", + "TMDB_CACHE_ERROR": "Nie udało się odczytać pamięci — spróbuj ponownie", "TMDB_CREDITS": "Metadane", "TMDB_ATTRIBUTION": "Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB.", "APP_PREFERENCES": "Preferencje aplikacji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 7f87a7027..da02cce09 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Verificar chave", "TMDB_TEST_SUCCESS": "A chave funciona — o TMDB respondeu com sucesso", "TMDB_TEST_FAILED": "A verificação falhou — o TMDB rejeitou a chave ou está inacessível", + "TMDB_CACHE": "Cache de metadados", + "TMDB_CACHE_DESCRIPTION": "Os metadados transferidos são guardados localmente para que as páginas de detalhes abram de imediato. Ao limpar, a app irá obter dados novos na próxima visita.", + "TMDB_CACHE_SIZE": "{{entries}} entradas · {{size}}", + "TMDB_CACHE_CLEAR": "Limpar cache", + "TMDB_CACHE_ERROR": "Não foi possível ler a cache — tente novamente", "TMDB_CREDITS": "Metadados", "TMDB_ATTRIBUTION": "Este produto usa a API do TMDB, mas não é endossado nem certificado pelo TMDB.", "APP_PREFERENCES": "Preferências do aplicativo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7b43ff48b..c2eb29748 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Проверить ключ", "TMDB_TEST_SUCCESS": "Ключ работает — TMDB успешно ответил", "TMDB_TEST_FAILED": "Проверка не удалась — TMDB отклонил ключ или недоступен", + "TMDB_CACHE": "Кэш метаданных", + "TMDB_CACHE_DESCRIPTION": "Загруженные метаданные хранятся локально, поэтому страницы деталей открываются мгновенно. После очистки приложение загрузит свежие данные при следующем открытии.", + "TMDB_CACHE_SIZE": "{{entries}} записей · {{size}}", + "TMDB_CACHE_CLEAR": "Очистить кэш", + "TMDB_CACHE_ERROR": "Не удалось прочитать кэш — попробуйте ещё раз", "TMDB_CREDITS": "Метаданные", "TMDB_ATTRIBUTION": "Этот продукт использует TMDB API, но не одобрен и не сертифицирован TMDB.", "APP_PREFERENCES": "Параметры приложения", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7f03f8c15..5df212dc2 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Anahtarı kontrol et", "TMDB_TEST_SUCCESS": "Anahtar çalışıyor — TMDB başarıyla yanıt verdi", "TMDB_TEST_FAILED": "Kontrol başarısız — TMDB anahtarı reddetti veya erişilemiyor", + "TMDB_CACHE": "Meta veri önbelleği", + "TMDB_CACHE_DESCRIPTION": "İndirilen meta veriler yerel olarak saklanır, böylece detay sayfaları anında açılır. Temizlediğinizde uygulama bir sonraki ziyarette yeni veri indirir.", + "TMDB_CACHE_SIZE": "{{entries}} kayıt · {{size}}", + "TMDB_CACHE_CLEAR": "Önbelleği temizle", + "TMDB_CACHE_ERROR": "Önbellek okunamadı — tekrar deneyin", "TMDB_CREDITS": "Meta veri", "TMDB_ATTRIBUTION": "Bu ürün TMDB API'sini kullanır ancak TMDB tarafından onaylanmamış veya sertifikalandırılmamıştır.", "APP_PREFERENCES": "Uygulama tercihleri", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 6ecf42801..3f0dc3ac2 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "检查密钥", "TMDB_TEST_SUCCESS": "密钥有效 — TMDB 响应成功", "TMDB_TEST_FAILED": "检查失败 — TMDB 拒绝了该密钥或无法访问", + "TMDB_CACHE": "元数据缓存", + "TMDB_CACHE_DESCRIPTION": "已下载的元数据保存在本地,详情页可立即打开。清除后应用会在下次访问时重新获取。", + "TMDB_CACHE_SIZE": "{{entries}} 条 · {{size}}", + "TMDB_CACHE_CLEAR": "清除缓存", + "TMDB_CACHE_ERROR": "无法读取缓存 — 请重试", "TMDB_CREDITS": "元数据", "TMDB_ATTRIBUTION": "本产品使用 TMDB API,但未获得 TMDB 的认可或认证。", "APP_PREFERENCES": "应用偏好", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 619d60d72..042b7c831 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "檢查金鑰", "TMDB_TEST_SUCCESS": "金鑰有效 — TMDB 回應成功", "TMDB_TEST_FAILED": "檢查失敗 — TMDB 拒絕該金鑰或無法連線", + "TMDB_CACHE": "中繼資料快取", + "TMDB_CACHE_DESCRIPTION": "已下載的中繼資料儲存在本機,詳細頁面可立即開啟。清除後應用程式會在下次瀏覽時重新取得。", + "TMDB_CACHE_SIZE": "{{entries}} 筆 · {{size}}", + "TMDB_CACHE_CLEAR": "清除快取", + "TMDB_CACHE_ERROR": "無法讀取快取 — 請重試", "TMDB_CREDITS": "中繼資料", "TMDB_ATTRIBUTION": "本產品使用 TMDB API,但未經 TMDB 認可或認證。", "APP_PREFERENCES": "應用程式偏好設定", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 49a329d9f..5c30adc29 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -75,8 +75,15 @@ 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 @@ -303,12 +310,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). @@ -317,7 +343,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 diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts index d7a4e40bd..d9e2da417 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts @@ -50,4 +50,145 @@ describe('TmdbCacheService (in-memory LRU)', () => { const cached = await service.get('movie', 'id:same', 'en-US'); expect(cached?.lookupKey).toBe('id:same'); }); + + describe('stats and clearing (PWA path)', () => { + it('counts entries and ENCODED payload bytes', async () => { + // String.length would report 3 for a 6-byte Cyrillic payload + await service.set({ ...entry('id:1'), payload: '"да"' }); + + const stats = await service.getStats(); + expect(stats?.entries).toBe(1); + expect(stats?.bytes).toBe( + new TextEncoder().encode('"да"').length + ); + }); + + it('clears the map and reports how many entries went', async () => { + await service.set(entry('id:1')); + await service.set(entry('id:2')); + + await expect(service.clear()).resolves.toBe(2); + await expect(service.getStats()).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('drops a write that was in flight when the cache was cleared', async () => { + // Otherwise the late write silently restores exactly the data + // the user just asked to remove + const pending = service.set(entry('id:late')); + await service.clear(); + await pending; + + await expect( + service.get('movie', 'id:late', 'en-US') + ).resolves.toBeNull(); + }); + }); +}); + +describe('TmdbCacheService (Electron bridge)', () => { + const entry = (lookupKey: string): TmdbCacheEntry => ({ + mediaType: 'movie', + lookupKey, + language: 'en-US', + tmdbId: 1, + payload: '{}', + }); + + interface ElectronStub { + dbGetTmdbMetadata: jest.Mock; + dbSetTmdbMetadata: jest.Mock; + dbGetTmdbCacheStats?: jest.Mock; + dbClearTmdbMetadata?: jest.Mock; + } + + let electron: ElectronStub; + + /** Resolves the next dbSetTmdbMetadata call on demand */ + let releaseWrite: () => void; + + beforeEach(() => { + releaseWrite = () => undefined; + electron = { + dbGetTmdbMetadata: jest.fn().mockResolvedValue(null), + dbSetTmdbMetadata: jest.fn( + () => + new Promise((resolve) => { + releaseWrite = resolve; + }) + ), + dbGetTmdbCacheStats: jest + .fn() + .mockResolvedValue({ entries: 3, bytes: 90 }), + dbClearTmdbMetadata: jest.fn().mockResolvedValue({ deleted: 3 }), + }; + (window as unknown as { electron: unknown }).electron = electron; + }); + + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('waits for an in-flight write instead of re-clearing after it', async () => { + const service = new TmdbCacheService(); + const pending = service.set(entry('id:late')); + + const cleared = service.clear(); + // The clear must not reach SQLite before the write it has to remove + await Promise.resolve(); + expect(electron.dbClearTmdbMetadata).not.toHaveBeenCalled(); + + releaseWrite(); + await pending; + await expect(cleared).resolves.toBe(3); + expect(electron.dbClearTmdbMetadata).toHaveBeenCalledTimes(1); + }); + + it('keeps rows written after the clear', async () => { + const service = new TmdbCacheService(); + await service.clear(); + + const pending = service.set(entry('id:fresh')); + releaseWrite(); + await pending; + + // A second clear here would delete data the user never asked to lose + expect(electron.dbClearTmdbMetadata).toHaveBeenCalledTimes(1); + }); + + it('lands a write that starts during the clear after the delete', async () => { + const service = new TmdbCacheService(); + const order: string[] = []; + electron.dbClearTmdbMetadata = jest.fn(async () => { + order.push('clear'); + return { deleted: 3 }; + }); + + const first = service.set(entry('id:before')); + const cleared = service.clear(); + + // Starts while the clear is waiting on the first write — without + // serialization its row would reach SQLite before the delete + const second = service.set(entry('id:during')); + electron.dbSetTmdbMetadata.mockImplementation(async () => { + order.push('write'); + }); + + releaseWrite(); + await Promise.all([first, cleared, second]); + + expect(order).toEqual(['clear', 'write']); + }); + + it('reports an unsupported shell instead of the empty renderer map', async () => { + // Newer renderer, older preload: rows are in SQLite but out of reach + delete electron.dbGetTmdbCacheStats; + delete electron.dbClearTmdbMetadata; + const service = new TmdbCacheService(); + + await expect(service.getStats()).resolves.toBeNull(); + await expect(service.clear()).resolves.toBeNull(); + }); }); diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.ts index b2bb18d33..60dd2f975 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.ts @@ -1,5 +1,9 @@ import { Injectable } from '@angular/core'; -import { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from '@iptvnator/shared/interfaces'; /** PWA in-memory cache ceiling — details payloads are a few KB each */ const MEMORY_CACHE_MAX_ENTRIES = 300; @@ -18,6 +22,17 @@ const MEMORY_CACHE_MAX_ENTRIES = 300; export class TmdbCacheService { private readonly memoryCache = new Map(); + /** + * Electron writes that have not landed yet. {@link clear} waits for + * them, so a write already in flight cannot outlive the clear that + * followed it — and, unlike re-clearing after the late write lands, + * waiting cannot take rows written *after* the user cleared with it. + */ + private readonly pendingWrites = new Set>(); + + /** Set for the duration of {@link clear}; writes queue behind it */ + private clearInFlight: Promise | null = null; + private get bridge() { // typeof checks guard against version skew: an older Electron shell // may not expose the TMDB cache methods yet @@ -59,17 +74,29 @@ export class TmdbCacheService { } async set(entry: TmdbCacheEntry): Promise { - const stamped: TmdbCacheEntry = { - ...entry, - fetchedAt: new Date().toISOString(), - }; - const bridge = this.bridge; if (bridge) { + // A clear owns the table until it finishes. Dispatching now + // would race the delete and lose a row the user never asked to + // lose, so this write waits it out and lands after it. + while (this.clearInFlight) { + await this.clearInFlight; + } + + // The IIFE also converts a synchronous throw from the bridge + // into a rejection, so `set` never throws at its caller + const write = (async () => { + try { + await bridge.dbSetTmdbMetadata(this.stamp(entry)); + } catch (error) { + console.warn('TMDB cache write failed:', error); + } + })(); + this.pendingWrites.add(write); try { - await bridge.dbSetTmdbMetadata(stamped); - } catch (error) { - console.warn('TMDB cache write failed:', error); + await write; + } finally { + this.pendingWrites.delete(write); } return; } @@ -80,7 +107,7 @@ export class TmdbCacheService { entry.language ); this.memoryCache.delete(key); - this.memoryCache.set(key, stamped); + this.memoryCache.set(key, this.stamp(entry)); // delete-then-reinsert above means at most one entry over the cap if (this.memoryCache.size > MEMORY_CACHE_MAX_ENTRIES) { const oldest = this.memoryCache.keys().next().value; @@ -90,6 +117,11 @@ export class TmdbCacheService { } } + /** Stamped at write time, not at call time — a write may have waited */ + private stamp(entry: TmdbCacheEntry): TmdbCacheEntry { + return { ...entry, fetchedAt: new Date().toISOString() }; + } + isFresh(entry: TmdbCacheEntry | null, ttlMs: number): boolean { if (!entry?.fetchedAt) { return false; @@ -106,4 +138,102 @@ export class TmdbCacheService { ): string { return `${mediaType}:${language}:${lookupKey}`; } + + /** + * Cache size for the settings panel. In the PWA the cache is the + * session-scoped map, so the numbers describe that instead. + */ + async getStats(): Promise { + const bridge = this.bridge; + if (bridge) { + if (!bridge.dbGetTmdbCacheStats) { + // Version skew: this shell persists to SQLite but predates + // the maintenance ops. The renderer map is empty in + // Electron, so falling back to it would report a cache that + // is anything but empty as having nothing in it. + return null; + } + try { + return await bridge.dbGetTmdbCacheStats(); + } catch (error) { + // `null`, not zeros: reporting a failed read as an empty + // cache would hide real rows and disable the Clear button + console.warn('TMDB cache stats failed:', error); + return null; + } + } + + // TextEncoder measures encoded bytes; String.length counts UTF-16 + // code units, which understates non-ASCII payloads and would + // disagree with the SQLite BLOB byte count. + const encoder = new TextEncoder(); + let bytes = 0; + for (const entry of this.memoryCache.values()) { + bytes += entry.payload ? encoder.encode(entry.payload).length : 0; + } + return { entries: this.memoryCache.size, bytes }; + } + + /** + * Drops everything. Enrichment refetches on demand, so this only + * costs the next few requests. + */ + async clear(): Promise { + const bridge = this.bridge; + const clearedFromMemory = this.memoryCache.size; + this.memoryCache.clear(); + + if (!bridge) { + return clearedFromMemory; + } + + const clearMetadata = bridge.dbClearTmdbMetadata; + if (!clearMetadata) { + // Same version skew as getStats(): the rows are in SQLite and + // stay there, so reporting a successful clear would be a lie + return null; + } + + // Called through the bridge so the preload keeps its receiver + const run = this.runClear(() => clearMetadata.call(bridge)); + this.clearInFlight = run; + try { + return await run; + } finally { + this.clearInFlight = null; + } + } + + /** + * Everything between the writes already in flight and the delete. Held + * in `clearInFlight` for its duration, which is what puts writes that + * start meanwhile on the far side of the delete rather than under it. + */ + private async runClear( + clearMetadata: () => Promise<{ deleted: number } | undefined> + ): Promise { + // Writes issued before the clear land first, so the delete takes + // them too. + if (this.pendingWrites.size > 0) { + // Not Promise.allSettled: the web target compiles against + // lib es2018. The writes swallow their own errors anyway. + await Promise.all( + [...this.pendingWrites].map((write) => + write.then( + () => undefined, + () => undefined + ) + ) + ); + } + + try { + const result = await clearMetadata(); + return result?.deleted ?? 0; + } catch (error) { + // `null` so the panel can say it failed and stay actionable + console.warn('TMDB cache clear failed:', error); + return null; + } + } } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index fe63da397..b36355207 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -32,7 +32,11 @@ import { import { PortalDebugEvent } from './portal-debug.interface'; import { CatalogTitleMatch } from './catalog-title-match.interface'; import { Settings } from './settings.interface'; -import { TmdbCacheEntry, TmdbCacheMediaType } from './tmdb.interface'; +import { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from './tmdb.interface'; import { XtreamCategory } from './xtream-category.interface'; export const ELECTRON_BRIDGE_CONTENT_TYPES = { @@ -821,6 +825,11 @@ export interface ElectronBridgeApi { language: string ) => Promise; dbSetTmdbMetadata: (entry: TmdbCacheEntry) => Promise; + /** Row count + payload bytes for the settings cache panel */ + dbGetTmdbCacheStats: () => Promise; + dbClearTmdbMetadata: () => Promise< + ElectronBridgeResult & { deleted: number } + >; /** Cross-playlist title matching (actor page "All portals" scope) */ dbMatchTitles: (titles: string[]) => Promise; onChannelChange?: ( diff --git a/libs/shared/interfaces/src/lib/tmdb.interface.ts b/libs/shared/interfaces/src/lib/tmdb.interface.ts index 0174b3265..a00db1381 100644 --- a/libs/shared/interfaces/src/lib/tmdb.interface.ts +++ b/libs/shared/interfaces/src/lib/tmdb.interface.ts @@ -106,6 +106,13 @@ export interface TmdbRecommendation { * - `title:|year:` — search resolution result; a `null` * `tmdbId` is a cached "no confident match" verdict (negative cache) */ +/** Size of the persisted TMDB cache, shown in the settings panel */ +export interface TmdbCacheStats { + entries: number; + /** Total payload size in bytes */ + bytes: number; +} + export interface TmdbCacheEntry { mediaType: TmdbCacheMediaType; lookupKey: string; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index b4d59da5b..50e1e1e23 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -17,6 +17,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util'; type="button" class="nav-item settings-section-item" [class.active]="ctx.activeSection() === section.id" + [attr.data-test-id]="'settings-section-' + section.id" (click)="ctx.navigateToSection(section.id)" > {{ section.icon }} From 6c946978b46e508d6a5c0e32ce2198c7c5cb9dbe Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:25:56 +0200 Subject: [PATCH 04/12] chore(release): drop the superseded v0.20 screenshot script (#1262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1261 replaced this one-off capture with a manifest-driven script, so the v0.20 version is dead weight: hard-coded slugs, paths and output directory, none of the fail-closed guards, and two `RegExp`-from-string constructions of the kind CodeQL flags (one of which it flagged on the replacement before that was rewritten to use predicates). Removing it also drops its `tools/eslint/max-lines-baseline.mjs` entry, so the baseline no longer carries a file that does not exist. Not a pure dead-code deletion, and worth stating: two capabilities go with it, neither reachable from the new pipeline — `createDesignedCopy` (title/kicker overlays on captured frames) and `createHeroImage` (a 1600x900 canvas collage built from three screenshots). The v0.20 assets they produced are already committed under apps/website/public/blog/v0-20/, so nothing published breaks; a future release wanting the same collage needs it ported deliberately rather than resurrected here. Docs: docs/architecture/xtream-mock-server.md now points at capture-release-screenshots.ts and notes its mock-identity check. Verified: no references to the removed file remain anywhere in the repo, and `pnpm run lint` passes for all 42 projects with the shortened baseline. Co-authored-by: Claude Opus 5 --- docs/architecture/xtream-mock-server.md | 5 +- tools/eslint/max-lines-baseline.mjs | 1 - tools/release/capture-v020-screenshots.ts | 1165 --------------------- 3 files changed, 3 insertions(+), 1168 deletions(-) delete mode 100644 tools/release/capture-v020-screenshots.ts diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index a41f89880..304ae7cb9 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -284,8 +284,9 @@ This scenario is reserved for release screenshots and marketing materials: including documentary, noir, animated family, retro rescue drama, cyberpunk, anime-inspired space school, and workplace dramedy styles - screenshot capture remains deterministic and offline because - `tools/release/capture-v020-screenshots.ts` consumes the local mock server - assets and never calls OpenAI + `tools/release/capture-release-screenshots.ts` consumes the local mock server + assets and never calls OpenAI; its guards additionally verify that the + service answering on the mock port really is this fixture server - the SVG renderer in `marketing.generator.ts` remains the fallback for missing assets, live logos, season covers, and episode thumbnails diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 6ef2f9985..73258275c 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -130,6 +130,5 @@ export const maxLinesBaseline = [ 'libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts', 'libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts', 'libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts', - 'tools/release/capture-v020-screenshots.ts', 'tools/release/generate-marketing-artwork.ts', ]; diff --git a/tools/release/capture-v020-screenshots.ts b/tools/release/capture-v020-screenshots.ts deleted file mode 100644 index eb09fce16..000000000 --- a/tools/release/capture-v020-screenshots.ts +++ /dev/null @@ -1,1165 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process'; -import { - accessSync, - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { - _electron as electron, - type ElectronApplication, - type Page, -} from '@playwright/test'; - -type ThemeName = 'dark' | 'light'; -type ScreenshotSlug = - | 'dashboard' - | 'm3u-live-groups-two-column' - | 'settings' - | 'xtream-series-season-open' - | 'xtream-vod-details'; - -type CaptureTarget = { - slug: ScreenshotSlug; - title: string; - kicker: string; -}; - -const workspaceRoot = process.cwd(); -const electronMainPath = path.join( - workspaceRoot, - 'dist/apps/electron-backend/main.js' -); -const rendererIndexPath = path.join(workspaceRoot, 'dist/apps/web/index.html'); -const xtreamMockOrigin = 'http://localhost:3211'; -const outputRoot = path.join( - workspaceRoot, - 'apps/website/public/blog/v0-20/screenshots' -); -const heroOutputPath = path.join( - workspaceRoot, - 'apps/website/public/blog/v0-20/v0-20-hero.png' -); -const viewport = { width: 1280, height: 720 }; -const captureTargets: CaptureTarget[] = [ - { - slug: 'dashboard', - title: 'Content-first dashboard', - kicker: 'New in v0.20', - }, - { - slug: 'xtream-vod-details', - title: 'Richer movie details', - kicker: 'Xtream', - }, - { - slug: 'xtream-series-season-open', - title: 'Series seasons and episodes', - kicker: 'Xtream', - }, - { - slug: 'settings', - title: 'Reworked settings', - kicker: 'Customization', - }, - { - slug: 'm3u-live-groups-two-column', - title: 'Two-column live TV', - kicker: 'M3U', - }, -]; -let capturedM3uPlaylistId: string | undefined; -let capturedXtreamPlaylistId: string | undefined; - -async function main(): Promise { - assertBuiltRuntimeExists(); - - mkdirSync(outputRoot, { recursive: true }); - const managedMockServer = await ensureXtreamMockServer(); - const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-v020-shots-')); - const m3uFixturePath = writeM3uPlaylistFixture(dataDir); - let app: ElectronApplication | undefined; - - try { - app = await launchApp(dataDir); - const page = await findMainWindow(app); - await sizeElectronWindow(app); - await waitForAppReady(page); - await seedDemoData(page, m3uFixturePath); - - for (const theme of ['dark', 'light'] as ThemeName[]) { - await applyTheme(page, theme); - await captureThemeSet(page, theme); - } - - await createHeroImage(page); - await verifyExpectedAssets(); - } finally { - await app?.close().catch(() => undefined); - managedMockServer?.kill('SIGTERM'); - rmSync(dataDir, { recursive: true, force: true }); - } -} - -function assertBuiltRuntimeExists(): void { - if (!fileExists(electronMainPath) || !fileExists(rendererIndexPath)) { - throw new Error( - [ - 'Electron E2E runtime is missing.', - `Expected ${electronMainPath}`, - `Expected ${rendererIndexPath}`, - 'Build it first, for example: pnpm nx run electron-backend:build-e2e', - ].join('\n') - ); - } -} - -async function ensureXtreamMockServer(): Promise { - if (await isHealthy(`${xtreamMockOrigin}/health`)) { - return undefined; - } - - const child = spawn( - path.join(workspaceRoot, 'node_modules/.bin/tsx'), - ['apps/xtream-mock-server/src/main.ts'], - { - cwd: workspaceRoot, - env: { - ...process.env, - NODE_ENV: 'development', - PORT: '3211', - }, - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - - child.stdout?.on('data', (chunk) => { - process.stdout.write(`[xtream-mock] ${chunk}`); - }); - child.stderr?.on('data', (chunk) => { - process.stderr.write(`[xtream-mock] ${chunk}`); - }); - - await waitForHealthy(`${xtreamMockOrigin}/health`, 20_000); - return child; -} - -function buildM3uPlaylist(): string { - const channels = [ - ['Newsroom', 'Aurora Local', 'aurora-local'], - ['Newsroom', 'Civic Pulse', 'civic-pulse'], - ['Sports', 'Fieldside One', 'fieldside-one'], - ['Sports', 'Motion Arena', 'motion-arena'], - ['Kids', 'Horizon Kids', 'horizon-kids'], - ['Kids', 'Story Lantern', 'story-lantern'], - ['Culture', 'Atlas Culture', 'atlas-culture'], - ['Culture', 'Night Music', 'night-music'], - ]; - const stream = `${xtreamMockOrigin}/live/marketing/marketing/52000.m3u8`; - const lines = ['#EXTM3U']; - - channels.forEach(([group, title, slug], index) => { - lines.push( - `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${xtreamMockOrigin}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}` - ); - lines.push(stream); - }); - - return `${lines.join('\n')}\n`; -} - -function writeM3uPlaylistFixture(dataDir: string): string { - const filePath = path.join(dataDir, 'v020-demo.m3u'); - writeFileSync(filePath, buildM3uPlaylist(), 'utf8'); - return filePath; -} - -function marketingAssetUrl( - kind: 'backdrop' | 'episode' | 'logo' | 'poster' | 'season', - title: string, - size: string -): string { - return `${xtreamMockOrigin}/assets/marketing/${kind}/${slugifyForFile( - title - )}.svg?size=${encodeURIComponent(size)}`; -} - -async function launchApp(dataDir: string): Promise { - return electron.launch({ - args: [electronMainPath], - env: { - ...process.env, - ELECTRON_IS_DEV: '0', - IPTVNATOR_E2E_DATA_DIR: dataDir, - NODE_ENV: 'test', - }, - }); -} - -async function findMainWindow(app: ElectronApplication): Promise { - await new Promise((resolve) => setTimeout(resolve, 1500)); - - for (const candidate of app.windows()) { - const title = await candidate.title(); - if (!title.includes('DevTools')) { - return candidate; - } - } - - return app.firstWindow(); -} - -async function sizeElectronWindow(app: ElectronApplication): Promise { - await app.evaluate(({ BrowserWindow }, size) => { - const win = BrowserWindow.getAllWindows().find( - (candidate) => !candidate.webContents.getTitle().includes('DevTools') - ); - win?.setSize(size.width, size.height); - win?.center(); - }, viewport); -} - -async function waitForAppReady(page: Page): Promise { - await page.waitForLoadState('domcontentloaded'); - await page.waitForSelector('app-root', { timeout: 45_000 }); - await page.waitForFunction( - () => (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > 0, - { timeout: 45_000 } - ); -} - -async function seedDemoData(page: Page, m3uPath: string): Promise { - await addXtreamPortal(page); - await addM3uPlaylist(page, m3uPath); - await seedDashboardActivity(page); -} - -async function addXtreamPortal(page: Page): Promise { - await openAddPlaylistDialog(page); - const dialog = page.locator('mat-dialog-container').last(); - - await clickDialogOption(dialog, /^xtream$/i); - await dialog.locator('#title').fill('v0.20 Fictional Xtream'); - await dialog.locator('#serverUrl').fill(xtreamMockOrigin); - await dialog.locator('#username').fill('marketing'); - await dialog.locator('#password').fill('marketing'); - await dialog.getByRole('button', { name: /^(add|add playlist)$/i }).last().click(); - await dialog.waitFor({ state: 'detached', timeout: 30_000 }); - await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { - timeout: 45_000, - }); - capturedXtreamPlaylistId = playlistIdFromUrl(page.url(), 'xtreams'); - await page.locator('.category-content-layout, app-content-card').first().waitFor({ - state: 'visible', - timeout: 45_000, - }); -} - -async function addM3uPlaylist(page: Page, m3uPath: string): Promise { - await openAddPlaylistDialog(page); - const dialog = page.locator('mat-dialog-container').last(); - - await clickDialogOption(dialog, /^m3u$/i); - await clickDialogOption(dialog, /add\s+via\s+file\s+upload/i); - - const fileInput = dialog.locator('input[type="file"][name="playlist"]'); - await fileInput.evaluate((element, selectedFilePath) => { - (element as HTMLInputElement).dataset.filePathOverride = - selectedFilePath; - }, m3uPath); - await fileInput.setInputFiles(m3uPath); - await dialog - .getByRole('button', { name: /add playlist/i }) - .last() - .click({ timeout: 15_000 }); - await dialog.waitFor({ state: 'detached', timeout: 30_000 }); - await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { - timeout: 45_000, - }); - capturedM3uPlaylistId = playlistIdFromUrl(page.url(), 'playlists'); - try { - await page.locator('[data-test-id="channel-item"]').first().waitFor({ - state: 'visible', - timeout: 60_000, - }); - } catch (error) { - await writeDebugScreenshot(page, 'm3u-import'); - const diagnostics = await page.evaluate(() => ({ - bodyText: document.body.innerText.slice(0, 2000), - title: document.title, - url: location.href, - })); - - console.error( - `[capture] M3U import did not show channels: ${JSON.stringify( - diagnostics, - null, - 2 - )}` - ); - throw error; - } -} - -async function seedDashboardActivity(page: Page): Promise { - const playlistId = requireCapturedPlaylistId('xtreams'); - const items = [ - { - xtreamId: 62000, - type: 'movie' as const, - backdropUrl: marketingAssetUrl('backdrop', 'Crimson Skylark', '1280x720'), - favorite: true, - recent: true, - }, - { - xtreamId: 62001, - type: 'movie' as const, - backdropUrl: marketingAssetUrl('backdrop', 'The Voltage Guard', '1280x720'), - favorite: true, - recent: true, - }, - { - xtreamId: 62002, - type: 'movie' as const, - backdropUrl: marketingAssetUrl('backdrop', 'Midnight Mantle', '1280x720'), - favorite: true, - recent: false, - }, - { - xtreamId: 72000, - type: 'series' as const, - backdropUrl: marketingAssetUrl('backdrop', 'Skyline Sentinels', '1280x720'), - favorite: true, - recent: true, - }, - { - xtreamId: 72001, - type: 'series' as const, - backdropUrl: marketingAssetUrl('backdrop', 'The Aegis Club', '1280x720'), - favorite: true, - recent: false, - }, - ]; - - await page.evaluate( - async ({ items, playlistId }) => { - const electronApi = (window as typeof window & { - electron?: { - dbAddFavorite?: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise; - dbAddRecentItem?: ( - contentId: number, - playlistId: string, - backdropUrl?: string - ) => Promise; - dbGetContentByXtreamId?: ( - xtreamId: number, - playlistId: string, - contentType?: 'live' | 'movie' | 'series' - ) => Promise<{ id: number } | null>; - }; - }).electron; - - if (!electronApi?.dbGetContentByXtreamId) { - throw new Error('Electron database bridge is unavailable.'); - } - - for (const item of items) { - const content = await electronApi.dbGetContentByXtreamId( - item.xtreamId, - playlistId, - item.type - ); - - if (!content?.id) { - throw new Error( - `Could not find imported Xtream content ${item.xtreamId}.` - ); - } - - if (item.favorite) { - await electronApi.dbAddFavorite?.( - content.id, - playlistId, - item.backdropUrl - ); - } - - if (item.recent) { - await electronApi.dbAddRecentItem?.( - content.id, - playlistId, - item.backdropUrl - ); - } - } - }, - { items, playlistId } - ); -} - -async function openAddPlaylistDialog(page: Page): Promise { - await page.getByRole('button', { name: /add playlist/i }).first().click(); - await page.locator('mat-dialog-container').last().waitFor({ - state: 'visible', - timeout: 15_000, - }); -} - -async function clickDialogOption(dialog: ReturnType, label: RegExp): Promise { - const tab = dialog.getByRole('tab', { name: label }).first(); - if ((await tab.count()) > 0) { - await tab.click(); - return; - } - - await dialog.getByRole('button', { name: label }).first().click(); -} - -async function applyTheme(page: Page, theme: ThemeName): Promise { - await openSettings(page); - const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; - const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); - - await themeButton.scrollIntoViewIfNeeded(); - await themeButton.click(); - - const saveButton = page.locator('[data-test-id="save-settings"]').first(); - if (await saveButton.isEnabled()) { - await saveButton.click(); - await waitForTransientUiToSettle(page); - } - - await page.waitForFunction( - (expectedTheme) => - document.body.classList.contains('dark-theme') === - (expectedTheme === 'dark'), - theme, - { timeout: 10_000 } - ); -} - -async function openSettings(page: Page): Promise { - await page.locator('a[href$="/workspace/settings"]').first().click(); - await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); - await page.locator('[data-test-id="settings-container"]').waitFor({ - state: 'visible', - timeout: 15_000, - }); -} - -async function captureThemeSet(page: Page, theme: ThemeName): Promise { - await captureCurrentView(page, theme, 'settings'); - await openDashboard(page); - await captureCurrentView(page, theme, 'dashboard'); - await openXtreamVodDetails(page); - await captureCurrentView(page, theme, 'xtream-vod-details'); - await openXtreamSeriesSeason(page); - await captureCurrentView(page, theme, 'xtream-series-season-open'); - await openM3uGroupsView(page); - await captureCurrentView(page, theme, 'm3u-live-groups-two-column'); -} - -async function openDashboard(page: Page): Promise { - await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); - await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); - await page.locator('[data-test-id="dashboard-page"]').waitFor({ - state: 'visible', - timeout: 20_000, - }); - await waitForTransientUiToSettle(page); - await page.locator('[data-test-id="dashboard-hero"]').waitFor({ - state: 'visible', - timeout: 30_000, - }); - - for (const testId of [ - 'dashboard-recently-watched-rail', - 'dashboard-global-favorites-rail', - 'dashboard-recent-sources-rail', - 'dashboard-xtream-recently-added-rail', - ]) { - await page.locator(`[data-test-id="${testId}"]`).waitFor({ - state: 'visible', - timeout: 30_000, - }); - } -} - -async function waitForTransientUiToSettle(page: Page): Promise { - await page - .locator('.mat-mdc-snack-bar-container') - .first() - .waitFor({ state: 'detached', timeout: 10_000 }) - .catch(() => undefined); - await page.evaluate(() => { - document - .querySelectorAll('.mat-mdc-snack-bar-container, simple-snack-bar') - .forEach((element) => { - const overlayPane = element.closest('.cdk-overlay-pane'); - (overlayPane ?? element).remove(); - }); - }); - await page.waitForTimeout(250); -} - -async function openXtreamVodDetails(page: Page): Promise { - const xtreamId = requireCapturedPlaylistId('xtreams'); - - await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/vod`); - await clickCategoryByName(page, 'Hero Premieres'); - await clickFirstGridCard(page); - await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, { - timeout: 30_000, - }); - await page.locator('app-content-hero').waitFor({ - state: 'visible', - timeout: 30_000, - }); - await page.waitForTimeout(700); -} - -async function openXtreamSeriesSeason(page: Page): Promise { - const xtreamId = requireCapturedPlaylistId('xtreams'); - - await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/series`); - await clickCategoryByName(page, 'Hero Teams'); - await clickFirstGridCard(page); - await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, { - timeout: 30_000, - }); - await page.locator('app-season-container').waitFor({ - state: 'visible', - timeout: 30_000, - }); - await page.locator('.season-card').first().click(); - await page.locator('.episode-card, .episode-list-item').first().waitFor({ - state: 'visible', - timeout: 20_000, - }); - await page.waitForTimeout(700); -} - -async function openM3uGroupsView(page: Page): Promise { - const playlistId = requireCapturedPlaylistId('playlists'); - - await switchPlaylistFromHeader(page, 'v020-demo'); - await page.waitForURL(new RegExp(`/workspace/playlists/${playlistId}/`), { - timeout: 20_000, - }); - await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`); - await page.waitForURL(/\/workspace\/playlists\/[^/]+\/groups/, { - timeout: 20_000, - }); - await page.locator('.group-nav-item').first().waitFor({ - state: 'visible', - timeout: 20_000, - }); - await page.locator('.group-nav-item').first().click(); - await page.locator('[data-test-id="channel-item"]').first().click(); - await page.waitForTimeout(1_000); -} - -async function switchPlaylistFromHeader( - page: Page, - title: string -): Promise { - await page.locator('app-playlist-switcher .playlist-switcher-trigger').click(); - - const item = page - .locator('.cdk-overlay-pane .playlist-item') - .filter({ hasText: title }) - .first(); - - await item.waitFor({ state: 'visible', timeout: 20_000 }); - await item.click(); - await page.locator('.cdk-overlay-pane .playlist-item').first().waitFor({ - state: 'detached', - timeout: 10_000, - }); -} - -async function clickCategoryByName( - page: Page, - categoryName: string -): Promise { - const category = page - .locator('app-workspace-context-panel .category-item') - .filter({ hasText: categoryName }) - .first(); - - try { - await category.waitFor({ state: 'visible', timeout: 30_000 }); - } catch (error) { - await writeDebugScreenshot(page, `category-${slugifyForFile(categoryName)}`); - const diagnostics = await page.evaluate(() => ({ - bodyText: document.body.innerText.slice(0, 2500), - categories: Array.from( - document.querySelectorAll( - 'app-workspace-context-panel .category-item' - ) - ).map((element) => ({ - id: element.getAttribute('data-category-id'), - text: (element.textContent ?? '').trim(), - })), - title: document.title, - url: location.href, - })); - - console.error( - `[capture] Category ${categoryName} was not visible: ${JSON.stringify( - diagnostics, - null, - 2 - )}` - ); - throw error; - } - await category.click(); - await page.waitForTimeout(600); -} - -async function clickFirstGridCard(page: Page): Promise { - const card = page.locator('.category-content-layout mat-card').first(); - - await card.waitFor({ state: 'visible', timeout: 30_000 }); - await card.click(); -} - -function requireCapturedPlaylistId( - provider: 'playlists' | 'xtreams' -): string { - const id = - provider === 'playlists' - ? capturedM3uPlaylistId - : capturedXtreamPlaylistId; - - if (!id) { - throw new Error(`Could not resolve captured ${provider} playlist id.`); - } - - return id; -} - -function playlistIdFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { - const match = url.match(new RegExp(`/workspace/${provider}/([^/]+)`)); - - if (!match?.[1]) { - throw new Error(`Could not resolve ${provider} playlist id from URL ${url}`); - } - - return match[1]; -} - -async function clickHrefSuffix(page: Page, suffix: string): Promise { - const link = page.locator(`a[href$="${suffix}"]`).first(); - - await link.waitFor({ state: 'attached', timeout: 20_000 }); - await link.click({ force: true }); - await page.waitForTimeout(500); -} - -async function captureCurrentView( - page: Page, - theme: ThemeName, - slug: ScreenshotSlug -): Promise { - const originalPath = screenshotPath('original', theme, slug); - - mkdirSync(path.dirname(originalPath), { recursive: true }); - await prepareForScreenshot(page); - await page.screenshot({ - path: originalPath, - type: 'png', - fullPage: slug === 'dashboard', - scale: 'css', - }); - - await createDesignedCopy( - page, - originalPath, - screenshotPath('designed', theme, slug), - { - ...captureTargets.find((target) => target.slug === slug)!, - theme, - } - ); -} - -async function prepareForScreenshot(page: Page): Promise { - await page.mouse.move(viewport.width - 32, viewport.height - 32); - await page.keyboard.press('Escape').catch(() => undefined); - await waitForTransientUiToSettle(page); - await page - .locator('.mat-mdc-tooltip, .mdc-tooltip, .mat-tooltip-panel') - .first() - .waitFor({ state: 'detached', timeout: 1_500 }) - .catch(() => undefined); - await page.waitForTimeout(200); -} - -async function writeDebugScreenshot(page: Page, name: string): Promise { - const debugPath = path.join(outputRoot, 'debug', `${name}.png`); - mkdirSync(path.dirname(debugPath), { recursive: true }); - await page.screenshot({ path: debugPath, fullPage: true, type: 'png' }); -} - -function screenshotPath( - variant: 'designed' | 'original', - theme: ThemeName, - slug: ScreenshotSlug -): string { - return path.join(outputRoot, variant, theme, `${slug}.png`); -} - -async function createDesignedCopy( - page: Page, - inputPath: string, - outputPath: string, - options: CaptureTarget & { theme: ThemeName } -): Promise { - mkdirSync(path.dirname(outputPath), { recursive: true }); - - const canvasWidth = 1920; - const canvasHeight = 1080; - const screenshotWidth = 1460; - const screenshotHeight = 822; - const left = 360; - const top = 170; - const palette = - options.theme === 'dark' - ? { - bg1: '#090b12', - bg2: '#172235', - accent: '#49c6e5', - text: '#f8fafc', - muted: '#a7b4c8', - } - : { - bg1: '#eef2f7', - bg2: '#dce7ef', - accent: '#2563eb', - text: '#111827', - muted: '#475569', - }; - const dataUrl = await page.evaluate( - async ({ - inputDataUrl, - options, - palette, - canvasWidth, - canvasHeight, - screenshotWidth, - screenshotHeight, - left, - top, - }) => { - const image = await loadCanvasImage(inputDataUrl); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - if (!context) { - throw new Error('Canvas 2D context is unavailable.'); - } - - canvas.width = canvasWidth; - canvas.height = canvasHeight; - - const background = context.createLinearGradient( - 0, - 0, - canvasWidth, - canvasHeight - ); - background.addColorStop(0, palette.bg1); - background.addColorStop(1, palette.bg2); - context.fillStyle = background; - context.fillRect(0, 0, canvasWidth, canvasHeight); - - context.globalAlpha = 0.16; - context.fillStyle = palette.accent; - context.beginPath(); - context.arc(1710, 140, 260, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 0.12; - context.beginPath(); - context.arc(160, 930, 340, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 0.28; - context.strokeStyle = palette.accent; - context.lineWidth = 4; - context.beginPath(); - context.moveTo(0, 810); - context.bezierCurveTo(420, 650, 720, 1040, 1220, 840); - context.bezierCurveTo(1460, 744, 1720, 610, 1920, 720); - context.stroke(); - context.globalAlpha = 1; - - context.fillStyle = palette.accent; - context.font = '800 28px Arial, Helvetica, sans-serif'; - context.fillText(options.kicker.toUpperCase(), 110, 150); - context.fillStyle = palette.text; - context.font = '900 72px Arial, Helvetica, sans-serif'; - context.fillText(options.title, 110, 220); - context.fillStyle = palette.muted; - context.font = '600 28px Arial, Helvetica, sans-serif'; - context.fillText( - 'IPTVnator v0.20 release preview using fictional demo content', - 110, - 278 - ); - - context.save(); - context.shadowColor = - options.theme === 'dark' - ? 'rgba(0, 0, 0, 0.42)' - : 'rgba(0, 0, 0, 0.18)'; - context.shadowBlur = 76; - context.shadowOffsetY = 34; - context.globalAlpha = options.theme === 'dark' ? 0.78 : 0.9; - context.fillStyle = options.theme === 'dark' ? '#020617' : '#ffffff'; - drawRoundRect( - context, - left - 18, - top - 18, - screenshotWidth + 36, - screenshotHeight + 36, - 34 - ); - context.fill(); - context.restore(); - - context.save(); - drawRoundRect(context, left, top, screenshotWidth, screenshotHeight, 18); - context.clip(); - context.drawImage( - image, - 0, - 0, - image.naturalWidth, - image.naturalHeight, - left, - top, - screenshotWidth, - screenshotHeight - ); - context.restore(); - - context.strokeStyle = hexToRgba(palette.accent, 0.35); - context.lineWidth = 2; - drawRoundRect( - context, - left - 18, - top - 18, - screenshotWidth + 36, - screenshotHeight + 36, - 34 - ); - context.stroke(); - - return canvas.toDataURL('image/png'); - - function loadCanvasImage(src: string): Promise { - return new Promise((resolve, reject) => { - const candidate = new Image(); - candidate.onload = () => resolve(candidate); - candidate.onerror = () => - reject(new Error('Could not load screenshot image.')); - candidate.src = src; - }); - } - - function drawRoundRect( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number - ): void { - context.beginPath(); - context.moveTo(x + radius, y); - context.lineTo(x + width - radius, y); - context.quadraticCurveTo(x + width, y, x + width, y + radius); - context.lineTo(x + width, y + height - radius); - context.quadraticCurveTo( - x + width, - y + height, - x + width - radius, - y + height - ); - context.lineTo(x + radius, y + height); - context.quadraticCurveTo(x, y + height, x, y + height - radius); - context.lineTo(x, y + radius); - context.quadraticCurveTo(x, y, x + radius, y); - context.closePath(); - } - - function hexToRgba(hex: string, alpha: number): string { - const normalized = hex.replace('#', ''); - const red = Number.parseInt(normalized.slice(0, 2), 16); - const green = Number.parseInt(normalized.slice(2, 4), 16); - const blue = Number.parseInt(normalized.slice(4, 6), 16); - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; - } - }, - { - inputDataUrl: fileToDataUrl(inputPath), - options, - palette, - canvasWidth, - canvasHeight, - screenshotWidth, - screenshotHeight, - left, - top, - } - ); - - writeDataUrlPng(outputPath, dataUrl); -} - -async function createHeroImage(page: Page): Promise { - const dashboard = screenshotPath('original', 'dark', 'dashboard'); - const vod = screenshotPath('original', 'dark', 'xtream-vod-details'); - const series = screenshotPath('original', 'light', 'xtream-series-season-open'); - mkdirSync(path.dirname(heroOutputPath), { recursive: true }); - - const dataUrl = await page.evaluate( - async ({ dashboard, vod, series }) => { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - if (!context) { - throw new Error('Canvas 2D context is unavailable.'); - } - - canvas.width = 1600; - canvas.height = 900; - - const background = context.createLinearGradient(0, 0, 1600, 900); - background.addColorStop(0, '#06111f'); - background.addColorStop(0.52, '#111827'); - background.addColorStop(1, '#233b54'); - context.fillStyle = background; - context.fillRect(0, 0, 1600, 900); - - context.globalAlpha = 0.18; - context.fillStyle = '#49c6e5'; - context.beginPath(); - context.arc(1380, 120, 240, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 0.15; - context.fillStyle = '#f2a65a'; - context.beginPath(); - context.arc(170, 790, 290, 0, Math.PI * 2); - context.fill(); - context.globalAlpha = 1; - - context.fillStyle = '#49c6e5'; - context.font = '800 30px Arial, Helvetica, sans-serif'; - context.fillText('IPTVNATOR V0.20', 86, 138); - context.fillStyle = '#f8fafc'; - context.font = '900 78px Arial, Helvetica, sans-serif'; - context.fillText('Unified UI.', 86, 224); - context.fillText('Content-first.', 86, 306); - context.fillStyle = '#b9c4d6'; - context.font = '600 28px Arial, Helvetica, sans-serif'; - context.fillText( - 'Dashboard, search, favorites, history, and settings reworked across M3U, Xtream, and Stalker.', - 88, - 366 - ); - - await drawFramedImage(context, dashboard, 650, 130, 780, 438, 26, '#020617'); - await drawFramedImage(context, vod, 500, 505, 520, 292, 22, '#020617'); - await drawFramedImage(context, series, 990, 565, 430, 242, 20, '#f8fafc'); - - return canvas.toDataURL('image/png'); - - async function drawFramedImage( - context: CanvasRenderingContext2D, - src: string, - x: number, - y: number, - width: number, - height: number, - radius: number, - frameColor: string - ): Promise { - const image = await loadCanvasImage(src); - - context.save(); - context.shadowColor = 'rgba(0, 0, 0, 0.45)'; - context.shadowBlur = 60; - context.shadowOffsetY = 28; - context.globalAlpha = frameColor === '#f8fafc' ? 0.94 : 0.82; - context.fillStyle = frameColor; - drawRoundRect(context, x, y, width, height, radius); - context.fill(); - context.restore(); - - context.save(); - drawRoundRect(context, x, y, width, height, radius); - context.clip(); - context.drawImage( - image, - 0, - 0, - image.naturalWidth, - image.naturalHeight, - x, - y, - width, - height - ); - context.restore(); - } - - function loadCanvasImage(src: string): Promise { - return new Promise((resolve, reject) => { - const candidate = new Image(); - candidate.onload = () => resolve(candidate); - candidate.onerror = () => - reject(new Error('Could not load screenshot image.')); - candidate.src = src; - }); - } - - function drawRoundRect( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number - ): void { - context.beginPath(); - context.moveTo(x + radius, y); - context.lineTo(x + width - radius, y); - context.quadraticCurveTo(x + width, y, x + width, y + radius); - context.lineTo(x + width, y + height - radius); - context.quadraticCurveTo( - x + width, - y + height, - x + width - radius, - y + height - ); - context.lineTo(x + radius, y + height); - context.quadraticCurveTo(x, y + height, x, y + height - radius); - context.lineTo(x, y + radius); - context.quadraticCurveTo(x, y, x + radius, y); - context.closePath(); - } - }, - { - dashboard: fileToDataUrl(dashboard), - vod: fileToDataUrl(vod), - series: fileToDataUrl(series), - } - ); - - writeDataUrlPng(heroOutputPath, dataUrl); -} - -async function verifyExpectedAssets(): Promise { - const expected = [ - heroOutputPath, - ...(['dark', 'light'] as ThemeName[]).flatMap((theme) => - captureTargets.flatMap((target) => [ - screenshotPath('original', theme, target.slug), - screenshotPath('designed', theme, target.slug), - ]) - ), - ]; - - for (const filePath of expected) { - if (!fileExists(filePath)) { - throw new Error(`Expected screenshot asset was not created: ${filePath}`); - } - - const dimensions = readPngDimensions(filePath); - if (!dimensions.width || !dimensions.height) { - throw new Error(`Screenshot asset has invalid dimensions: ${filePath}`); - } - } -} - -async function isHealthy(url: string): Promise { - try { - const response = await fetch(url); - return response.ok; - } catch { - return false; - } -} - -async function waitForHealthy(url: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await isHealthy(url)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - throw new Error(`Timed out waiting for ${url}`); -} - -function fileExists(filePath: string): boolean { - try { - accessSync(filePath); - return true; - } catch { - return false; - } -} - -function fileToDataUrl(filePath: string): string { - return `data:image/png;base64,${readFileSync(filePath).toString('base64')}`; -} - -function writeDataUrlPng(filePath: string, dataUrl: string): void { - const marker = 'data:image/png;base64,'; - - if (!dataUrl.startsWith(marker)) { - throw new Error(`Expected PNG data URL for ${filePath}`); - } - - writeFileSync(filePath, Buffer.from(dataUrl.slice(marker.length), 'base64')); -} - -function readPngDimensions(filePath: string): { height: number; width: number } { - const buffer = readFileSync(filePath); - const pngSignature = '89504e470d0a1a0a'; - - if (buffer.subarray(0, 8).toString('hex') !== pngSignature) { - throw new Error(`Expected PNG signature in ${filePath}`); - } - - return { - width: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20), - }; -} - -function slugifyForFile(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); From c0e065da176948af2ef20965ba0212d12cae533c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:49:08 +0200 Subject: [PATCH 05/12] fix(window-controls): derive window-state pushes from events so controls reappear after fullscreen (#1178) The custom minimize/maximize/close controls stayed hidden forever after leaving HTML-element (video player) fullscreen on Windows: window state was polled at event time, and isFullScreen() can still report the pre-transition value while 'leave-full-screen' fires, leaving a stale push with no later event to correct it. The same polling on the companion flag cleared isMaximized during fullscreen transitions and stuck the maximize/restore glyph on the wrong icon. attachWindowStateEvents now seeds the state once at window creation and each event patches only the flag it names, sending a copy per push. The enter/leave-html-full-screen variants are wired too. Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail against the old implementation) and an Electron E2E case that toggles HTML element fullscreen and asserts the controls come back. --- .changes/window-controls-fullscreen-exit.md | 9 + .../src/window-controls.e2e.ts | 61 +++++ .../src/app/app-window-state.spec.ts | 242 ++++++++++++++++++ apps/electron-backend/src/app/app.spec.ts | 6 + apps/electron-backend/src/app/app.ts | 46 +++- docs/architecture/workspace-shell.md | 23 +- 6 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 .changes/window-controls-fullscreen-exit.md create mode 100644 apps/electron-backend/src/app/app-window-state.spec.ts diff --git a/.changes/window-controls-fullscreen-exit.md b/.changes/window-controls-fullscreen-exit.md new file mode 100644 index 000000000..65607da9b --- /dev/null +++ b/.changes/window-controls-fullscreen-exit.md @@ -0,0 +1,9 @@ +--- +type: fix +area: window-controls +--- + +On Windows, the minimize, maximize and close buttons disappeared for good after +leaving fullscreen video playback — the only way to get them back was to restart +the app. They now reappear as soon as you exit fullscreen, and the maximize +button no longer gets stuck on the wrong icon afterwards. diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts index 5d4f36c99..d65b463bd 100644 --- a/apps/electron-backend-e2e/src/window-controls.e2e.ts +++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts @@ -103,6 +103,67 @@ test.describe('Custom window controls', () => { } }); + test('@electron controls reappear after leaving HTML element fullscreen', async ({ + dataDir, + }) => { + test.skip( + isWindowManagerlessCi, + 'xvfb on Linux CI has no window manager' + ); + const app = await launchElectronApp(dataDir); + + try { + const controls = app.mainWindow.locator('app-window-controls'); + await expect(controls).not.toHaveClass(/is-hidden/); + + // The video players fullscreen their player root through the + // HTML element-fullscreen API. That API needs transient user + // activation, so trigger it from a real click on a temporary + // overlay instead of calling it directly from evaluate(). + await app.mainWindow.evaluate(() => { + const overlay = document.createElement('div'); + overlay.id = 'e2e-fullscreen-trigger'; + overlay.style.cssText = + 'position: fixed; inset: 0; z-index: 2147483647;'; + overlay.addEventListener('click', () => { + overlay.remove(); + void document.documentElement.requestFullscreen(); + }); + document.body.append(overlay); + }); + await app.mainWindow.locator('#e2e-fullscreen-trigger').click(); + + const isHtmlFullScreen = () => + app.mainWindow.evaluate( + () => document.fullscreenElement !== null + ); + + await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe( + true + ); + await expect(controls).toHaveClass(/is-hidden/, { + timeout: 10_000, + }); + + await app.mainWindow.evaluate(() => document.exitFullscreen()); + await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe( + false + ); + + // Regression: the exit push must not carry a stale + // isFullScreen=true read mid-transition — the controls have to + // come back once fullscreen is left. + await expect(controls).not.toHaveClass(/is-hidden/, { + timeout: 10_000, + }); + await expect( + app.mainWindow.getByTestId('window-minimize') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + test('@electron minimize button minimizes the window', async ({ dataDir, }) => { diff --git a/apps/electron-backend/src/app/app-window-state.spec.ts b/apps/electron-backend/src/app/app-window-state.spec.ts new file mode 100644 index 000000000..f550142df --- /dev/null +++ b/apps/electron-backend/src/app/app-window-state.spec.ts @@ -0,0 +1,242 @@ +/** + * Regression coverage for the WINDOW:STATE_CHANGED pushes that drive the + * renderer-drawn window controls on Windows/Linux. + * + * The handlers must never re-read window state at event time: on Windows, + * isFullScreen() can still report true while 'leave-full-screen' fires for + * HTML-element fullscreen (video player) exits, and isMaximized() reports + * false while the window is fullscreen. Polling pushed a stale + * `isFullScreen: true` on exit — leaving the custom window controls hidden + * forever — and cleared the companion `isMaximized`, sticking the + * maximize/restore glyph on the wrong icon. Instead the state is seeded once + * at attach time and each event patches only the flag it names. + */ + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp'), + isPackaged: false, + isReady: jest.fn(() => false), + on: jest.fn(), + }, + BrowserWindow: jest.fn(), + Menu: { + buildFromTemplate: jest.fn(), + }, + screen: { + getPrimaryDisplay: jest.fn(), + }, + session: { + defaultSession: { + clearStorageData: jest.fn(), + }, + }, + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('./services/store.service', () => ({ + store: { + get: jest.fn(), + set: jest.fn(), + }, + WINDOW_BOUNDS: 'windowBounds', +})); + +import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces'; +import App from './app'; + +type MockStateWindow = { + isDestroyed: jest.Mock; + isFullScreen: jest.Mock; + isMaximized: jest.Mock; + on: jest.Mock void]>; + webContents: { + send: jest.Mock; + }; +}; + +function createMockStateWindow(): MockStateWindow { + return { + isDestroyed: jest.fn().mockReturnValue(false), + isFullScreen: jest.fn().mockReturnValue(false), + isMaximized: jest.fn().mockReturnValue(false), + on: jest.fn void]>(), + webContents: { + send: jest.fn(), + }, + }; +} + +function attachWindowStateEvents(win: MockStateWindow): void { + ( + App as unknown as { + attachWindowStateEvents: (win: MockStateWindow) => void; + } + ).attachWindowStateEvents(win); +} + +function fireWindowEvent(win: MockStateWindow, eventName: string): void { + const handlers = win.on.mock.calls + .filter(([name]) => name === eventName) + .map(([, handler]) => handler); + + expect(handlers).toHaveLength(1); + handlers[0](); +} + +describe('window state change pushes', () => { + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); + + function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + } + + beforeEach(() => { + setProcessPlatform('win32'); + }); + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + }); + + it('registers no window-state listeners on macOS', () => { + setProcessPlatform('darwin'); + const win = createMockStateWindow(); + + attachWindowStateEvents(win); + + expect(win.on).not.toHaveBeenCalled(); + }); + + it('pushes isFullScreen=false on leave-full-screen even while the window still reports fullscreen', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + // Windows still reports the old state while the transition runs. + win.isFullScreen.mockReturnValue(true); + + fireWindowEvent(win, 'leave-full-screen'); + + expect(win.webContents.send).toHaveBeenCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: false } + ); + }); + + it('pushes isFullScreen=false on leave-html-full-screen even while the window still reports fullscreen', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + win.isFullScreen.mockReturnValue(true); + + fireWindowEvent(win, 'leave-html-full-screen'); + + expect(win.webContents.send).toHaveBeenCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: false } + ); + }); + + it('pushes isFullScreen=true on enter events before the window reports fullscreen', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + win.isFullScreen.mockReturnValue(false); + + fireWindowEvent(win, 'enter-full-screen'); + fireWindowEvent(win, 'enter-html-full-screen'); + + expect(win.webContents.send).toHaveBeenCalledTimes(2); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: true } + ); + }); + + it('derives isMaximized from the maximize/unmaximize events instead of polling', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + + win.isMaximized.mockReturnValue(false); + fireWindowEvent(win, 'maximize'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: false } + ); + + win.isMaximized.mockReturnValue(true); + fireWindowEvent(win, 'unmaximize'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: false, isFullScreen: false } + ); + }); + + it('keeps the maximized flag across a fullscreen round-trip while the window misreports it', () => { + const win = createMockStateWindow(); + win.isMaximized.mockReturnValue(true); + attachWindowStateEvents(win); + // Windows reports a fullscreen window as not maximized, so polling + // the companion flag here would clear it and leave the glyph on + // "maximize" after the controls come back. + win.isMaximized.mockReturnValue(false); + + fireWindowEvent(win, 'enter-html-full-screen'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: true } + ); + + fireWindowEvent(win, 'leave-html-full-screen'); + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: false } + ); + }); + + it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => { + const win = createMockStateWindow(); + win.isFullScreen.mockReturnValue(true); + attachWindowStateEvents(win); + win.isFullScreen.mockReturnValue(false); + + fireWindowEvent(win, 'maximize'); + + expect(win.webContents.send).toHaveBeenLastCalledWith( + WINDOW_STATE_CHANGED, + { isMaximized: true, isFullScreen: true } + ); + }); + + it('sends an independent payload per push', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + + fireWindowEvent(win, 'enter-full-screen'); + const firstPayload = win.webContents.send.mock.calls[0][1]; + fireWindowEvent(win, 'leave-full-screen'); + + // The renderer must not see the earlier push mutate under it. + expect(firstPayload).toEqual({ + isMaximized: false, + isFullScreen: true, + }); + }); + + it('skips pushes for destroyed windows', () => { + const win = createMockStateWindow(); + attachWindowStateEvents(win); + win.isDestroyed.mockReturnValue(true); + + fireWindowEvent(win, 'leave-full-screen'); + + expect(win.webContents.send).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 639d86d48..bfd5a24a2 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -55,6 +55,10 @@ import { store } from './services/store.service'; type MockMainWindow = { center: jest.Mock; getNormalBounds: jest.Mock; + // Read once by attachWindowStateEvents to seed the tracked window + // state; only reached off macOS, where the custom controls exist. + isFullScreen: jest.Mock; + isMaximized: jest.Mock; loadFile: jest.Mock, [string]>; loadURL: jest.Mock, [string]>; on: jest.Mock void]>; @@ -72,6 +76,8 @@ function createMockMainWindow(): MockMainWindow { return { center: jest.fn(), getNormalBounds: jest.fn().mockReturnValue({}), + isFullScreen: jest.fn().mockReturnValue(false), + isMaximized: jest.fn().mockReturnValue(false), loadFile: jest.fn, [string]>().mockResolvedValue(), loadURL: jest.fn, [string]>().mockResolvedValue(), on: jest.fn void]>(), diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 00d7d940b..7aef4a9ae 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,5 +1,8 @@ import { app, BrowserWindow, Menu, screen, session, shell } from 'electron'; -import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces'; +import { + ElectronBridgeWindowState, + WINDOW_STATE_CHANGED, +} from '@iptvnator/shared/interfaces'; import { join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { rendererAppName, rendererAppPort } from './constants'; @@ -318,21 +321,44 @@ export default class App { return; } - const sendWindowState = () => { + // Window state is never re-read at event time: on Windows both + // isFullScreen() and isMaximized() can still report the + // pre-transition value while the matching event fires (notably for + // HTML-element fullscreen, i.e. the video player). Since the + // renderer replaces both flags on every push and no later event + // corrects a stale one, polling left the controls hidden forever + // after leaving fullscreen — and, for the companion flag, the + // maximize/restore glyph stuck on the wrong icon. + // + // Instead the state is seeded once here (window creation, so no + // transition is in flight) and each event patches only the flag it + // names. + const state: ElectronBridgeWindowState = { + isMaximized: win.isMaximized(), + isFullScreen: win.isFullScreen(), + }; + + const push = (patch: Partial) => { + Object.assign(state, patch); + if (win.isDestroyed()) { return; } - win.webContents.send(WINDOW_STATE_CHANGED, { - isMaximized: win.isMaximized(), - isFullScreen: win.isFullScreen(), - }); + // A copy per push: the renderer must not observe later + // mutations of the tracked state. + win.webContents.send(WINDOW_STATE_CHANGED, { ...state }); }; - win.on('maximize', sendWindowState); - win.on('unmaximize', sendWindowState); - win.on('enter-full-screen', sendWindowState); - win.on('leave-full-screen', sendWindowState); + win.on('maximize', () => push({ isMaximized: true })); + win.on('unmaximize', () => push({ isMaximized: false })); + // The html variants cover HTML-element fullscreen; not every + // platform/trigger emits both pairs, and duplicate pushes with the + // same payload are harmless. + win.on('enter-full-screen', () => push({ isFullScreen: true })); + win.on('enter-html-full-screen', () => push({ isFullScreen: true })); + win.on('leave-full-screen', () => push({ isFullScreen: false })); + win.on('leave-html-full-screen', () => push({ isFullScreen: false })); } private static initMainWindow() { diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 5a0b6c9c0..c0f4ffbb3 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -255,10 +255,25 @@ handlers in `apps/electron-backend/src/app/events/window.events.ts`): WebContents. Close goes through `win.close()` so the existing window-bounds persistence in `app.ts` still runs. 2. `WINDOW:STATE_CHANGED` is pushed main → renderer on - maximize/unmaximize/enter-full-screen/leave-full-screen so the - maximize/restore glyph stays correct for externally triggered changes - (double-click on a drag region, OS snap, F11). The controls hide - themselves while the window is fullscreen. + maximize/unmaximize and on the fullscreen events — + `enter/leave-full-screen` plus the `enter/leave-html-full-screen` + variants emitted for HTML-element fullscreen (video player + fullscreen) — so the maximize/restore glyph stays correct for + externally triggered changes (double-click on a drag region, OS + snap, F11). The controls hide themselves while the window is + fullscreen. + + Window state is **never re-read at event time**. `attachWindowStateEvents` + seeds `{ isMaximized, isFullScreen }` once at window creation and each + event patches only the flag it names; every push carries a copy of that + tracked state. On Windows both getters can still report the + pre-transition value while the matching event fires — `isFullScreen()` + stays `true` during an HTML fullscreen exit, and `isMaximized()` reads + `false` while the window is fullscreen. Because the renderer replaces + both flags on every push and no later event corrects a stale one, + polling left the controls hidden forever after leaving fullscreen and + stuck the maximize/restore glyph on the wrong icon. Regression coverage: + `app-window-state.spec.ts` and `window-controls.e2e.ts`. Layout integration: From a5bb8dc25ca1284320d93f14e17de58687984602 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:55:44 +0200 Subject: [PATCH 06/12] fix(tmdb): series cast was the latest season only, not the show (#1242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tmdb): series cast was the latest season only, not the show TMDB documents a TV id's `credits` as the credits of the LATEST SEASON. We requested exactly that and rendered it as "the cast", so every long-running show lost every regular who had left: The Boys showed whoever appears in the newest season, not the ensemble. The TV details request now also appends `aggregate_credits`, which spans the whole run — but per TMDB omits the newest season, so neither payload alone is the cast. `unifiedTvCast` unions them: whole-run billing order first, then people who appear only in the newest season, deduplicated by person id. Characters come from the aggregate `roles[]` shape. Deliberately NO cache-key bump. Rows cached before this simply lack `aggregate_credits` and keep the previous behaviour until they expire, which avoids invalidating every user's details cache twice — the roadmap schedules one consolidated bump once the remaining append_to_response additions (images, certifications, alternative_titles) land together. Movies are untouched: /movie/{id} has no aggregate_credits and its `credits` is already the full cast. Tests: departed regulars retained, newest-season arrivals appended after show billing order, characters read from roles[], no duplicates across the two payloads, graceful fallback for pre-aggregate cache rows. Refs docs/architecture/tmdb-roadmap.md A2. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): reserve cast slots so newest-season arrivals survive the cap The union was appended aggregate-first and then truncated to ten, so on exactly the shows it was built for — long-running ones, where the whole-run cast alone exceeds the limit — every newest-season arrival was sliced back off. The original fixture had two aggregate members and could not catch it. unifiedTvCast now holds back up to three slots for the top-billed arrivals instead of appending them where the cap discards them, and gives the slots back when nobody is new. Tests: a 12-member aggregate plus two arrivals keeps both arrivals and top billing; an aggregate with no arrivals still gets all ten slots. Co-Authored-By: Claude Opus 4.8 * test(tmdb): split the series-cast suite out of the merge spec The merge conflict resolution put both new describes back into tmdb-merge.spec.ts, pushing it to 499 lines — past the 400-line max-lines cap. The aggregate-credits suite moves to its own file. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): stop the cast union from shrinking, and bound what it caches Three follow-ups from a review pass over the aggregate-credits union: - The reserved arrival slots were subtracted from the aggregate even when the aggregate was shorter than the cap, so a show with four regulars and five newcomers returned seven names instead of nine. The reservation is a floor for arrivals now, not a quota. - An aggregate member's character came from the first role with any text, so a one-episode cameo could outrank the part the actor is known for. Pick the role with the most episodes. - aggregate_credits carries a show's whole-run cast AND crew, and details payloads are cached verbatim — orders of magnitude of JSON for a list the merge truncates to ten people. Cache the billing-order prefix and drop the crew nothing reads. Extracting the people-related helpers into tmdb-credits.ts keeps tmdb-merge.ts under the line cap. Co-Authored-By: Claude Opus 4.8 * fix(tmdb): keep the aggregate ids the arrival check depends on Trimming the cached cast to its top 40 broke the property it was supposed to preserve: `known` is built from the aggregate ids, so a returning actor billed below the cut read as a new arrival on the cached path and took a reserved slot. The same show then showed a different top ten on its second open than on its first. Keep the whole cast, and cut the two things nothing reads instead: the aggregate crew, and every `roles[]` entry except the one the merge picks (most episodes). A merge over the trimmed payload now provably returns what a merge over the full one does — covered by a test that runs both. Also points CLAUDE.md and the doc's module table at tmdb-credits.ts, where the credit helpers now live. Co-Authored-By: Claude Opus 4.8 * docs(tmdb): add the release note for the series-cast fix Co-Authored-By: Claude Opus 4.8 * fix(tmdb): let the cache trim reuse the merge's own role choice The trim picked the role with the most episodes; the merge picks the NAMED role with the most episodes. TMDB uses unnamed roles for uncredited appearances, so a member whose blank role outranked their real one lost their character on every render after the first. Both now call pickAggregateRole, which is the point — two copies of the same choice are what let them drift. Also adds a test pinning the property the earlier truncation defect broke: the displayed cast is the cap or everyone available, whichever is smaller. Which people make the cut at the cap is the reservation's job and is deliberate; the count is not negotiable. Co-Authored-By: Claude Opus 4.8 * docs(tmdb): state the aggregate-credits contract as TMDB actually words it TMDB describes the endpoint in one sentence that contradicts itself: "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." The doc and the code comment asserted the first half as settled fact. The union never depended on that reading — arrivals are a set difference, so under "whole run" they are simply empty — but the comment implied an assumption the code does not make. Say what TMDB says, note the ambiguity, and note why either reading is safe. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changes/tmdb-series-cast.md | 9 + CLAUDE.md | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 16 +- .../services/src/lib/tmdb/tmdb-api.service.ts | 9 +- .../src/lib/tmdb/tmdb-cache-payload.spec.ts | 175 +++++++++++++ .../src/lib/tmdb/tmdb-cache-payload.ts | 47 ++++ libs/services/src/lib/tmdb/tmdb-credits.ts | 197 +++++++++++++++ .../src/lib/tmdb/tmdb-enrichment.service.ts | 5 +- .../lib/tmdb/tmdb-merge-series-cast.spec.ts | 236 ++++++++++++++++++ libs/services/src/lib/tmdb/tmdb-merge.spec.ts | 8 +- libs/services/src/lib/tmdb/tmdb-merge.ts | 116 ++------- libs/services/src/lib/tmdb/tmdb.types.ts | 19 ++ 12 files changed, 740 insertions(+), 99 deletions(-) create mode 100644 .changes/tmdb-series-cast.md create mode 100644 libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-cache-payload.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-credits.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts diff --git a/.changes/tmdb-series-cast.md b/.changes/tmdb-series-cast.md new file mode 100644 index 000000000..037ad2f8a --- /dev/null +++ b/.changes/tmdb-series-cast.md @@ -0,0 +1,9 @@ +--- +type: fix +area: tmdb +--- + +Series detail pages now list the cast of the whole show instead of only its +newest season, so actors who left partway through stop disappearing from +long-running shows — while people who joined for the current season still +show up. diff --git a/CLAUDE.md b/CLAUDE.md index 96363b01a..a2e3b4021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -833,7 +833,7 @@ 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); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 5c30adc29..146bfa063 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -44,6 +44,8 @@ store imports): | `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | | `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | | `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | +| `tmdb-credits.ts` | People out of credit payloads: display cast, person chips, and the two-shape union a series cast needs | +| `tmdb-cache-payload.ts` | Trims a details payload before caching (aggregate roles/crew) without changing what a merge over it produces | | `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | | `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | | `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | @@ -149,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 diff --git a/libs/services/src/lib/tmdb/tmdb-api.service.ts b/libs/services/src/lib/tmdb/tmdb-api.service.ts index 8aacd83af..b2b6555b0 100644 --- a/libs/services/src/lib/tmdb/tmdb-api.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-api.service.ts @@ -90,7 +90,14 @@ export class TmdbApiService { ): Promise { return this.request( `/tv/${tmdbId}`, - { language, append_to_response: 'credits,videos,recommendations' }, + { + language, + // aggregate_credits spans the whole run; plain `credits` on + // a TV id is documented as the LATEST SEASON only, so both + // are needed to reconstruct the real cast (see tmdb-merge) + append_to_response: + 'credits,aggregate_credits,videos,recommendations', + }, apiKey ); } diff --git a/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts new file mode 100644 index 000000000..d96cfdd63 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts @@ -0,0 +1,175 @@ +import { mergeSerieInfoWithTmdb } from './tmdb-merge'; +import { trimDetailsForCache } from './tmdb-cache-payload'; +import { XtreamSerieInfo } from '@iptvnator/shared/interfaces'; +import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types'; + +describe('trimDetailsForCache', () => { + it('passes a movie payload through untouched', () => { + const movie: TmdbMovieDetails = { + id: 603, + title: 'The Matrix', + credits: { cast: [{ name: 'Keanu Reeves' }] }, + }; + + expect(trimDetailsForCache(movie)).toBe(movie); + }); + + it('keeps every aggregate cast member', () => { + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: Array.from({ length: 120 }, (_, i) => ({ + id: i, + name: `Actor ${i}`, + order: i, + })), + }, + }; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails; + + // The tail is what tells a returning actor from a new arrival + expect(trimmed.aggregate_credits?.cast).toHaveLength(120); + }); + + it('reduces roles to the character with the most episodes', () => { + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + roles: [ + { character: 'Cameo Guy', episode_count: 1 }, + { character: 'Billy Butcher', episode_count: 32 }, + ], + }, + ], + }, + }; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails; + + expect(trimmed.aggregate_credits?.cast?.[0].roles).toEqual([ + { character: 'Billy Butcher', episode_count: 32 }, + ]); + }); + + it('keeps the named role even when a blank one has more episodes', () => { + // TMDB uses unnamed roles for uncredited appearances; the merge + // skips them, so the trim must not cache one in their place + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + roles: [ + { character: '', episode_count: 40 }, + { character: 'Billy Butcher', episode_count: 32 }, + ], + }, + ], + }, + }; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails; + + expect(trimmed.aggregate_credits?.cast?.[0].roles).toEqual([ + { character: 'Billy Butcher', episode_count: 32 }, + ]); + }); + + it('drops the aggregate crew nothing reads', () => { + const details = { + id: 76479, + aggregate_credits: { + cast: [{ id: 1, name: 'Karl Urban', order: 0 }], + crew: Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Crew ${i}`, + })), + }, + } as TmdbTvDetails; + + const trimmed = trimDetailsForCache(details) as TmdbTvDetails & { + aggregate_credits?: { crew?: unknown[] }; + }; + + expect(trimmed.aggregate_credits?.crew).toBeUndefined(); + expect(trimmed.aggregate_credits?.cast).toHaveLength(1); + }); + + it('does not mutate the payload the caller still uses', () => { + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + roles: [ + { character: 'A', episode_count: 1 }, + { character: 'B', episode_count: 2 }, + ], + }, + ], + }, + }; + + trimDetailsForCache(details); + + expect(details.aggregate_credits?.cast?.[0].roles).toHaveLength(2); + }); + + it('merges a trimmed payload to the same cast as the full one', () => { + // The property that matters: the first render (network payload) and + // every later one (cached payload) must show the same people + const info = { + name: 'The Boys', + cover: '', + plot: '', + cast: '', + director: '', + genre: '', + releaseDate: '', + last_modified: '', + rating: '', + rating_5based: 0, + backdrop_path: [], + youtube_trailer: '', + episode_run_time: '', + category_id: '1', + } as XtreamSerieInfo; + const details: TmdbTvDetails = { + id: 76479, + aggregate_credits: { + cast: Array.from({ length: 60 }, (_, i) => ({ + id: i, + name: `Regular ${i}`, + order: i, + roles: [{ character: `Role ${i}`, episode_count: 10 }], + })), + }, + credits: { + // A long-serving actor billed 50th who is still in the + // newest season — dropping the aggregate tail would have + // promoted them into a reserved arrival slot + cast: [{ id: 50, name: 'Regular 50', order: 0 }], + }, + }; + + const fromFull = mergeSerieInfoWithTmdb(info, details); + const fromCache = mergeSerieInfoWithTmdb( + info, + trimDetailsForCache(details) as TmdbTvDetails + ); + + expect(fromCache.cast).toBe(fromFull.cast); + expect(fromCache.cast).not.toContain('Regular 50'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-cache-payload.ts b/libs/services/src/lib/tmdb/tmdb-cache-payload.ts new file mode 100644 index 000000000..7289855a8 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache-payload.ts @@ -0,0 +1,47 @@ +import { pickAggregateRole } from './tmdb-credits'; +import { TmdbDetails, TmdbTvDetails } from './tmdb.types'; + +/** + * Trims a details payload before it is cached. + * + * `aggregate_credits` spans a show's whole run: a crew list that can reach + * into the thousands, plus every cast member carrying one `roles[]` entry + * per character they ever played. Caching that verbatim grows + * `tmdb_metadata` (and the PWA's in-memory map) by orders of magnitude for + * data nothing reads. + * + * What goes is chosen so that a merge over the trimmed payload produces + * exactly what a merge over the full one would: + * + * - the whole cast stays, ids included. It is the only record of who was + * in the show before the newest season, so dropping the tail would make + * a returning actor read as a new arrival on the cached path — the + * displayed cast would then differ between the first render and every + * later one. + * - `roles[]` keeps only the entry the merge itself would pick — the + * named character with the most episodes, chosen by the very same + * function, so the two can never drift apart. + * - `crew` goes entirely: series credits come from `created_by`, so + * nothing reads it. + * + * Everything else is passed through untouched — payloads still hold + * whatever a later phase might want without a refetch. + */ +export function trimDetailsForCache(details: TmdbDetails): TmdbDetails { + const aggregate = (details as TmdbTvDetails).aggregate_credits; + if (!aggregate) { + return details; + } + + const cast = (aggregate.cast ?? []).map((member) => { + const roles = member.roles ?? []; + if (roles.length <= 1) { + return member; + } + // The merge's own choice, not a second one that could differ + const main = pickAggregateRole(member); + return { ...member, roles: main ? [main] : [] }; + }); + + return { ...details, aggregate_credits: { cast } } as TmdbDetails; +} diff --git a/libs/services/src/lib/tmdb/tmdb-credits.ts b/libs/services/src/lib/tmdb/tmdb-credits.ts new file mode 100644 index 000000000..66e48d672 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-credits.ts @@ -0,0 +1,197 @@ +import { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces'; +import { tmdbProfileUrl } from './tmdb-config'; +import { TmdbCastMember, TmdbCredits, TmdbTvDetails } from './tmdb.types'; + +/** + * Extraction of people from TMDB credit payloads: the display cast, the + * clickable person chips, and the two-shape union a series cast needs. + * Pure functions — the field-level merge itself lives in `tmdb-merge.ts`. + */ + +const MAX_CAST_NAMES = 10; + +/** + * Slots held back for people who appear only in the newest season. On a + * long-running show the whole-run cast alone fills the display limit, so + * without a reservation the arrivals this union exists to preserve would + * be sliced straight back off. + */ +const RESERVED_NEW_SEASON_SLOTS = 3; + +const byBillingOrder = (a: { order?: number }, b: { order?: number }) => + (a.order ?? 0) - (b.order ?? 0); + +export function limitCast(cast: TmdbCastMember[]): TmdbCastMember[] { + return cast + .slice(0, MAX_CAST_NAMES) + .filter((member) => Boolean(member.name)); +} + +export function topCast(credits: TmdbCredits | undefined): TmdbCastMember[] { + return limitCast([...(credits?.cast ?? [])].sort(byBillingOrder)); +} + +/** + * The role an aggregate member is actually known for. A returning actor + * accumulates one entry per character, so a single-episode cameo sits in + * `roles[]` next to the lead they played for ten seasons — pick by episode + * count rather than by array position. Unnamed roles are skipped: TMDB + * uses them for uncredited appearances, and they would otherwise beat a + * real character on episode count alone. + * + * Exported because the cache trim keeps exactly this role and discards + * the rest — sharing the choice is what stops a cached payload from + * displaying a different character than the payload it was written from. + */ +export function pickAggregateRole< + T extends { character?: string; episode_count?: number }, +>(member: { roles?: T[] }): T | undefined { + const named = (member.roles ?? []).filter((role) => role.character?.trim()); + if (named.length === 0) { + return undefined; + } + return named.reduce((best, role) => + (role.episode_count ?? 0) > (best.episode_count ?? 0) ? role : best + ); +} + +function aggregateCharacter(member: { + roles?: { character?: string; episode_count?: number }[]; +}): string | undefined { + return pickAggregateRole(member)?.character; +} + +/** + * The cast of a SERIES, reconstructed from the two shapes TMDB offers. + * + * `/tv/{id}` `credits` is documented as the credits of the LATEST SEASON. + * TMDB describes `aggregate_credits` in one self-contradicting sentence — + * "it does not return the newest season. Instead, it is a view of all the + * entire cast & crew for all episodes belonging to a TV show" — so it is + * either the whole run minus the newest season, or the whole run. This + * union does not care which: whoever `credits` has and `aggregate_credits` + * does not is missing from the whole-run view and is appended, and under + * the second reading that set is simply empty. Either way, long-running + * shows neither lose departed regulars nor miss new arrivals. + */ +export function unifiedTvCast(details: TmdbTvDetails): TmdbCastMember[] { + const aggregate: TmdbCastMember[] = (details.aggregate_credits?.cast ?? []) + .filter((member) => Boolean(member.name)) + .sort(byBillingOrder) + .map((member) => ({ + id: member.id, + name: member.name, + character: aggregateCharacter(member), + order: member.order, + profile_path: member.profile_path, + })); + + // Cache rows written before aggregate_credits was requested, and shows + // TMDB has no aggregate for, still work — they just keep the old cast. + if (aggregate.length === 0) { + return [...(details.credits?.cast ?? [])].sort(byBillingOrder); + } + + const known = new Set( + aggregate + .map((member) => member.id) + .filter((id): id is number => id !== undefined) + ); + + // Newest-season arrivals go last on purpose: their `order` is scoped to + // that season and would otherwise outrank show-level billing. + const arrivals = [...(details.credits?.cast ?? [])] + .filter( + (member) => + Boolean(member.name) && + (member.id === undefined || !known.has(member.id)) + ) + .sort(byBillingOrder); + + if (arrivals.length === 0) { + return aggregate; + } + + // Reserve room for the top-billed arrivals rather than appending them + // where the cap will discard them. The reservation is a floor, not a + // quota: whatever the aggregate leaves unused still goes to arrivals, + // so a short whole-run cast never shrinks the list below the cap. + const reserved = Math.min(arrivals.length, RESERVED_NEW_SEASON_SLOTS); + const fromAggregate = aggregate.slice( + 0, + Math.max(0, MAX_CAST_NAMES - reserved) + ); + return [ + ...fromAggregate, + ...arrivals.slice(0, MAX_CAST_NAMES - fromAggregate.length), + ]; +} + +export function castNames(cast: TmdbCastMember[]): string { + return cast.map((member) => member.name).join(', '); +} + +/** Cast with profile photos for the avatar chips in detail views */ +export function enrichedCast(cast: TmdbCastMember[]): TmdbEnrichedCastMember[] { + return cast.map((member) => ({ + name: member.name, + ...(member.character ? { character: member.character } : {}), + profileUrl: tmdbProfileUrl(member.profile_path), + ...(member.id ? { tmdbPersonId: member.id } : {}), + })); +} + +export function directorNames(credits: TmdbCredits | undefined): string { + return (credits?.crew ?? []) + .filter((member) => member.job === 'Director') + .map((member) => member.name) + .filter(Boolean) + .join(', '); +} + +export function creatorNames(details: TmdbTvDetails): string { + return (details.created_by ?? []) + .map((creator) => creator.name) + .filter(Boolean) + .join(', '); +} + +/** + * Directors (movies) as clickable person chips — same shape as the cast + * chips, so a director opens the same person page as an actor. Deduped by + * TMDB id to collapse the duplicate crew rows TMDB sometimes returns. + */ +export function enrichedDirectors( + credits: TmdbCredits | undefined +): TmdbEnrichedCastMember[] { + const seen = new Set(); + const directors: TmdbEnrichedCastMember[] = []; + for (const member of credits?.crew ?? []) { + if (member.job !== 'Director' || !member.name) { + continue; + } + if (member.id !== undefined) { + if (seen.has(member.id)) { + continue; + } + seen.add(member.id); + } + directors.push({ + name: member.name, + profileUrl: tmdbProfileUrl(member.profile_path), + ...(member.id ? { tmdbPersonId: member.id } : {}), + }); + } + return directors; +} + +/** Series creators as clickable person chips (TV shows have no director) */ +export function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] { + return (details.created_by ?? []) + .filter((creator) => Boolean(creator.name)) + .map((creator) => ({ + name: creator.name, + profileUrl: tmdbProfileUrl(creator.profile_path), + ...(creator.id ? { tmdbPersonId: creator.id } : {}), + })); +} diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 882f8a8b3..600448be5 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { TmdbMediaType } from '@iptvnator/shared/interfaces'; import { TmdbApiService, isTmdbNotFound } from './tmdb-api.service'; +import { trimDetailsForCache } from './tmdb-cache-payload'; import { TmdbCacheService } from './tmdb-cache.service'; import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config'; import { @@ -283,7 +284,9 @@ export class TmdbEnrichmentService { lookupKey, language, tmdbId, - payload: JSON.stringify(details), + payload: JSON.stringify( + details === null ? null : trimDetailsForCache(details) + ), }); return details; diff --git a/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts new file mode 100644 index 000000000..5afa94629 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts @@ -0,0 +1,236 @@ +import { XtreamSerieInfo } from '@iptvnator/shared/interfaces'; +import { mergeSerieInfoWithTmdb } from './tmdb-merge'; +import { TmdbTvDetails } from './tmdb.types'; + +describe('series cast (aggregate + latest season)', () => { + const info: XtreamSerieInfo = { + name: 'The Boys', + cover: '', + plot: '', + cast: '', + director: '', + genre: '', + releaseDate: '', + last_modified: '', + rating: '', + rating_5based: 0, + backdrop_path: [], + youtube_trailer: '', + episode_run_time: '', + category_id: '1', + }; + + // TMDB documents /tv/{id} `credits` as the LATEST SEASON only, and + // aggregate_credits as everything EXCEPT the newest season. + const details: TmdbTvDetails = { + id: 76479, + name: 'The Boys', + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + profile_path: '/urban.jpg', + roles: [{ character: 'Billy Butcher' }], + }, + { + id: 2, + name: 'Jack Quaid', + order: 1, + roles: [{ character: 'Hughie' }], + }, + ], + }, + credits: { + cast: [ + // Still around in the newest season + { id: 1, name: 'Karl Urban', order: 0, character: 'Butcher' }, + // Joined only in the newest season + { + id: 3, + name: 'Newcomer Person', + order: 1, + character: 'Rookie', + }, + ], + }, + }; + + it('keeps whole-run cast that the latest season dropped', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + // Jack Quaid is absent from `credits` — the old code lost him + expect(merged.cast).toContain('Jack Quaid'); + }); + + it('adds newest-season arrivals after the show billing order', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + expect(merged.cast).toBe('Karl Urban, Jack Quaid, Newcomer Person'); + }); + + it('takes the character from the aggregate roles array', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + expect(merged.tmdb_cast?.[0]).toEqual({ + name: 'Karl Urban', + character: 'Billy Butcher', + profileUrl: 'https://image.tmdb.org/t/p/w185/urban.jpg', + tmdbPersonId: 1, + }); + }); + + it('does not duplicate people present in both payloads', () => { + const merged = mergeSerieInfoWithTmdb(info, details); + expect( + merged.tmdb_cast?.filter((m) => m.name === 'Karl Urban') + ).toHaveLength(1); + }); + + it('keeps newest-season arrivals when the aggregate already fills the cap', () => { + // The case the union exists for: a long-running show whose + // whole-run cast alone exceeds the display limit + const bigAggregate = Array.from({ length: 12 }, (_, i) => ({ + id: 100 + i, + name: `Regular ${i}`, + order: i, + roles: [{ character: `Role ${i}` }], + })); + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { cast: bigAggregate }, + credits: { + cast: [ + { id: 900, name: 'Brand New Lead', order: 0 }, + { id: 901, name: 'Brand New Sidekick', order: 1 }, + ], + }, + }); + + const names = merged.tmdb_cast?.map((member) => member.name) ?? []; + expect(names).toHaveLength(10); + expect(names).toContain('Brand New Lead'); + expect(names).toContain('Brand New Sidekick'); + // Top billing survives; the reservation eats into the tail only + expect(names[0]).toBe('Regular 0'); + }); + + it('gives every slot to the aggregate when nobody is new', () => { + const bigAggregate = Array.from({ length: 12 }, (_, i) => ({ + id: 100 + i, + name: `Regular ${i}`, + order: i, + })); + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { cast: bigAggregate }, + credits: { cast: [{ id: 100, name: 'Regular 0', order: 0 }] }, + }); + + expect(merged.tmdb_cast).toHaveLength(10); + expect(merged.tmdb_cast?.[9].name).toBe('Regular 9'); + }); + + it('does not shrink the list when the whole-run cast is short', () => { + // The reservation is a floor for arrivals, not a ceiling: with four + // regulars and five newcomers all nine fit under the cap + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { + cast: Array.from({ length: 4 }, (_, i) => ({ + id: 100 + i, + name: `Regular ${i}`, + order: i, + })), + }, + credits: { + cast: Array.from({ length: 5 }, (_, i) => ({ + id: 200 + i, + name: `Newcomer ${i}`, + order: i, + })), + }, + }); + + expect(merged.tmdb_cast).toHaveLength(9); + expect(merged.cast).toContain('Newcomer 4'); + }); + + it('takes the role the actor played the most episodes of', () => { + // A one-episode cameo sits in roles[] next to the lead part + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { + cast: [ + { + id: 1, + name: 'Karl Urban', + order: 0, + roles: [ + { character: 'Cameo Guy', episode_count: 1 }, + { character: 'Billy Butcher', episode_count: 32 }, + ], + }, + ], + }, + }); + + expect(merged.tmdb_cast?.[0].character).toBe('Billy Butcher'); + }); + + it('always fills the cap when there are enough people to fill it', () => { + // The reservation decides WHO makes the cut, never how many: the + // list is the display limit or everyone available, whichever is + // smaller. The earlier defect broke exactly this. + const cast = (n: number, offset: number, label: string) => + Array.from({ length: n }, (_, i) => ({ + id: offset + i, + name: `${label} ${i}`, + order: i, + })); + + for (const [regulars, newcomers] of [ + [0, 4], + [2, 1], + [4, 5], + [7, 5], + [9, 4], + [12, 2], + [12, 0], + ]) { + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + aggregate_credits: { cast: cast(regulars, 100, 'Regular') }, + credits: { cast: cast(newcomers, 900, 'Newcomer') }, + }); + + expect({ + regulars, + newcomers, + shown: merged.tmdb_cast?.length ?? 0, + }).toEqual({ + regulars, + newcomers, + shown: Math.min(10, regulars + newcomers), + }); + } + }); + + it('falls back to plain credits when no aggregate is present', () => { + // Cache rows written before aggregate_credits was requested + const merged = mergeSerieInfoWithTmdb(info, { + id: 76479, + name: 'The Boys', + credits: { + cast: [ + { id: 3, name: 'Second Billed', order: 1 }, + { id: 1, name: 'Top Billed', order: 0 }, + ], + }, + }); + expect(merged.cast).toBe('Top Billed, Second Billed'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 084ff13df..c64af3f98 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -2,7 +2,9 @@ import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces'; import { mergeSerieInfoWithTmdb, mergeVodInfoWithTmdb } from './tmdb-merge'; import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types'; -function providerVodInfo(overrides: Partial = {}): XtreamVodInfo { +function providerVodInfo( + overrides: Partial = {} +): XtreamVodInfo { return { kinopoisk_url: '', tmdb_id: '', @@ -183,9 +185,7 @@ describe('mergeVodInfoWithTmdb', () => { expect(merged.rating).toBe(7); expect(merged.movie_image).toBe('http://provider/poster.jpg'); expect(merged.tmdb_cast).toBeUndefined(); - expect(merged.backdrop_path).toEqual([ - 'http://provider/backdrop.jpg', - ]); + expect(merged.backdrop_path).toEqual(['http://provider/backdrop.jpg']); }); it('ignores TMDB rating without votes', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index 4f309605d..13d8669c8 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -1,16 +1,25 @@ import { normalizeSeriesStatus, StalkerVodInfo, - TmdbEnrichedCastMember, TmdbMediaType, TmdbRecommendation, XtreamSerieInfo, XtreamVodInfo, } from '@iptvnator/shared/interfaces'; -import { tmdbBackdropUrl, tmdbPosterUrl, tmdbProfileUrl } from './tmdb-config'; +import { tmdbBackdropUrl, tmdbPosterUrl } from './tmdb-config'; +import { + castNames, + creatorNames, + directorNames, + enrichedCast, + enrichedCreators, + enrichedDirectors, + limitCast, + topCast, + unifiedTvCast, +} from './tmdb-credits'; import { extractYear } from './tmdb-matcher'; import { - TmdbCredits, TmdbDetails, TmdbMovieDetails, TmdbTvDetails, @@ -21,90 +30,9 @@ import { * The provider stays authoritative for stream-related data; TMDB wins for * editorial fields (plot, cast, director, genres, rating, artwork) when it * has a value, otherwise the provider value is kept. Nothing is mutated. + * People (cast, directors, creators) are extracted in `tmdb-credits.ts`. */ -const MAX_CAST_NAMES = 10; - -function topCast(credits: TmdbCredits | undefined) { - return [...(credits?.cast ?? [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .slice(0, MAX_CAST_NAMES) - .filter((member) => Boolean(member.name)); -} - -function castNames(credits: TmdbCredits | undefined): string { - return topCast(credits) - .map((member) => member.name) - .join(', '); -} - -/** Cast with profile photos for the avatar chips in detail views */ -function enrichedCast( - credits: TmdbCredits | undefined -): TmdbEnrichedCastMember[] { - return topCast(credits).map((member) => ({ - name: member.name, - ...(member.character ? { character: member.character } : {}), - profileUrl: tmdbProfileUrl(member.profile_path), - ...(member.id ? { tmdbPersonId: member.id } : {}), - })); -} - -function directorNames(credits: TmdbCredits | undefined): string { - return (credits?.crew ?? []) - .filter((member) => member.job === 'Director') - .map((member) => member.name) - .filter(Boolean) - .join(', '); -} - -function creatorNames(details: TmdbTvDetails): string { - return (details.created_by ?? []) - .map((creator) => creator.name) - .filter(Boolean) - .join(', '); -} - -/** - * Directors (movies) as clickable person chips — same shape as the cast - * chips, so a director opens the same person page as an actor. Deduped by - * TMDB id to collapse the duplicate crew rows TMDB sometimes returns. - */ -function enrichedDirectors( - credits: TmdbCredits | undefined -): TmdbEnrichedCastMember[] { - const seen = new Set(); - const directors: TmdbEnrichedCastMember[] = []; - for (const member of credits?.crew ?? []) { - if (member.job !== 'Director' || !member.name) { - continue; - } - if (member.id !== undefined) { - if (seen.has(member.id)) { - continue; - } - seen.add(member.id); - } - directors.push({ - name: member.name, - profileUrl: tmdbProfileUrl(member.profile_path), - ...(member.id ? { tmdbPersonId: member.id } : {}), - }); - } - return directors; -} - -/** Series creators as clickable person chips (TV shows have no director) */ -function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] { - return (details.created_by ?? []) - .filter((creator) => Boolean(creator.name)) - .map((creator) => ({ - name: creator.name, - profileUrl: tmdbProfileUrl(creator.profile_path), - ...(creator.id ? { tmdbPersonId: creator.id } : {}), - })); -} - const MAX_RECOMMENDATIONS = 12; /** Best YouTube trailer key: official trailer > any trailer > teaser */ @@ -170,11 +98,12 @@ export function mergeVodInfoWithTmdb( info: XtreamVodInfo, details: TmdbMovieDetails ): XtreamVodInfo { - const tmdbCast = enrichedCast(details.credits); + const movieCast = topCast(details.credits); + const tmdbCast = enrichedCast(movieCast); const tmdbDirectors = enrichedDirectors(details.credits); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); - const cast = castNames(details.credits); + const cast = castNames(movieCast); const director = directorNames(details.credits); const genre = genreNames(details); const rating = tmdbRating(details); @@ -217,12 +146,13 @@ export function mergeSerieInfoWithTmdb( info: XtreamSerieInfo, details: TmdbTvDetails ): XtreamSerieInfo { - const tmdbCast = enrichedCast(details.credits); + const seriesCast = limitCast(unifiedTvCast(details)); + const tmdbCast = enrichedCast(seriesCast); const tmdbDirectors = enrichedCreators(details); const status = normalizeSeriesStatus(details.status); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); - const cast = castNames(details.credits); + const cast = castNames(seriesCast); const creators = creatorNames(details); const genre = genreNames(details); const rating = tmdbRating(details); @@ -262,7 +192,11 @@ export function mergeStalkerInfoWithTmdb( details: TmdbMovieDetails | TmdbTvDetails, mediaType: TmdbMediaType ): StalkerVodInfo { - const tmdbCast = enrichedCast(details.credits); + const selectedCast = + mediaType === 'movie' + ? topCast(details.credits) + : limitCast(unifiedTvCast(details as TmdbTvDetails)); + const tmdbCast = enrichedCast(selectedCast); const tmdbDirectors = mediaType === 'movie' ? enrichedDirectors(details.credits) @@ -273,7 +207,7 @@ export function mergeStalkerInfoWithTmdb( : null; const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); - const cast = castNames(details.credits); + const cast = castNames(selectedCast); const director = mediaType === 'movie' ? directorNames(details.credits) diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index c6ec2b093..ccb5087af 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -57,6 +57,23 @@ export interface TmdbCredits { crew?: TmdbCrewMember[]; } +/** + * `/tv/{id}/aggregate_credits` groups a person's work across the whole + * run, so a character lives in `roles[]` rather than on the member. + */ +export interface TmdbAggregateCastMember { + id?: number; + name: string; + order?: number; + profile_path?: string | null; + total_episode_count?: number; + roles?: { character?: string; episode_count?: number }[]; +} + +export interface TmdbAggregateCredits { + cast?: TmdbAggregateCastMember[]; +} + export interface TmdbGenre { id: number; name: string; @@ -100,6 +117,8 @@ export interface TmdbTvDetails extends TmdbDetailsBase { first_air_date?: string; /** English production status ("Ended", "Returning Series", ...) */ status?: string; + /** Series-wide cast; `credits` alone covers only the latest season */ + aggregate_credits?: TmdbAggregateCredits; episode_run_time?: number[]; created_by?: { id?: number; From 3032cfa88d6ad608ba4af3faaf94798e287ce4fc Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:20:32 +0200 Subject: [PATCH 07/12] fix(m3u): avoid persisting hydrated favorites (#1232) --- .changes/m3u-favorites-hydration.md | 7 +++ CLAUDE.md | 2 +- docs/architecture/m3u-playlist-module.md | 10 +++- libs/m3u-state/src/lib/actions.ts | 1 + .../lib/reducers/favorites.reducers.spec.ts | 36 +++++++++++++ .../src/lib/reducers/favorites.reducers.ts | 42 +++++++++------- ...3u-workspace-route-session.service.spec.ts | 50 ++++++++++++++++++- .../m3u-workspace-route-session.service.ts | 4 +- 8 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 .changes/m3u-favorites-hydration.md create mode 100644 libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts diff --git a/.changes/m3u-favorites-hydration.md b/.changes/m3u-favorites-hydration.md new file mode 100644 index 000000000..97d9deb64 --- /dev/null +++ b/.changes/m3u-favorites-hydration.md @@ -0,0 +1,7 @@ +--- +type: perf +area: m3u +--- + +Importing large M3U playlists is faster because IPTVnator no longer rewrites +the entire playlist when loading saved favorites. diff --git a/CLAUDE.md b/CLAUDE.md index a2e3b4021..9b5b422fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -430,7 +430,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. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 08c5dd84e..a376abbd3 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -152,7 +152,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 +250,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 +901,6 @@ Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts 1. Dispatch `FavoritesActions.updateFavorites` for toggle 2. Dispatch `FavoritesActions.setFavorites` for reordering -3. Effects automatically persist to database +3. Dispatch `FavoritesActions.hydrateFavorites` only when copying values that + were already read from persistence into NgRx +4. Effects persist the two user-mutation actions; hydration is reducer-only diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index d2ea2baee..9fcdaf2ca 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -71,6 +71,7 @@ export const FavoritesActions = createActionGroup({ events: { 'Update Favorites': props<{ channel: Channel }>(), 'Set Favorites': props<{ channelIds: string[] }>(), + 'Hydrate Favorites': props<{ channelIds: string[] }>(), }, }); diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts new file mode 100644 index 000000000..25d781018 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts @@ -0,0 +1,36 @@ +import { createReducer } from '@ngrx/store'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { FavoritesActions } from '../actions'; +import { playlistsAdapter } from '../playlists.state'; +import { initialState } from '../state'; +import { favoritesReducers } from './favorites.reducers'; + +const reducer = createReducer(initialState, ...favoritesReducers); + +describe('favoritesReducers', () => { + it('hydrates persisted favorites into state without using the persistence action', () => { + const playlist = { + _id: 'playlist-1', + title: 'Synthetic playlist', + favorites: ['stale-channel'], + } as PlaylistMeta; + const state = { + ...initialState, + playlists: playlistsAdapter.addOne(playlist, { + ...initialState.playlists, + selectedId: playlist._id, + }), + }; + + const nextState = reducer( + state, + FavoritesActions.hydrateFavorites({ + channelIds: ['persisted-channel'], + }) + ); + + expect(nextState.playlists.entities[playlist._id]?.favorites).toEqual([ + 'persisted-channel', + ]); + }); +}); diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts index 4e83e0947..12bb4a383 100644 --- a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts @@ -32,26 +32,30 @@ export const favoritesReducers = [ }, }; }), - on(FavoritesActions.setFavorites, (state, action): PlaylistState => { - const selectedId = state.playlists.selectedId; - const playlist = state.playlists.entities[selectedId]; - if (!selectedId || !playlist) { - return state; - } + on( + FavoritesActions.setFavorites, + FavoritesActions.hydrateFavorites, + (state, action): PlaylistState => { + const selectedId = state.playlists.selectedId; + const playlist = state.playlists.entities[selectedId]; + if (!selectedId || !playlist) { + return state; + } - const { channelIds } = action; - return { - ...state, - playlists: { - ...state.playlists, - entities: { - ...state.playlists.entities, - [selectedId]: { - ...playlist, - favorites: channelIds, + const { channelIds } = action; + return { + ...state, + playlists: { + ...state.playlists, + entities: { + ...state.playlists.entities, + [selectedId]: { + ...playlist, + favorites: channelIds, + }, }, }, - }, - }; - }), + }; + } + ), ]; diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts index cee66647b..4581781d4 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { of, Subject } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; @@ -180,6 +180,52 @@ describe('M3uWorkspaceRouteSession', () => { ); }); + it('hydrates persisted favorites without dispatching the persistence action', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + favorites: [PRIMARY_CHANNEL.url], + playlist: { + items: [PRIMARY_CHANNEL], + }, + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(store.dispatch).toHaveBeenCalledWith( + FavoritesActions.hydrateFavorites({ + channelIds: [PRIMARY_CHANNEL.url], + }) + ); + expect( + store.dispatch.mock.calls.some( + ([action]) => action.type === FavoritesActions.setFavorites.type + ) + ).toBe(false); + }); + + it('clears in-memory favorites without dispatching persistence when playlist loading fails', async () => { + playlistsService.getPlaylist.mockReturnValue( + throwError(() => new Error('Playlist loading failed')) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannels({ channels: [] }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + FavoritesActions.hydrateFavorites({ channelIds: [] }) + ); + expect( + store.dispatch.mock.calls.some( + ([action]) => action.type === FavoritesActions.setFavorites.type + ) + ).toBe(false); + }); + it('ignores stale playlist responses after a newer route request wins', async () => { const firstResponse = new Subject(); const secondResponse = new Subject(); @@ -225,7 +271,7 @@ describe('M3uWorkspaceRouteSession', () => { [ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })], ]); expect(store.dispatch).toHaveBeenCalledWith( - FavoritesActions.setFavorites({ + FavoritesActions.hydrateFavorites({ channelIds: [NEXT_CHANNEL.url], }) ); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 09d8ed28b..0db0dcd87 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -117,7 +117,7 @@ export class M3uWorkspaceRouteSession { (favorite): favorite is string => typeof favorite === 'string' ); this.store.dispatch( - FavoritesActions.setFavorites({ + FavoritesActions.hydrateFavorites({ channelIds: favorites, }) ); @@ -128,7 +128,7 @@ export class M3uWorkspaceRouteSession { this.store.dispatch(ChannelActions.setChannels({ channels: [] })); this.store.dispatch( - FavoritesActions.setFavorites({ channelIds: [] }) + FavoritesActions.hydrateFavorites({ channelIds: [] }) ); } } From e91a7cde7a0e14e94bd9b058b937ed9e5ad532d2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:21:11 +0200 Subject: [PATCH 08/12] fix(deps): patch transitive runtime CVEs via pnpm overrides (#1258) Closes 13 runtime-scope Dependabot advisories that Dependabot cannot fix itself: every vulnerable package here is transitive, so the bot has no lever until each parent publishes a release widening its own pin. Overrides added (pinned-source form, matching existing convention): - @xmldom/xmldom 0.8.11 -> 0.8.13 (5 high) via video.js -> mpd-parser - fast-uri 3.1.0 -> 3.1.4 (4 high) via electron-conf -> ajv - js-yaml 4.1.1 -> 4.3.0 (2) via electron-updater - form-data 4.0.5 -> 4.0.6 (1 high) via axios - ajv 8.17.1 -> 8.18.0 (1) via electron-conf Every target stays inside its parent's declared semver range. For xmldom, fast-uri and js-yaml the newest published version is outside that range (0.9.x / 4.x / 5.x), so "latest" would have broken them; the new doc records that constraint. Deliberately excluded: axios and uuid are direct deps already covered by open Dependabot PRs (#1251, #1252). undici is labelled runtime scope but every path to it is build tooling (electron -> @electron/get, @angular/build, @module-federation/dts-plugin) and it is not in the packaged app. Reachability: xmldom arrives via video.js -> VHS -> mpd-parser, but the app routes every .mpd to Shaka, which uses its own DASH parser, so that one is defence in depth. The genuinely reachable one is js-yaml, which electron-updater uses to parse latest.yml from releases. Adds docs/architecture/dependency-security-overrides.md and a .changes note. Co-Authored-By: Claude Opus 5 --- .changes/deps-transitive-cve-overrides.md | 8 ++ .../dependency-security-overrides.md | 74 +++++++++++++ package.json | 5 + pnpm-lock.yaml | 102 +++++++++--------- 4 files changed, 137 insertions(+), 52 deletions(-) create mode 100644 .changes/deps-transitive-cve-overrides.md create mode 100644 docs/architecture/dependency-security-overrides.md diff --git a/.changes/deps-transitive-cve-overrides.md b/.changes/deps-transitive-cve-overrides.md new file mode 100644 index 000000000..8be3e46d5 --- /dev/null +++ b/.changes/deps-transitive-cve-overrides.md @@ -0,0 +1,8 @@ +--- +type: internal +area: deps +--- + +Patched five vulnerable transitive dependencies that ship with the app — +including the YAML parser `electron-updater` uses to read update manifests, and +the HTTP form encoder behind portal requests. No behaviour change. diff --git a/docs/architecture/dependency-security-overrides.md b/docs/architecture/dependency-security-overrides.md new file mode 100644 index 000000000..be525ed63 --- /dev/null +++ b/docs/architecture/dependency-security-overrides.md @@ -0,0 +1,74 @@ +# Dependency Security Overrides + +How transitive CVEs are patched in this repo, and the constraint that makes +"just bump to latest" the wrong move. + +## Why overrides exist + +Dependabot can only bump packages we declare ourselves. When the vulnerable +package is transitive — pulled in by `video.js`, `electron-updater`, +`electron-conf`, `axios` — the bot has no lever: it would have to wait for the +parent to publish a release that widens its own pin. Until then the alert stays +open regardless of how many bot PRs land. + +`pnpm.overrides` in the root `package.json` is that lever. Every entry uses the +pinned-source form so an override only rewrites the exact resolution it was +written for, and goes stale visibly instead of silently re-targeting a future +version: + +```json +"@xmldom/xmldom@0.8.11": "0.8.13" +``` + +## The semver ceiling + +**An override must stay inside the range its parent declares.** pnpm applies +overrides without re-checking the parent's range, so an out-of-range target +installs cleanly and then fails at runtime or under load, not at install time. + +For three of the five current security overrides, the newest published version +is _outside_ the parent's range. Taking "latest" would break them: + +| Override | Pinned to | Parent range | Latest on npm | +| ---------------- | --------- | --------------------------------------- | ------------- | +| `@xmldom/xmldom` | 0.8.13 | `mpd-parser` `^0.8.3`, `plist` `^0.8.8` | 0.9.x ❌ | +| `fast-uri` | 3.1.4 | `ajv` `^3.0.1` | 4.x ❌ | +| `js-yaml` | 4.3.0 | `electron-updater` `^4.1.0` | 5.x ❌ | +| `form-data` | 4.0.6 | `axios` `^4.0.5` | 4.0.6 ✅ | +| `ajv` | 8.18.0 | `electron-conf` `^8.13.0` | 8.20.0 ✅ | + +Before changing any of these, check the parent's declared range first: + +```bash +npm view @ dependencies --json +``` + +## Verifying an override actually applied + +Grepping `pnpm-lock.yaml` for the old version still finds it, but that hit is +not a leftover package block — pnpm removes those once nothing resolves to them. +It is the override's own selector key, echoed in the `overrides:` block at the +top of the lockfile: + +```yaml +overrides: + '@xmldom/xmldom@0.8.11': 0.8.13 +``` + +So a bare grep proves only that the override is declared, never that it took +effect. Resolve the real path on disk instead: + +```bash +node -e "console.log(require('./node_modules/.pnpm/mpd-parser@1.3.1/node_modules/@xmldom/xmldom/package.json').version)" +``` + +## What is deliberately not overridden + +`undici` carries open alerts flagged `runtime` scope, but every path to it is +build tooling — `electron` → `@electron/get`, `@angular/build`, and +`@module-federation/dts-plugin`. It is not in the packaged app. The `runtime` +label is a Dependabot classification artifact, not a shipped-code claim. Bumping +it inside the Angular/Nx toolchain risks the build for no runtime benefit. + +When triaging, confirm scope from the dependency graph rather than trusting the +alert's `scope` field. diff --git a/package.json b/package.json index 7f4879c18..45b2170bc 100644 --- a/package.json +++ b/package.json @@ -218,16 +218,21 @@ "pnpm": { "overrides": { "@hono/node-server@1.19.9": "1.19.14", + "@xmldom/xmldom@0.8.11": "0.8.13", "ajv@6.12.6": "6.14.0", + "ajv@8.17.1": "8.18.0", "brace-expansion@1.1.12": "1.1.13", "defu@6.1.4": "6.1.6", "devalue@5.6.2": "5.6.4", "express-rate-limit@8.2.1": "8.3.0", + "fast-uri@3.1.0": "3.1.4", "flatted@3.3.3": "3.4.2", "follow-redirects@1.15.11": "1.16.0", + "form-data@4.0.5": "4.0.6", "h3@1.15.5": "1.15.10", "hono@4.12.0": "4.12.14", "immutable@5.1.4": "5.1.5", + "js-yaml@4.1.1": "4.3.0", "lodash-es@4.17.22": "4.18.1", "node-abi@3.85.0": "3.92.0", "node-forge@1.3.3": "1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5659b2ab..06aa48366 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,16 +6,21 @@ settings: overrides: '@hono/node-server@1.19.9': 1.19.14 + '@xmldom/xmldom@0.8.11': 0.8.13 ajv@6.12.6: 6.14.0 + ajv@8.17.1: 8.18.0 brace-expansion@1.1.12: 1.1.13 defu@6.1.4: 6.1.6 devalue@5.6.2: 5.6.4 express-rate-limit@8.2.1: 8.3.0 + fast-uri@3.1.0: 3.1.4 flatted@3.3.3: 3.4.2 follow-redirects@1.15.11: 1.16.0 + form-data@4.0.5: 4.0.6 h3@1.15.5: 1.15.10 hono@4.12.0: 4.12.14 immutable@5.1.4: 5.1.5 + js-yaml@4.1.1: 4.3.0 lodash-es@4.17.22: 4.18.1 node-abi@3.85.0: 3.92.0 node-forge@1.3.3: 1.4.0 @@ -4712,10 +4717,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4806,7 +4810,7 @@ packages: ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -4814,7 +4818,7 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -4827,14 +4831,11 @@ packages: ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: - ajv: ^8.8.2 + ajv: 8.18.0 ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -6498,8 +6499,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -6620,8 +6621,8 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} forwarded@0.2.0: @@ -6871,6 +6872,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -7608,8 +7613,8 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsdom@26.1.0: @@ -11278,8 +11283,8 @@ snapshots: '@angular-devkit/core@21.1.4(chokidar@5.0.0)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 picomatch: 4.0.3 rxjs: 7.8.2 @@ -11595,7 +11600,7 @@ snapshots: hast-util-from-html: 2.0.3 hast-util-to-text: 4.0.2 import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 @@ -11621,7 +11626,7 @@ snapshots: hast-util-from-html: 2.0.3 hast-util-to-text: 4.0.2 import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 @@ -13272,7 +13277,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -16104,7 +16109,7 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.13': {} '@xtuc/ieee754@1.2.0': {} @@ -16190,10 +16195,6 @@ snapshots: optionalDependencies: ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -16214,17 +16215,10 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -16335,7 +16329,7 @@ snapshots: hosted-git-info: 4.1.0 is-ci: 3.0.1 isbinaryfile: 5.0.7 - js-yaml: 4.1.1 + js-yaml: 4.3.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.1.1 @@ -16460,7 +16454,7 @@ snapshots: html-escaper: 3.0.3 http-cache-semantics: 4.2.0 import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 magic-string: 0.30.21 magicast: 0.5.2 mrmime: 2.0.1 @@ -16555,7 +16549,7 @@ snapshots: axios@1.15.0: dependencies: follow-redirects: 1.16.0(debug@4.4.3) - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -16563,7 +16557,7 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0(debug@4.4.3) - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -16894,7 +16888,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-ci: 3.0.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 sanitize-filename: 1.6.3 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -17269,7 +17263,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -17279,7 +17273,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -17581,7 +17575,7 @@ snapshots: builder-util-runtime: 9.3.1 fs-extra: 10.1.0 iconv-lite: 0.6.3 - js-yaml: 4.1.1 + js-yaml: 4.3.0 optionalDependencies: dmg-license: 1.0.11 transitivePeerDependencies: @@ -17702,7 +17696,7 @@ snapshots: electron-conf@1.3.0(electron@41.7.2): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 electron: 41.7.2 electron-playwright-helpers@1.8.2: @@ -17715,7 +17709,7 @@ snapshots: builder-util: 26.0.11 builder-util-runtime: 9.3.1 chalk: 4.1.2 - form-data: 4.0.5 + form-data: 4.0.6 fs-extra: 10.1.0 lazy-val: 1.0.5 mime: 2.6.0 @@ -17730,7 +17724,7 @@ snapshots: dependencies: builder-util-runtime: 9.7.0 fs-extra: 10.1.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -18379,7 +18373,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.4: {} fastq@1.20.1: dependencies: @@ -18520,12 +18514,12 @@ snapshots: typescript: 5.9.3 webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -18806,6 +18800,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -19832,7 +19830,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -20786,14 +20784,14 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 '@videojs/vhs-utils': 3.0.5 - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 global: 4.4.0 mpd-parser@1.3.1: dependencies: '@babel/runtime': 7.28.4 '@videojs/vhs-utils': 4.1.1 - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 global: 4.4.0 mpegts.js@1.8.0: @@ -21098,7 +21096,7 @@ snapshots: figures: 3.2.0 flat: 5.0.2 follow-redirects: 1.16.0(debug@4.4.3) - form-data: 4.0.5 + form-data: 4.0.6 fs-constants: 1.0.0 function-bind: 1.1.2 get-caller-file: 2.0.5 @@ -21552,7 +21550,7 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 From b05841f1218886ed6edca4f2bc44a1b8667553be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:21:56 +0200 Subject: [PATCH 09/12] chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#1248) --- .github/workflows/deploy-website.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index b7c0b8bac..01a5143c7 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -42,7 +42,7 @@ jobs: run: pnpm nx build website - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: dist/apps/website From 7e8c2ccce16b71d72a64c45f1d96dd090dc8e077 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:22:05 +0200 Subject: [PATCH 10/12] chore(deps): bump codecov/codecov-action from 6 to 7 (#1246) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f8b1031d..bc86e2950 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,7 +176,7 @@ jobs: - name: Upload unit coverage to Codecov if: always() - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml flags: unit From f147d4fe37804bf46528a2e7fa65364f80aab8c0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:25:51 +0200 Subject: [PATCH 11/12] perf(m3u): stop cancelled refresh workers (#1268) --- .changes/m3u-refresh-cancellation.md | 8 + AGENTS.md | 1 + CLAUDE.md | 3 +- .../playwright.performance.config.ts | 13 + apps/electron-backend-e2e/project.json | 10 + .../src/electron-test-fixtures.ts | 3 +- .../m3u-refresh-cancellation.performance.ts | 8 + .../m3u-refresh-cancellation-contract.ts | 219 +++++ .../m3u-refresh-cancellation-report.ts | 414 +++++++++ .../m3u-refresh-cancellation.benchmark.ts | 543 +++++++++++ .../performance/m3u-refresh-main-capture.ts | 871 ++++++++++++++++++ .../m3u-refresh-renderer-capture.ts | 508 ++++++++++ .../src/performance/performance-statistics.ts | 64 ++ .../src/performance/synthetic-m3u.ts | 40 + .../src/app/api/main.preload.spec.ts | 23 + .../src/app/events/playlist.events.spec.ts | 107 ++- .../src/app/events/playlist.events.ts | 172 ++-- .../src/app/workers/database-worker.types.ts | 3 + .../src/app/workers/database.worker.ts | 19 +- .../app/workers/playlist-refresh.worker.ts | 13 + .../workers/playlist-refresh.worker.types.ts | 7 +- .../worker-performance-capture.spec.ts | 48 + .../app/workers/worker-performance-capture.ts | 68 ++ docs/architecture/m3u-playlist-module.md | 42 +- .../src/lib/playlist-refresh.service.spec.ts | 49 + .../src/lib/playlist-refresh.service.ts | 11 +- .../src/lib/electron-api.interface.ts | 5 +- .../src/lib/playlist-refresh.interface.ts | 22 + 28 files changed, 3205 insertions(+), 89 deletions(-) create mode 100644 .changes/m3u-refresh-cancellation.md create mode 100644 apps/electron-backend-e2e/playwright.performance.config.ts create mode 100644 apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-statistics.ts create mode 100644 apps/electron-backend-e2e/src/performance/synthetic-m3u.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.ts create mode 100644 libs/services/src/lib/playlist-refresh.service.spec.ts diff --git a/.changes/m3u-refresh-cancellation.md b/.changes/m3u-refresh-cancellation.md new file mode 100644 index 000000000..6029dad22 --- /dev/null +++ b/.changes/m3u-refresh-cancellation.md @@ -0,0 +1,8 @@ +--- +type: perf +area: m3u +--- + +Cancelling a large M3U refresh now stops its background worker before parsed +channels can be copied or saved, keeping the interface responsive and leaving +the existing playlist unchanged. diff --git a/AGENTS.md b/AGENTS.md index aa1215c03..cdadea77b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/CLAUDE.md b/CLAUDE.md index 9b5b422fd..a8bfaedc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching @@ -615,7 +616,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 diff --git a/apps/electron-backend-e2e/playwright.performance.config.ts b/apps/electron-backend-e2e/playwright.performance.config.ts new file mode 100644 index 000000000..58a6ba03b --- /dev/null +++ b/apps/electron-backend-e2e/playwright.performance.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + fullyParallel: false, + reporter: [['list']], + testDir: './src', + testMatch: '**/*.performance.ts', + timeout: 30 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index cdaa4ec01..1918e34fa 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,16 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "benchmark-m3u-refresh-cancellation": { + "dependsOn": ["electron-backend:build-e2e"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 8b4198ab4..df1f1f025 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -75,6 +75,7 @@ type ElectronFixtures = { }; export type LaunchElectronAppOptions = { + args?: readonly string[]; env?: Record; }; @@ -142,7 +143,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = [electronMainPath]; + const args = [...(options.args ?? []), electronMainPath]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); diff --git a/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts b/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts new file mode 100644 index 000000000..6ff9b5f6c --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; + +import { runM3uRefreshCancellationBenchmark } from './performance/m3u-refresh-cancellation.benchmark'; + +// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle. +test('profiles cancellation of a 100k M3U refresh', async () => { + await runM3uRefreshCancellationBenchmark(); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts new file mode 100644 index 000000000..f0abcd22f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -0,0 +1,219 @@ +export const PERFORMANCE_ITERATION_KIND = { + DIAGNOSTIC: 'diagnostic', + MEASURED: 'measured', + WARMUP: 'warmup', +} as const; + +export type PerformanceIterationKind = + (typeof PERFORMANCE_ITERATION_KIND)[keyof typeof PERFORMANCE_ITERATION_KIND]; + +export const PERFORMANCE_WORKER_KIND = { + DATABASE: 'database.worker', + PLAYLIST_REFRESH: 'playlist-refresh.worker', +} as const; + +export type PerformanceWorkerKind = + (typeof PERFORMANCE_WORKER_KIND)[keyof typeof PERFORMANCE_WORKER_KIND]; + +export interface NumericDistribution { + readonly count: number; + readonly max: number | null; + readonly mean: number | null; + readonly median: number | null; + readonly min: number | null; + readonly p95: number | null; + readonly p99: number | null; +} + +export interface EventLoopDelayMetrics { + readonly maxMs: number; + readonly p95Ms: number; + readonly p99Ms: number; +} + +export interface ProcessMemoryMetrics { + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number | null; + readonly postGcRssBytes: number | null; +} + +export interface MainTimelineRecord { + readonly epochMs: number; + readonly operation?: string; + readonly operationId?: string; + readonly playlistId?: string; + readonly requestId?: string; + readonly success?: boolean; + readonly type: string; +} + +export interface WorkerCaptureMetrics { + readonly cancelPostedEpochMs: number | null; + readonly cpuSystemMicros: number | null; + readonly cpuUserMicros: number | null; + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly kind: PerformanceWorkerKind; + readonly operationId: string | null; + readonly peakExternalBytes: number; + readonly peakHeapUsedBytes: number; + readonly playlistId: string | null; + readonly postGcHeapUsedBytes: number | null; + readonly profilePath: string | null; + readonly responseEpochMs: number | null; + readonly snapshotPath: string | null; + readonly terminatedEpochMs: number | null; +} + +export interface MainCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly eventLoopDelay: EventLoopDelayMetrics; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly heapSnapshotPath: string | null; + readonly memory: ProcessMemoryMetrics; + readonly rendererPeakRssBytes: number; + readonly rssScope: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly timeline: readonly MainTimelineRecord[]; + readonly unresponsiveEvents: number; + readonly responsiveEvents: number; + readonly workers: readonly WorkerCaptureMetrics[]; +} + +export interface PlaylistRefreshProbeEvent { + readonly operationId: string; + readonly phase: string | null; + readonly receivedEpochMs: number; + readonly status: string; +} + +export interface RendererProbeMetrics { + readonly cancelButtonFound: boolean; + readonly cancelClickEpochMs: number | null; + readonly events: readonly PlaylistRefreshProbeEvent[]; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; + readonly uiPhaseEpochMs: Readonly>; + readonly uiSettledEpochMs: number | null; +} + +export interface RendererCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number | null; + readonly probe: RendererProbeMetrics; + readonly tracePath: string | null; +} + +export interface CancellationPhaseMetrics { + readonly cancelAcknowledgementLatencyMs: number | null; + readonly cancelToDurableTerminalMs: number | null; + readonly cancelToWorkerTerminatedMs: number | null; + readonly cancelTransportLatencyMs: number | null; + readonly dataFetchMs: number | null; + readonly dbCommitProxyMs: number | null; + readonly ipcStoreDispatchProxyMs: number | null; + readonly parseNormalizeCloneProxyMs: number | null; + readonly persistencePreparationProxyMs: number | null; + readonly totalMs: number | null; + readonly uiSettlementToPaintMs: number | null; + readonly visibleTotalMs: number | null; +} + +export interface CancellationIterationResult { + readonly cancellationEffectObserved: boolean; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly phases: CancellationPhaseMetrics; + readonly renderer: RendererCaptureMetrics; + readonly runId: string; +} + +export interface CancellationBenchmarkManifest { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly fixtureSha256: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly memoryAccounting: { + readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly rendererRss: 'separate-renderer-process'; + readonly workerJsHeap: 'separate-v8-isolate'; + readonly workerRss: 'unavailable-per-thread'; + }; + readonly scenario: 'm3u-refresh-cancel'; + readonly syntheticSeed: number; + readonly runtime: { + readonly arch: string; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; + }; + readonly variant: string; + readonly warmupRuns: number; +} + +export interface CancellationBenchmarkSummary { + readonly cancellationEffectRate: number; + readonly iterations: readonly CancellationIterationResult[]; + readonly manifest: CancellationBenchmarkManifest; + readonly measured: { + readonly cancelAcknowledgementLatencyMs: NumericDistribution; + readonly cancelToDurableTerminalMs: NumericDistribution; + readonly cancelToWorkerTerminatedMs: NumericDistribution; + readonly cancelTransportLatencyMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerExternalPeakBytes: NumericDistribution; + readonly databaseWorkerHeapPeakBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly dataFetchMs: NumericDistribution; + readonly dbCommitProxyMs: NumericDistribution; + readonly ipcStoreDispatchProxyMs: NumericDistribution; + readonly mainEventLoopDelayMaxMs: NumericDistribution; + readonly mainEventLoopDelayP95Ms: NumericDistribution; + readonly mainEventLoopDelayP99Ms: NumericDistribution; + readonly mainEventLoopUtilization: NumericDistribution; + readonly mainHeapPeakBytes: NumericDistribution; + readonly mainHeapPostGcBytes: NumericDistribution; + readonly mainRssPeakBytes: NumericDistribution; + readonly mainRssPostGcBytes: NumericDistribution; + readonly parseNormalizeCloneProxyMs: NumericDistribution; + readonly persistencePreparationProxyMs: NumericDistribution; + readonly playlistWorkerEventLoopUtilization: NumericDistribution; + readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerExternalPeakBytes: NumericDistribution; + readonly playlistWorkerHeapPeakBytes: NumericDistribution; + readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly rendererFrameGapMs: NumericDistribution; + readonly rendererHeartbeatDelayMs: NumericDistribution; + readonly rendererHeapPeakBytes: NumericDistribution; + readonly rendererHeapPostGcBytes: NumericDistribution; + readonly rendererLongTaskCount: number; + readonly rendererLongTaskMs: NumericDistribution; + readonly rendererRssPeakBytes: NumericDistribution; + readonly responsiveEvents: number; + readonly totalMs: NumericDistribution; + readonly uiSettlementToPaintMs: NumericDistribution; + readonly unresponsiveEvents: number; + readonly visibleTotalMs: NumericDistribution; + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts new file mode 100644 index 000000000..2f063cd0a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -0,0 +1,414 @@ +/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */ +import type { + CancellationBenchmarkManifest, + CancellationBenchmarkSummary, + CancellationIterationResult, + CancellationPhaseMetrics, + MainCaptureMetrics, + MainTimelineRecord, + NumericDistribution, + PerformanceWorkerKind, + RendererCaptureMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; +import { + nonNegativeDifference, + summarizeNumbers, +} from './performance-statistics'; + +export function createCancellationIterationResult(input: { + readonly kind: CancellationIterationResult['kind']; + readonly main: MainCaptureMetrics; + readonly renderer: RendererCaptureMetrics; + readonly runId: string; +}): CancellationIterationResult { + const { main, renderer } = input; + const phases = derivePhases(main, renderer); + const cancellationEffectObserved = renderer.probe.events.some( + (event) => event.status === 'cancelled' + ); + + return Object.freeze({ + cancellationEffectObserved, + kind: input.kind, + main, + phases, + renderer, + runId: input.runId, + }); +} + +export function createCancellationBenchmarkSummary( + manifest: CancellationBenchmarkManifest, + iterations: readonly CancellationIterationResult[] +): CancellationBenchmarkSummary { + const measured = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + const flattenRenderer = ( + select: (iteration: CancellationIterationResult) => readonly number[] + ): number[] => measured.flatMap((iteration) => [...select(iteration)]); + const workerMetric = ( + kind: PerformanceWorkerKind, + select: (worker: MainCaptureMetrics['workers'][number]) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.map((iteration) => { + const worker = iteration.main.workers.find( + (candidate) => candidate.kind === kind + ); + return worker ? select(worker) : null; + }) + ); + const workerEventLoopDelayMetric = ( + kind: PerformanceWorkerKind, + percentile: keyof NonNullable< + MainCaptureMetrics['workers'][number]['eventLoopDelay'] + > + ): NumericDistribution => + workerMetric( + kind, + (worker) => worker.eventLoopDelay?.[percentile] ?? null + ); + + return Object.freeze({ + cancellationEffectRate: + measured.length === 0 + ? 0 + : measured.filter( + (iteration) => iteration.cancellationEffectObserved + ).length / measured.length, + iterations: Object.freeze([...iterations]), + manifest, + measured: Object.freeze({ + cancelAcknowledgementLatencyMs: summarizeNumbers( + measured.map( + (iteration) => + iteration.phases.cancelAcknowledgementLatencyMs + ) + ), + cancelToDurableTerminalMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelToDurableTerminalMs + ) + ), + cancelToWorkerTerminatedMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelToWorkerTerminatedMs + ) + ), + cancelTransportLatencyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelTransportLatencyMs + ) + ), + databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerEventLoopUtilization: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.eventLoopUtilization + ), + databaseWorkerExternalPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.peakExternalBytes + ), + databaseWorkerHeapPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.peakHeapUsedBytes + ), + databaseWorkerPostGcHeapBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.postGcHeapUsedBytes + ), + dataFetchMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.dataFetchMs) + ), + dbCommitProxyMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.dbCommitProxyMs) + ), + ipcStoreDispatchProxyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.ipcStoreDispatchProxyMs + ) + ), + mainEventLoopDelayMaxMs: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.maxMs) + ), + mainEventLoopDelayP95Ms: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.p95Ms) + ), + mainEventLoopDelayP99Ms: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.p99Ms) + ), + mainEventLoopUtilization: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopUtilization) + ), + mainHeapPeakBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.peakHeapUsedBytes + ) + ), + mainHeapPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.postGcHeapUsedBytes + ) + ), + mainRssPeakBytes: summarizeNumbers( + measured.map((iteration) => iteration.main.memory.peakRssBytes) + ), + mainRssPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.postGcRssBytes + ) + ), + parseNormalizeCloneProxyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.parseNormalizeCloneProxyMs + ) + ), + persistencePreparationProxyMs: summarizeNumbers( + measured.map( + (iteration) => + iteration.phases.persistencePreparationProxyMs + ) + ), + playlistWorkerEventLoopUtilization: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.eventLoopUtilization + ), + playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerExternalPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.peakExternalBytes + ), + playlistWorkerHeapPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.peakHeapUsedBytes + ), + playlistWorkerPostGcHeapBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.postGcHeapUsedBytes + ), + rendererFrameGapMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.frameGapsMs + ) + ), + rendererHeartbeatDelayMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.heartbeatDelaysMs + ) + ), + rendererHeapPeakBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.renderer.peakHeapUsedBytes + ) + ), + rendererHeapPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.renderer.postGcHeapUsedBytes + ) + ), + rendererLongTaskCount: measured.reduce( + (sum, iteration) => + sum + iteration.renderer.probe.longTasksMs.length, + 0 + ), + rendererLongTaskMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.longTasksMs + ) + ), + rendererRssPeakBytes: summarizeNumbers( + measured.map((iteration) => iteration.main.rendererPeakRssBytes) + ), + responsiveEvents: measured.reduce( + (sum, iteration) => sum + iteration.main.responsiveEvents, + 0 + ), + totalMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.totalMs) + ), + uiSettlementToPaintMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.uiSettlementToPaintMs + ) + ), + unresponsiveEvents: measured.reduce( + (sum, iteration) => sum + iteration.main.unresponsiveEvents, + 0 + ), + visibleTotalMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.visibleTotalMs) + ), + }), + }); +} + +function derivePhases( + main: MainCaptureMetrics, + renderer: RendererCaptureMetrics +): CancellationPhaseMetrics { + const started = findTimeline( + main.timeline, + 'playlist-event:started:fetching' + ); + const parsing = findTimeline( + main.timeline, + 'playlist-event:progress:parsing' + ); + const response = findTimeline(main.timeline, 'playlist-response'); + const playlistRequest = findTimeline(main.timeline, 'playlist-request'); + const playlistId = playlistRequest?.playlistId; + const dbGetRequest = findPlaylistDbRecord( + main.timeline, + 'db-request', + response?.epochMs, + 'DB_GET_APP_PLAYLIST', + playlistId + ); + const dbGetResponse = findResponse(main.timeline, dbGetRequest); + const dbUpsertRequest = findPlaylistDbRecord( + main.timeline, + 'db-request', + dbGetResponse?.epochMs ?? dbGetRequest?.epochMs ?? response?.epochMs, + 'DB_UPSERT_APP_PLAYLIST', + playlistId + ); + const dbUpsertResponse = findResponse(main.timeline, dbUpsertRequest); + const playlistWorker = main.workers.find( + (worker) => worker.kind === 'playlist-refresh.worker' + ); + const cancelClick = renderer.probe.cancelClickEpochMs; + const uiPainted = + renderer.probe.uiPaintedEpochMs ?? renderer.probe.terminalEpochMs; + const cancelledEvent = renderer.probe.events.find( + (event) => event.status === 'cancelled' + ); + const terminal = maxEpoch( + uiPainted, + dbUpsertResponse?.epochMs, + cancelledEvent?.receivedEpochMs, + response?.epochMs, + playlistWorker?.terminatedEpochMs + ); + + return Object.freeze({ + cancelAcknowledgementLatencyMs: nonNegativeDifference( + cancelledEvent?.receivedEpochMs, + cancelClick + ), + cancelToDurableTerminalMs: nonNegativeDifference(terminal, cancelClick), + cancelToWorkerTerminatedMs: nonNegativeDifference( + playlistWorker?.terminatedEpochMs, + cancelClick + ), + cancelTransportLatencyMs: nonNegativeDifference( + playlistWorker?.cancelPostedEpochMs, + cancelClick + ), + dataFetchMs: nonNegativeDifference(parsing?.epochMs, started?.epochMs), + dbCommitProxyMs: nonNegativeDifference( + dbUpsertResponse?.epochMs, + dbUpsertRequest?.epochMs + ), + ipcStoreDispatchProxyMs: nonNegativeDifference( + dbGetRequest?.epochMs, + response?.epochMs + ), + parseNormalizeCloneProxyMs: nonNegativeDifference( + response?.epochMs, + parsing?.epochMs + ), + persistencePreparationProxyMs: nonNegativeDifference( + dbUpsertRequest?.epochMs, + dbGetResponse?.epochMs + ), + totalMs: nonNegativeDifference( + terminal, + renderer.probe.operationStartEpochMs + ), + uiSettlementToPaintMs: nonNegativeDifference( + uiPainted, + renderer.probe.uiSettledEpochMs + ), + visibleTotalMs: nonNegativeDifference( + uiPainted, + renderer.probe.operationStartEpochMs + ), + }); +} + +function findTimeline( + timeline: readonly MainTimelineRecord[], + type: string +): MainTimelineRecord | undefined { + return timeline.find((record) => record.type === type); +} + +function findPlaylistDbRecord( + timeline: readonly MainTimelineRecord[], + type: string, + afterEpochMs: number | undefined, + operation: string, + playlistId: string | undefined +): MainTimelineRecord | undefined { + if (afterEpochMs === undefined) { + return undefined; + } + return timeline.find( + (record) => + record.type === type && + record.epochMs >= afterEpochMs && + record.operation === operation && + (playlistId === undefined || record.playlistId === playlistId) + ); +} + +function findResponse( + timeline: readonly MainTimelineRecord[], + request: MainTimelineRecord | undefined +): MainTimelineRecord | undefined { + if (request?.requestId === undefined) { + return undefined; + } + return timeline.find( + (record) => + record.type === 'db-response' && + record.requestId === request.requestId + ); +} + +function maxEpoch( + ...values: readonly (number | null | undefined)[] +): number | null { + const finite = values.filter( + (value): value is number => + typeof value === 'number' && Number.isFinite(value) + ); + return finite.length === 0 ? null : Math.max(...finite); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts new file mode 100644 index 000000000..fcfd83d1f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -0,0 +1,543 @@ +/* eslint-disable max-lines -- Benchmark lifecycle, local fixture server, and artifact safeguards stay auditable in one entry point. */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { createServer as createTcpServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { expect, type ConsoleMessage, type Page } from '@playwright/test'; + +import { + closeElectronApp, + launchElectronApp, + openSources, + sourceRowByTitle, + waitForM3uCatalog, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import type { + CancellationBenchmarkManifest, + CancellationIterationResult, + PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + createCancellationBenchmarkSummary, + createCancellationIterationResult, +} from './m3u-refresh-cancellation-report'; +import { + installMainCapture, + readMainCaptureStatus, + startMainCapture, + stopMainCapture, +} from './m3u-refresh-main-capture'; +import { startRendererCapture } from './m3u-refresh-renderer-capture'; +import { + createSyntheticM3uFixture, + SYNTHETIC_M3U_CHANNEL_COUNT, + SYNTHETIC_M3U_SEED, + type SyntheticM3uFixture, +} from './synthetic-m3u'; + +const SCENARIO = 'm3u-refresh-cancel'; +const SOURCE_TITLE = 'Synthetic M3U cancellation 100k'; +const RESOURCE_PATH = '/synthetic-performance.m3u'; +const DEFAULT_MEASURED_RUNS = 5; +const DEFAULT_WARMUP_RUNS = 1; +const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +const RENDERER_CDP_PORT = 9222; + +interface SyntheticServer { + readonly resourceUrl: string; + close(): Promise; + serveLargeFixture(): void; + serveSeedFixture(): void; +} + +interface BenchmarkConfiguration { + readonly electronVersion: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly outputDirectory: string; + readonly variant: string; + readonly warmupRuns: number; + readonly workspaceRoot: string; +} + +interface IterationDefinition { + readonly kind: PerformanceIterationKind; + readonly runId: string; +} + +export async function runM3uRefreshCancellationBenchmark(): Promise { + const config = await resolveConfiguration(); + const fixture = createSyntheticM3uFixture(); + const server = await startSyntheticServer(fixture); + const iterations: CancellationIterationResult[] = []; + + try { + for (const definition of createIterationDefinitions(config)) { + console.log( + `[performance] ${config.variant}/${definition.runId} starting` + ); + const result = await runIteration(config, definition, server); + iterations.push(result); + console.log( + `[performance] ${definition.runId} total=${ + result.phases.totalMs?.toFixed(3) ?? 'n/a' + }ms cancelEffect=${result.cancellationEffectObserved}` + ); + } + } finally { + await server.close(); + } + + const manifest: CancellationBenchmarkManifest = Object.freeze({ + channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + fixtureBytes: fixture.bytes, + fixtureSha256: fixture.sha256, + gitCommit: config.gitCommit, + gitDiffSha256: config.gitDiffSha256, + gitDirty: config.gitDirty, + measuredRuns: config.measuredRuns, + memoryAccounting: Object.freeze({ + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + workerRss: 'unavailable-per-thread', + }), + scenario: SCENARIO, + syntheticSeed: SYNTHETIC_M3U_SEED, + runtime: Object.freeze({ + arch: process.arch, + electronVersion: config.electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }), + variant: config.variant, + warmupRuns: config.warmupRuns, + }); + const summary = createCancellationBenchmarkSummary(manifest, iterations); + await writeJson(join(config.outputDirectory, 'manifest.json'), manifest); + await writeJson(join(config.outputDirectory, 'summary.json'), summary); + console.log( + `[performance] completed: ${join( + config.outputDirectory, + 'summary.json' + )}` + ); +} + +async function runIteration( + config: BenchmarkConfiguration, + definition: IterationDefinition, + server: SyntheticServer +): Promise { + const iterationDirectory = join(config.outputDirectory, definition.runId); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-performance-') + ); + await mkdir(iterationDirectory, { recursive: false }); + let app: LaunchedElectronApp | null = null; + let rendererConsoleListener: ((message: ConsoleMessage) => void) | null = + null; + + try { + await assertTcpPortAvailable(RENDERER_CDP_PORT); + server.serveSeedFixture(); + app = await launchElectronApp(dataDirectory, { + args: [ + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${RENDERER_CDP_PORT}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: + process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', + }, + }); + await assertRendererCdpTarget(app.mainWindow); + app.mainWindow.setDefaultTimeout(120_000); + await installMainCapture(app.electronApp); + await seedPlaylist(app.mainWindow, server.resourceUrl); + await waitForSeedSettlement(app); + server.serveLargeFixture(); + const rendererRefreshErrors: string[] = []; + rendererConsoleListener = (message) => { + if ( + message.type() === 'error' && + message.text().includes('Error refreshing playlist:') + ) { + rendererRefreshErrors.push(message.text()); + } + }; + app.mainWindow.on('console', rendererConsoleListener); + + const diagnostic = + definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + await startMainCapture(app.electronApp, { + diagnostic, + outputDirectory: iterationDirectory, + }); + const renderer = await startRendererCapture(app.mainWindow, { + diagnostic, + outputDirectory: iterationDirectory, + sourceTitle: SOURCE_TITLE, + }); + + await renderer.markOperationStart(); + const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); + await row.locator('.refresh-btn').click(); + await renderer.waitForCancelClick(); + await Promise.all([ + renderer.waitForTerminal(), + waitForOperationSettlement(app), + ]); + expect(rendererRefreshErrors).toEqual([]); + + const [rendererMetrics, mainMetrics] = await Promise.all([ + renderer.stop(), + stopMainCapture(app.electronApp), + ]); + const result = createCancellationIterationResult({ + kind: definition.kind, + main: mainMetrics, + renderer: rendererMetrics, + runId: definition.runId, + }); + await writeJson(join(iterationDirectory, 'result.json'), result); + return result; + } finally { + if (app) { + if (rendererConsoleListener) { + app.mainWindow.off('console', rendererConsoleListener); + } + await closeElectronApp(app).catch((error: unknown) => { + console.error('[performance] Electron cleanup failed', error); + }); + } + await rm(dataDirectory, { force: true, recursive: true }); + } +} + +async function seedPlaylist(page: Page, playlistUrl: string): Promise { + await page.getByRole('button', { name: 'Add playlist' }).first().click(); + const dialog = page.locator('mat-dialog-container').last(); + await expect( + dialog.getByRole('heading', { name: 'Add playlist' }) + ).toBeVisible(); + await dialog + .getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' }) + .fill(playlistUrl); + await dialog + .getByRole('textbox', { name: 'Playlist title' }) + .fill(SOURCE_TITLE); + await dialog.getByRole('button', { name: 'Add playlist' }).last().click(); + await expect(dialog).toBeHidden(); + await waitForM3uCatalog(page); + await openSources(page); + await expect(sourceRowByTitle(page, SOURCE_TITLE).first()).toBeVisible(); + await waitForTwoAnimationFrames(page); +} + +async function waitForSeedSettlement(app: LaunchedElectronApp): Promise { + await expect + .poll( + async () => + (await readMainCaptureStatus(app.electronApp)).databasePending, + { timeout: 120_000 } + ) + .toBe(0); + await waitForTwoAnimationFrames(app.mainWindow); +} + +async function waitForOperationSettlement( + app: LaunchedElectronApp +): Promise { + await expect + .poll( + async () => { + const status = await readMainCaptureStatus(app.electronApp); + if (status.playlistResponsesSucceeded > 0) { + return ( + status.databaseUpsertsCompleted > 0 && + status.databasePending === 0 + ); + } + return ( + status.playlistTerminated > 0 && + (status.playlistResponses === 0 || + status.playlistResponsesFailed > 0) + ); + }, + { timeout: 120_000 } + ) + .toBe(true); +} + +async function waitForTwoAnimationFrames(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolvePromise) => { + requestAnimationFrame(() => + requestAnimationFrame(() => resolvePromise()) + ); + }) + ); +} + +function createIterationDefinitions( + config: BenchmarkConfiguration +): readonly IterationDefinition[] { + const definitions: IterationDefinition[] = []; + for (let index = 0; index < config.warmupRuns; index += 1) { + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: `warmup-${String(index + 1).padStart(2, '0')}`, + }); + } + for (let index = 0; index < config.measuredRuns; index += 1) { + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + }); + } + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }); + return Object.freeze(definitions); +} + +async function resolveConfiguration(): Promise { + const workspaceRoot = resolve(__dirname, '../../../..'); + const performanceRoot = join(workspaceRoot, 'dist', 'performance'); + const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(requestedRoot) || + !isStrictDescendant(performanceRoot, outputRoot) + ) { + throw new Error( + 'Performance output must be an absolute path below dist/performance' + ); + } + const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + if (!OUTPUT_VARIANT_PATTERN.test(variant)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1'; + const sourceState = readGitSourceState(workspaceRoot); + if (!smoke && sourceState.dirty) { + throw new Error( + 'Formal performance runs require a clean Git worktree; commit or stash changes first' + ); + } + const electronPackage = JSON.parse( + await readFile( + join(workspaceRoot, 'node_modules', 'electron', 'package.json'), + 'utf8' + ) + ) as { version?: unknown }; + if (typeof electronPackage.version !== 'string') { + throw new Error('Unable to resolve the Electron runtime version'); + } + const outputDirectory = join(outputRoot, variant); + await assertMissing(outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + return Object.freeze({ + electronVersion: electronPackage.version, + gitCommit: sourceState.commit, + gitDiffSha256: sourceState.diffSha256, + gitDirty: sourceState.dirty, + measuredRuns: smoke ? 1 : DEFAULT_MEASURED_RUNS, + outputDirectory, + variant, + warmupRuns: DEFAULT_WARMUP_RUNS, + workspaceRoot, + }); +} + +function readGitSourceState(workspaceRoot: string): { + readonly commit: string; + readonly diffSha256: string; + readonly dirty: boolean; +} { + const git = (args: readonly string[]): string => + execFileSync('git', [...args], { + cwd: workspaceRoot, + encoding: 'utf8', + }); + const commit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const trackedDiff = git(['diff', '--binary', 'HEAD']); + const stagedDiff = git(['diff', '--binary', '--cached', 'HEAD']); + const diffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(trackedDiff) + .update('\0') + .update(stagedDiff) + .digest('hex'); + return Object.freeze({ + commit, + diffSha256, + dirty: status.trim().length > 0, + }); +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(resolve(parent), resolve(candidate)); + return ( + pathFromParent.length > 0 && + pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +async function assertTcpPortAvailable(port: number): Promise { + const server = createTcpServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen( + { exclusive: true, host: '127.0.0.1', port }, + resolvePromise + ); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +async function assertRendererCdpTarget(page: Page): Promise { + await expect + .poll( + async () => { + try { + const response = await fetch( + `http://127.0.0.1:${RENDERER_CDP_PORT}/json/list` + ); + if (!response.ok) { + return false; + } + const targets = (await response.json()) as unknown; + return ( + Array.isArray(targets) && + targets.some( + (target) => + typeof target === 'object' && + target !== null && + (target as Record)['type'] === + 'page' && + (target as Record)['url'] === + page.url() + ) + ); + } catch { + return false; + } + }, + { timeout: 10_000 } + ) + .toBe(true); +} + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch { + return; + } + throw new Error(`Performance output already exists: ${path}`); +} + +async function startSyntheticServer( + fixture: SyntheticM3uFixture +): Promise { + const seedBody = createSeedPlaylistBody(); + let body = seedBody; + const server = createServer((request, response) => { + if ( + request.method !== 'GET' || + new URL(request.url ?? '/', 'http://127.0.0.1').pathname !== + RESOURCE_PATH + ) { + response.writeHead(404, { 'Content-Length': '0' }); + response.end(); + return; + } + const bytes = Buffer.byteLength(body, 'utf8'); + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': String(bytes), + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + }); + response.end(body); + }); + server.listen({ exclusive: true, host: '127.0.0.1', port: 0 }); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Synthetic M3U server did not bind IPv4 loopback'); + } + return Object.freeze({ + close: () => closeServer(server), + resourceUrl: `http://127.0.0.1:${address.port}${RESOURCE_PATH}`, + serveLargeFixture: () => { + body = fixture.body; + }, + serveSeedFixture: () => { + body = seedBody; + }, + }); +} + +function createSeedPlaylistBody(): string { + const lines = ['#EXTM3U']; + for (let index = 1; index <= 100; index += 1) { + lines.push( + `#EXTINF:-1 group-title="Synthetic Seed",Synthetic Seed ${String( + index + ).padStart(3, '0')}` + ); + lines.push(`http://127.0.0.1/seed/${index}`); + } + return `${lines.join('\n')}\n`; +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts new file mode 100644 index 000000000..fd9435445 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -0,0 +1,871 @@ +/* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ +import type { ElectronApplication } from '@playwright/test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; + +const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; + +export interface MainCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; +} + +export interface MainCaptureStatus { + readonly databasePending: number; + readonly databaseRequests: number; + readonly databaseUpsertsCompleted: number; + readonly playlistTerminated: number; + readonly playlistResponsesFailed: number; + readonly playlistResponses: number; + readonly playlistResponsesSucceeded: number; +} + +export async function installMainCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + type JsonRecord = Record; + type WorkerTransferable = import('node:worker_threads').Transferable; + interface CpuProfileHandle { + stop(): Promise; + } + interface WorkerHeapStatistics { + external_memory?: number; + used_heap_size?: number; + } + interface WorkerCpuUsage { + system: number; + user: number; + } + interface WorkerElu { + active: number; + idle: number; + utilization: number; + } + interface InstrumentedWorker { + cpuUsage?(): Promise; + getHeapSnapshot?(): Promise; + getHeapStatistics?(): Promise; + on(event: 'message', listener: (message: unknown) => void): this; + performance?: { + eventLoopUtilization( + utilization1?: WorkerElu, + utilization2?: WorkerElu + ): WorkerElu; + }; + postMessage( + message: unknown, + transferList?: readonly WorkerTransferable[] + ): void; + startCpuProfile?(): Promise; + terminate(): Promise; + } + interface WorkerRecord { + cancelPostedEpochMs: number | null; + cpuFirst: WorkerCpuUsage | null; + cpuLast: WorkerCpuUsage | null; + elu: number | null; + eluStart: WorkerElu | null; + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + } | null; + externalPeak: number; + finalized: boolean; + finalizing: Promise | null; + heapPeak: number; + kind: 'database.worker' | 'playlist-refresh.worker'; + operationId: string | null; + playlistId: string | null; + postGcHeapUsed: number | null; + profileHandle: Promise | null; + profilePath: string | null; + responseEpochMs: number | null; + sampleBusy: boolean; + sampleTimer: NodeJS.Timeout | null; + snapshotPath: string | null; + terminatedEpochMs: number | null; + worker: InstrumentedWorker; + } + interface TimelineRecord { + readonly epochMs: number; + readonly operation?: string; + readonly operationId?: string; + readonly playlistId?: string; + readonly requestId?: string; + readonly success?: boolean; + readonly type: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const runtimeProcess = process as typeof process & { + getBuiltinModule(id: string): unknown; + }; + const fs = runtimeProcess.getBuiltinModule( + 'node:fs' + ) as typeof import('node:fs'); + const inspector = runtimeProcess.getBuiltinModule( + 'node:inspector' + ) as typeof import('node:inspector'); + const path = runtimeProcess.getBuiltinModule( + 'node:path' + ) as typeof import('node:path'); + const perfHooks = runtimeProcess.getBuiltinModule( + 'node:perf_hooks' + ) as typeof import('node:perf_hooks'); + const streamPromises = runtimeProcess.getBuiltinModule( + 'node:stream/promises' + ) as typeof import('node:stream/promises'); + const workerThreads = runtimeProcess.getBuiltinModule( + 'node:worker_threads' + ) as typeof import('node:worker_threads'); + const WorkerClass = workerThreads.Worker; + const originalPostMessage = WorkerClass.prototype.postMessage; + const originalTerminate = WorkerClass.prototype.terminate; + const records = new Map(); + const operationWorkers = new Map(); + const dbRequests = new Map< + string, + { + operation: string; + playlistId: string | null; + record: WorkerRecord; + } + >(); + + const state = { + active: false, + cpuStart: null as NodeJS.CpuUsage | null, + diagnostic: false, + eventLoopDelay: null as ReturnType< + typeof perfHooks.monitorEventLoopDelay + > | null, + eventLoopStart: null as ReturnType< + typeof perfHooks.performance.eventLoopUtilization + > | null, + inspectorSession: null as import('node:inspector').Session | null, + mainPeakHeap: 0, + mainPeakRss: 0, + mainProfilePath: null as string | null, + mainSnapshotPath: null as string | null, + outputDirectory: '', + postGcHeap: null as number | null, + postGcRss: null as number | null, + rendererPeakRss: 0, + responsiveEvents: 0, + sampleTimer: null as NodeJS.Timeout | null, + timeline: [] as TimelineRecord[], + unresponsiveEvents: 0, + windowListeners: [] as { + responsive: () => void; + unresponsive: () => void; + window: Electron.BrowserWindow; + }[], + }; + + const nowEpochMs = (): number => + perfHooks.performance.timeOrigin + perfHooks.performance.now(); + const recordTimeline = ( + record: Omit + ): void => { + if (state.active) { + state.timeline.push({ epochMs: nowEpochMs(), ...record }); + } + }; + const classifyRequest = ( + message: JsonRecord + ): 'database.worker' | 'playlist-refresh.worker' | null => { + if (typeof message['operation'] === 'string') { + return 'database.worker'; + } + const payload = message['payload']; + return typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)['operationId'] === 'string' && + typeof (payload as JsonRecord)['playlistId'] === 'string' + ? 'playlist-refresh.worker' + : null; + }; + const readDatabasePlaylistId = (message: JsonRecord): string | null => { + const payload = message['payload']; + if (typeof payload !== 'object' || payload === null) { + return null; + } + const value = payload as JsonRecord; + const playlistId = value['playlistId'] ?? value['_id']; + return typeof playlistId === 'string' ? playlistId : null; + }; + const createWorkerRecord = ( + worker: InstrumentedWorker, + kind: WorkerRecord['kind'] + ): WorkerRecord => { + const existing = records.get(worker); + if (existing) { + return existing; + } + const record: WorkerRecord = { + cancelPostedEpochMs: null, + cpuFirst: null, + cpuLast: null, + elu: null, + eluStart: null, + eventLoopDelay: null, + externalPeak: 0, + finalized: false, + finalizing: null, + heapPeak: 0, + kind, + operationId: null, + playlistId: null, + postGcHeapUsed: null, + profileHandle: null, + profilePath: null, + responseEpochMs: null, + sampleBusy: false, + sampleTimer: null, + snapshotPath: null, + terminatedEpochMs: null, + worker, + }; + records.set(worker, record); + worker.on('message', (incoming) => { + if (typeof incoming !== 'object' || incoming === null) { + return; + } + const message = incoming as JsonRecord; + const performanceCapture = readWorkerPerformance(message); + if (performanceCapture) { + record.eventLoopDelay = record.eventLoopDelay + ? { + maxMs: Math.max( + record.eventLoopDelay.maxMs, + performanceCapture.eventLoopDelay.maxMs + ), + p95Ms: Math.max( + record.eventLoopDelay.p95Ms, + performanceCapture.eventLoopDelay.p95Ms + ), + p99Ms: Math.max( + record.eventLoopDelay.p99Ms, + performanceCapture.eventLoopDelay.p99Ms + ), + } + : performanceCapture.eventLoopDelay; + } + if (record.kind === 'playlist-refresh.worker') { + if (message['type'] === 'event') { + const event = message['event'] as + | JsonRecord + | undefined; + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `playlist-event:${String( + event?.['status'] ?? 'unknown' + )}:${String(event?.['phase'] ?? 'none')}`, + }); + } else if (message['type'] === 'response') { + record.responseEpochMs = nowEpochMs(); + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + success: message['success'] === true, + type: 'playlist-response', + }); + } + return; + } + if ( + message['type'] === 'response' && + typeof message['requestId'] === 'string' + ) { + const request = dbRequests.get(message['requestId']); + if (request) { + dbRequests.delete(message['requestId']); + recordTimeline({ + operation: request.operation, + playlistId: request.playlistId ?? undefined, + requestId: message['requestId'], + success: message['success'] === true, + type: 'db-response', + }); + } + } + }); + return record; + }; + const readWorkerPerformance = ( + message: JsonRecord + ): { + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + }; + } | null => { + const performanceCapture = message['performance']; + if ( + typeof performanceCapture !== 'object' || + performanceCapture === null + ) { + return null; + } + const eventLoopDelay = (performanceCapture as JsonRecord)[ + 'eventLoopDelay' + ]; + if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { + return null; + } + const value = eventLoopDelay as JsonRecord; + const maxMs = value['maxMs']; + const p95Ms = value['p95Ms']; + const p99Ms = value['p99Ms']; + if ( + typeof maxMs !== 'number' || + !Number.isFinite(maxMs) || + typeof p95Ms !== 'number' || + !Number.isFinite(p95Ms) || + typeof p99Ms !== 'number' || + !Number.isFinite(p99Ms) + ) { + return null; + } + return { + eventLoopDelay: { maxMs, p95Ms, p99Ms }, + }; + }; + const sampleWorker = async (record: WorkerRecord): Promise => { + if (record.sampleBusy || record.finalized) { + return; + } + record.sampleBusy = true; + try { + const stats = await record.worker.getHeapStatistics?.(); + if (stats) { + record.heapPeak = Math.max( + record.heapPeak, + Number(stats.used_heap_size ?? 0) + ); + record.externalPeak = Math.max( + record.externalPeak, + Number(stats.external_memory ?? 0) + ); + } + const cpu = await record.worker.cpuUsage?.(); + if (cpu) { + record.cpuFirst ??= cpu; + record.cpuLast = cpu; + } + const elu = record.worker.performance?.eventLoopUtilization( + record.eluStart ?? undefined + ); + if (elu) { + record.elu = elu.utilization; + } + } catch { + // A one-shot worker may terminate between sampling calls. + } finally { + record.sampleBusy = false; + } + }; + const startWorker = (record: WorkerRecord): void => { + if (!state.active || record.sampleTimer !== null) { + return; + } + record.elu = null; + record.eluStart = + record.worker.performance?.eventLoopUtilization() ?? null; + record.eventLoopDelay = null; + void sampleWorker(record); + record.sampleTimer = setInterval( + () => void sampleWorker(record), + 20 + ); + if ( + state.diagnostic && + typeof record.worker.startCpuProfile === 'function' + ) { + record.profilePath = path.join( + state.outputDirectory, + `${record.kind}.cpuprofile` + ); + record.profileHandle = record.worker.startCpuProfile(); + } + }; + const finalizeWorker = (record: WorkerRecord): Promise => { + record.finalizing ??= (async () => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + await sampleWorker(record); + if (record.profileHandle && record.profilePath) { + const handle = await record.profileHandle; + const profile = await handle.stop(); + fs.writeFileSync( + record.profilePath, + JSON.stringify(normalizeWorkerCpuProfile(profile)) + ); + } + if ( + state.diagnostic && + typeof record.worker.getHeapSnapshot === 'function' + ) { + record.snapshotPath = path.join( + state.outputDirectory, + `${record.kind}.heapsnapshot` + ); + const snapshot = await record.worker.getHeapSnapshot(); + await streamPromises.pipeline( + snapshot, + fs.createWriteStream(record.snapshotPath) + ); + const postSnapshot = + await record.worker.getHeapStatistics?.(); + record.postGcHeapUsed = Number( + postSnapshot?.used_heap_size ?? 0 + ); + } + record.finalized = true; + })().catch((error: unknown) => { + recordTimeline({ + type: `worker-profile-error:${ + error instanceof Error + ? error.message.slice(0, 160) + : String(error).slice(0, 160) + }`, + }); + record.finalized = true; + }); + return record.finalizing; + }; + + WorkerClass.prototype.postMessage = function ( + this: InstrumentedWorker, + message: unknown, + transferList?: readonly WorkerTransferable[] + ): void { + if (typeof message === 'object' && message !== null) { + const value = message as JsonRecord; + if (value['type'] === 'request') { + const kind = classifyRequest(value); + if (kind) { + const record = createWorkerRecord(this, kind); + if (kind === 'playlist-refresh.worker') { + const payload = value['payload'] as JsonRecord; + record.operationId = String(payload['operationId']); + record.playlistId = String(payload['playlistId']); + operationWorkers.set(record.operationId, record); + recordTimeline({ + operationId: record.operationId, + playlistId: record.playlistId, + type: 'playlist-request', + }); + } else if ( + typeof value['requestId'] === 'string' && + typeof value['operation'] === 'string' + ) { + dbRequests.set(value['requestId'], { + operation: value['operation'], + playlistId: readDatabasePlaylistId(value), + record, + }); + recordTimeline({ + operation: value['operation'], + playlistId: + readDatabasePlaylistId(value) ?? undefined, + requestId: value['requestId'], + type: 'db-request', + }); + } + startWorker(record); + } + } else if ( + value['type'] === 'cancel' && + typeof value['operationId'] === 'string' + ) { + const record = operationWorkers.get(value['operationId']); + if (record) { + record.cancelPostedEpochMs = nowEpochMs(); + recordTimeline({ + operationId: value['operationId'], + type: 'playlist-cancel-posted', + }); + } + } + } + originalPostMessage.call(this, message, transferList); + }; + WorkerClass.prototype.terminate = function ( + this: InstrumentedWorker + ): Promise { + const record = records.get(this); + if (!record || !state.active) { + return originalTerminate.call(this); + } + const markTerminated = (code: number): number => { + record.terminatedEpochMs = nowEpochMs(); + record.finalized = true; + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `${record.kind}-terminated`, + }); + return code; + }; + if (!state.diagnostic) { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + return originalTerminate.call(this).then(markTerminated); + } + return finalizeWorker(record) + .then(() => originalTerminate.call(this)) + .then(markTerminated); + }; + + const inspectorPost = ( + session: import('node:inspector').Session, + method: string, + params: JsonRecord = {} + ): Promise => + new Promise((resolve, reject) => { + session.post(method, params, (error, result) => { + if (error) { + reject(error); + } else { + resolve((result ?? {}) as JsonRecord); + } + }); + }); + const normalizeWorkerCpuProfile = (profile: unknown): unknown => { + if (typeof profile !== 'string') { + return profile; + } + try { + return JSON.parse(profile) as unknown; + } catch { + // Node 24's Worker CPU profiler can leave quotes inside + // RegExp pseudo-frame names unescaped. Repair only those + // generated frame names, then require the full JSON to parse. + const fieldPrefix = '"functionName":"RegExp: '; + const fieldSuffix = '","lineNumber":'; + const valuePrefixLength = '"functionName":"'.length; + let cursor = 0; + let repaired = ''; + while (true) { + const fieldStart = profile.indexOf(fieldPrefix, cursor); + if (fieldStart < 0) { + repaired += profile.slice(cursor); + break; + } + const valueStart = fieldStart + valuePrefixLength; + const valueEnd = profile.indexOf(fieldSuffix, valueStart); + if (valueEnd < 0) { + throw new Error( + 'Worker CPU profile has an unterminated RegExp frame' + ); + } + repaired += + profile.slice(cursor, fieldStart) + + '"functionName":' + + JSON.stringify(profile.slice(valueStart, valueEnd)); + cursor = valueEnd + 1; + } + return JSON.parse(repaired) as unknown; + } + }; + const sampleMain = (): void => { + const memory = process.memoryUsage(); + state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); + state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); + for (const metric of app.getAppMetrics()) { + const type = String(metric.type).toLowerCase(); + if (type.includes('tab') || type.includes('renderer')) { + state.rendererPeakRss = Math.max( + state.rendererPeakRss, + Number(metric.memory?.workingSetSize ?? 0) * 1024 + ); + } + } + }; + const attachWindowListeners = (): void => { + for (const window of BrowserWindow.getAllWindows()) { + const unresponsive = () => { + state.unresponsiveEvents += 1; + }; + const responsive = () => { + state.responsiveEvents += 1; + }; + window.on('unresponsive', unresponsive); + window.on('responsive', responsive); + state.windowListeners.push({ + responsive, + unresponsive, + window, + }); + } + }; + + const api = { + status: (): MainCaptureStatus => ({ + databasePending: dbRequests.size, + databaseRequests: state.timeline.filter( + (entry) => entry['type'] === 'db-request' + ).length, + databaseUpsertsCompleted: state.timeline.filter( + (entry) => + entry['type'] === 'db-response' && + entry['operation'] === 'DB_UPSERT_APP_PLAYLIST' && + entry['success'] === true + ).length, + playlistTerminated: [...records.values()].filter( + (record) => + record.kind === 'playlist-refresh.worker' && + record.terminatedEpochMs !== null + ).length, + playlistResponsesFailed: state.timeline.filter( + (entry) => + entry['type'] === 'playlist-response' && + entry['success'] === false + ).length, + playlistResponses: state.timeline.filter( + (entry) => entry['type'] === 'playlist-response' + ).length, + playlistResponsesSucceeded: state.timeline.filter( + (entry) => + entry['type'] === 'playlist-response' && + entry['success'] === true + ).length, + }), + start: async (options: MainCaptureStartOptions): Promise => { + state.active = true; + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.rendererPeakRss = 0; + state.postGcHeap = null; + state.postGcRss = null; + state.unresponsiveEvents = 0; + state.responsiveEvents = 0; + state.cpuStart = process.cpuUsage(); + state.eventLoopStart = + perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + attachWindowListeners(); + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + if (state.diagnostic) { + const session = new inspector.Session(); + session.connect(); + state.inspectorSession = session; + await inspectorPost(session, 'Profiler.enable'); + await inspectorPost(session, 'Profiler.start'); + state.mainProfilePath = path.join( + state.outputDirectory, + 'main.cpuprofile' + ); + state.mainSnapshotPath = path.join( + state.outputDirectory, + 'main.heapsnapshot' + ); + } + }, + stop: async (): Promise => { + if (state.sampleTimer) { + clearInterval(state.sampleTimer); + state.sampleTimer = null; + } + state.eventLoopDelay?.disable(); + sampleMain(); + const cpu = process.cpuUsage(state.cpuStart ?? undefined); + const eluEnd = perfHooks.performance.eventLoopUtilization(); + const elu = perfHooks.performance.eventLoopUtilization( + state.eventLoopStart ?? undefined + ); + const eventLoopUtilization = + perfHooks.performance.nodeTiming.loopStart < 0 || + (elu.active === 0 && + elu.idle === 0 && + eluEnd.active === 0 && + eluEnd.idle === 0) + ? null + : elu.utilization; + const delay = state.eventLoopDelay; + const databaseRecords = [...records.values()].filter( + (record) => + record.kind === 'database.worker' && + record.sampleTimer !== null + ); + await Promise.all( + databaseRecords.map((record) => finalizeWorker(record)) + ); + const session = + state.inspectorSession ?? new inspector.Session(); + if (!state.inspectorSession) { + session.connect(); + } + if (state.diagnostic && state.mainProfilePath) { + const result = await inspectorPost( + session, + 'Profiler.stop' + ); + fs.writeFileSync( + state.mainProfilePath, + JSON.stringify(result['profile']) + ); + } + await inspectorPost(session, 'HeapProfiler.enable'); + await inspectorPost(session, 'HeapProfiler.collectGarbage'); + const postGc = process.memoryUsage(); + state.postGcHeap = postGc.heapUsed; + state.postGcRss = postGc.rss; + if (state.diagnostic && state.mainSnapshotPath) { + const output = fs.createWriteStream(state.mainSnapshotPath); + const onChunk = (message: { params: { chunk: string } }) => + output.write(message.params.chunk); + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + await inspectorPost( + session, + 'HeapProfiler.takeHeapSnapshot', + { reportProgress: false } + ); + session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + output.end(); + await new Promise((resolve, reject) => { + output.once('finish', resolve); + output.once('error', reject); + }); + } + session.disconnect(); + state.inspectorSession = null; + for (const listener of state.windowListeners) { + listener.window.off('unresponsive', listener.unresponsive); + listener.window.off('responsive', listener.responsive); + } + state.windowListeners = []; + state.active = false; + + const workers = [...records.values()] + .filter( + (record) => + record.kind === 'playlist-refresh.worker' || + record.heapPeak > 0 + ) + .map((record) => ({ + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopDelay: record.eventLoopDelay, + eventLoopDelayUnavailableReason: + record.eventLoopDelay === null + ? record.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable' + : null, + eventLoopUtilization: record.elu, + kind: record.kind, + operationId: record.operationId, + peakExternalBytes: record.externalPeak, + peakHeapUsedBytes: record.heapPeak, + playlistId: record.playlistId, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + })); + return { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererPeakRssBytes: state.rendererPeakRss, + responsiveEvents: state.responsiveEvents, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, + unresponsiveEvents: state.unresponsiveEvents, + workers, + }; + }, + }; + target[stateKey] = api; + }, MAIN_CAPTURE_STATE_KEY); +} + +export async function startMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + start(options: MainCaptureStartOptions): Promise; + }; + await api.start(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); +} + +export async function readMainCaptureStatus( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + status(): MainCaptureStatus; + }; + return api.status(); + }, MAIN_CAPTURE_STATE_KEY); +} + +export async function stopMainCapture( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, MAIN_CAPTURE_STATE_KEY); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts new file mode 100644 index 000000000..a759db490 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -0,0 +1,508 @@ +/* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */ +import { once } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CDPSession, Page } from '@playwright/test'; + +import type { + RendererCaptureMetrics, + RendererProbeMetrics, +} from './m3u-refresh-cancellation-contract'; +import { summarizeNumbers } from './performance-statistics'; + +const RENDERER_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshRendererCapture'; +const HEARTBEAT_INTERVAL_MS = 50; + +export interface RendererCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly sourceTitle: string; +} + +export interface RunningRendererCapture { + readonly session: CDPSession; + markOperationStart(): Promise; + stop(): Promise; + waitForCancelClick(): Promise; + waitForTerminal(): Promise; +} + +interface HeapUsageResult { + readonly usedSize: number; +} + +interface TraceData { + readonly value: readonly unknown[]; +} + +export async function startRendererCapture( + page: Page, + options: RendererCaptureStartOptions +): Promise { + const session = await page.context().newCDPSession(page); + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installRendererProbe(page, options.sourceTitle); + + const heapSamples: number[] = []; + let heapSampleBusy = false; + let heapSampleError: unknown = null; + const sampleHeap = async (): Promise => { + if (heapSampleBusy) { + return; + } + heapSampleBusy = true; + try { + const result = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (Number.isFinite(result.usedSize)) { + heapSamples.push(result.usedSize); + } + } catch (error) { + heapSampleError ??= error; + } finally { + heapSampleBusy = false; + } + }; + await sampleHeap(); + throwHeapSampleError(heapSampleError); + const heapSampleTimer = setInterval(() => void sampleHeap(), 20); + + const cpuProfilePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.cpuprofile') + : null; + const heapSnapshotPath = options.diagnostic + ? join(options.outputDirectory, 'renderer.heapsnapshot') + : null; + const tracePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.trace.json') + : null; + let traceOutput: ReturnType | null = null; + let traceFirstEvent = true; + let resolveTraceComplete: (() => void) | null = null; + const traceComplete = new Promise((resolve) => { + resolveTraceComplete = resolve; + }); + const onTraceData = (data: TraceData): void => { + if (!traceOutput) { + return; + } + for (const event of data.value) { + traceOutput.write( + `${traceFirstEvent ? '' : ','}${JSON.stringify(event)}` + ); + traceFirstEvent = false; + } + }; + const onTraceComplete = (): void => { + resolveTraceComplete?.(); + resolveTraceComplete = null; + }; + + if (options.diagnostic) { + await session.send('Profiler.enable'); + await session.send('Profiler.start'); + traceOutput = createWriteStream( + requireArtifactPath(tracePath, 'renderer trace') + ); + traceOutput.write('{"traceEvents":['); + session.on('Tracing.dataCollected', onTraceData); + session.on('Tracing.tracingComplete', onTraceComplete); + await session.send('Tracing.start', { + categories: [ + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-v8.cpu_profiler', + 'v8', + ].join(','), + options: 'sampling-frequency=1000', + transferMode: 'ReportEvents', + }); + } + + let stopped = false; + return Object.freeze({ + session, + markOperationStart: () => markRendererOperationStart(page), + stop: async () => { + if (stopped) { + throw new Error('Renderer capture was already stopped'); + } + stopped = true; + const probe = await stopRendererProbe(page); + clearInterval(heapSampleTimer); + await sampleHeap(); + throwHeapSampleError(heapSampleError); + + if (options.diagnostic) { + const cpuProfile = await session.send('Profiler.stop'); + await writeFile( + requireArtifactPath(cpuProfilePath, 'renderer CPU profile'), + JSON.stringify(cpuProfile['profile']), + 'utf8' + ); + await session.send('Tracing.end'); + await traceComplete; + session.off('Tracing.dataCollected', onTraceData); + session.off('Tracing.tracingComplete', onTraceComplete); + traceOutput?.write(']}'); + traceOutput?.end(); + if (traceOutput) { + await once(traceOutput, 'finish'); + } + } + + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + + if (options.diagnostic) { + const output = createWriteStream( + requireArtifactPath( + heapSnapshotPath, + 'renderer heap snapshot' + ) + ); + const onSnapshotChunk = (message: { + readonly chunk: string; + }): void => { + output.write(message.chunk); + }; + session.on( + 'HeapProfiler.addHeapSnapshotChunk', + onSnapshotChunk + ); + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + }); + session.off( + 'HeapProfiler.addHeapSnapshotChunk', + onSnapshotChunk + ); + output.end(); + await once(output, 'finish'); + } + + await session.detach(); + return Object.freeze({ + cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath, + heartbeatDelay: summarizeNumbers(probe.heartbeatDelaysMs), + longTask: summarizeNumbers(probe.longTasksMs), + peakHeapUsedBytes: Math.max(0, ...heapSamples), + postGcHeapUsedBytes: postGc.usedSize, + probe, + tracePath, + }); + }, + waitForCancelClick: () => + page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { cancelClickEpochMs: number | null } + | undefined; + return typeof state?.cancelClickEpochMs === 'number'; + }, + RENDERER_CAPTURE_STATE_KEY, + { timeout: 60_000 } + ) + .then(() => undefined), + waitForTerminal: () => + page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { uiPaintedEpochMs: number | null } + | undefined; + return typeof state?.uiPaintedEpochMs === 'number'; + }, + RENDERER_CAPTURE_STATE_KEY, + { timeout: 120_000 } + ) + .then(() => undefined), + }); +} + +function throwHeapSampleError(error: unknown): void { + if (error instanceof Error) { + throw error; + } + if (error !== null) { + throw new Error(`Renderer heap sampling failed: ${String(error)}`); + } +} + +function requireArtifactPath(value: string | null, label: string): string { + if (value === null) { + throw new Error(`${label} path is unavailable`); + } + return value; +} + +async function installRendererProbe( + page: Page, + sourceTitle: string +): Promise { + await page.evaluate( + ({ heartbeatIntervalMs, sourceTitle: title, stateKey }) => { + interface PlaylistRefreshEvent { + readonly operationId: string; + readonly phase?: string; + readonly status: string; + } + interface ElectronApi { + onPlaylistRefreshEvent( + callback: (event: PlaylistRefreshEvent) => void + ): (() => void) | undefined; + } + interface ProbeState { + cancelButtonFound: boolean; + cancelClickEpochMs: number | null; + events: { + operationId: string; + phase: string | null; + receivedEpochMs: number; + status: string; + }[]; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + heartbeatTimer: number; + lastFrameAt: number | null; + lastHeartbeatAt: number; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + mutationObserver: MutationObserver; + operationId: string | null; + operationStartEpochMs: number; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + uiPhaseEpochMs: Record; + uiSettledEpochMs: number | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + const epoch = (): number => + performance.timeOrigin + performance.now(); + const sourceRow = (): Element | undefined => + Array.from(document.querySelectorAll('app-playlist-item')).find( + (element) => element.textContent?.includes(title) + ); + const state = { + cancelButtonFound: false, + cancelClickEpochMs: null, + events: [], + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + heartbeatTimer: 0, + lastFrameAt: null, + lastHeartbeatAt: performance.now(), + longTaskObserver: null, + longTasksMs: [], + mutationObserver: null, + operationId: null, + operationStartEpochMs: 0, + terminalEpochMs: null, + uiPaintFrameRequestId: null, + uiPaintedEpochMs: null, + uiPhaseEpochMs: Object.create(null) as Record, + uiSettledEpochMs: null, + unsubscribe: () => undefined, + } as unknown as ProbeState; + const inspectUi = (): void => { + const row = sourceRow(); + const message = + row + ?.querySelector('.busy-state__message') + ?.textContent?.trim() + .toLowerCase() ?? ''; + for (const phase of ['fetch', 'pars', 'sav']) { + if ( + message.includes(phase) && + state.uiPhaseEpochMs[phase] === undefined + ) { + state.uiPhaseEpochMs[phase] = epoch(); + } + } + if ( + state.cancelClickEpochMs !== null && + state.uiSettledEpochMs === null && + row !== undefined && + row.querySelector('.busy-state__message') === null && + row.querySelector('.action-spinner') === null && + row.querySelector('.cancel-btn') === null + ) { + state.uiSettledEpochMs = epoch(); + state.uiPaintFrameRequestId = requestAnimationFrame(() => { + state.uiPaintFrameRequestId = requestAnimationFrame( + () => { + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + state.uiPaintFrameRequestId = null; + } + ); + }); + } + }; + const frame = (timestamp: number): void => { + if (state.lastFrameAt !== null) { + state.frameGapsMs.push(timestamp - state.lastFrameAt); + } + state.lastFrameAt = timestamp; + state.frameRequestId = requestAnimationFrame(frame); + }; + state.frameRequestId = requestAnimationFrame(frame); + state.heartbeatTimer = window.setInterval(() => { + const now = performance.now(); + state.heartbeatDelaysMs.push( + Math.max( + 0, + now - state.lastHeartbeatAt - heartbeatIntervalMs + ) + ); + state.lastHeartbeatAt = now; + }, heartbeatIntervalMs); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.longTasksMs.push(entry.duration); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + state.mutationObserver = new MutationObserver(inspectUi); + state.mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + const electron = ( + globalThis as unknown as { electron?: ElectronApi } + ).electron; + if (!electron?.onPlaylistRefreshEvent) { + throw new Error('Playlist refresh event bridge is unavailable'); + } + const unsubscribe = electron.onPlaylistRefreshEvent((event) => { + const receivedEpochMs = epoch(); + if (state.operationId === null && event.status === 'started') { + state.operationId = event.operationId; + } + if (state.operationId !== event.operationId) { + return; + } + state.events.push({ + operationId: event.operationId, + phase: event.phase ?? null, + receivedEpochMs, + status: event.status, + }); + if ( + event.status === 'progress' && + event.phase === 'parsing' && + state.cancelClickEpochMs === null + ) { + const button = sourceRow()?.querySelector('.cancel-btn'); + state.cancelButtonFound = + button instanceof HTMLButtonElement; + if (button instanceof HTMLButtonElement) { + state.cancelClickEpochMs = epoch(); + button.click(); + } + } + }); + state.unsubscribe = + typeof unsubscribe === 'function' + ? unsubscribe + : () => undefined; + target[stateKey] = state; + inspectUi(); + }, + { + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + sourceTitle, + stateKey: RENDERER_CAPTURE_STATE_KEY, + } + ); +} + +async function markRendererOperationStart(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as { operationStartEpochMs: number }; + state.operationStartEpochMs = + performance.timeOrigin + performance.now(); + return state.operationStartEpochMs; + }, RENDERER_CAPTURE_STATE_KEY); +} + +async function stopRendererProbe(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as { + cancelButtonFound: boolean; + cancelClickEpochMs: number | null; + events: RendererProbeMetrics['events']; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + heartbeatTimer: number; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + mutationObserver: MutationObserver; + operationStartEpochMs: number; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + uiPhaseEpochMs: Record; + uiSettledEpochMs: number | null; + unsubscribe: () => void; + }; + cancelAnimationFrame(state.frameRequestId); + if (state.uiPaintFrameRequestId !== null) { + cancelAnimationFrame(state.uiPaintFrameRequestId); + } + clearInterval(state.heartbeatTimer); + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.longTasksMs.push(entry.duration); + } + state.longTaskObserver?.disconnect(); + state.mutationObserver.disconnect(); + state.unsubscribe(); + const result: RendererProbeMetrics = { + cancelButtonFound: state.cancelButtonFound, + cancelClickEpochMs: state.cancelClickEpochMs, + events: [...state.events], + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + uiPhaseEpochMs: { ...state.uiPhaseEpochMs }, + uiSettledEpochMs: state.uiSettledEpochMs, + }; + delete target[stateKey]; + return result; + }, RENDERER_CAPTURE_STATE_KEY); +} diff --git a/apps/electron-backend-e2e/src/performance/performance-statistics.ts b/apps/electron-backend-e2e/src/performance/performance-statistics.ts new file mode 100644 index 000000000..ab7d53140 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-statistics.ts @@ -0,0 +1,64 @@ +import type { NumericDistribution } from './m3u-refresh-cancellation-contract'; + +export function summarizeNumbers( + values: readonly (number | null | undefined)[] +): NumericDistribution { + const samples = values + .filter( + (value): value is number => value !== null && value !== undefined + ) + .filter((value) => Number.isFinite(value) && value >= 0) + .sort((left, right) => left - right); + + if (samples.length === 0) { + return Object.freeze({ + count: 0, + max: null, + mean: null, + median: null, + min: null, + p95: null, + p99: null, + }); + } + + const total = samples.reduce((sum, value) => sum + value, 0); + return Object.freeze({ + count: samples.length, + max: samples.at(-1) ?? null, + mean: total / samples.length, + median: percentile(samples, 50), + min: samples[0] ?? null, + p95: percentile(samples, 95), + p99: percentile(samples, 99), + }); +} + +export function nonNegativeDifference( + endEpochMs: number | null | undefined, + startEpochMs: number | null | undefined +): number | null { + if ( + endEpochMs === null || + endEpochMs === undefined || + startEpochMs === null || + startEpochMs === undefined + ) { + return null; + } + const difference = endEpochMs - startEpochMs; + return Number.isFinite(difference) && difference >= 0 ? difference : null; +} + +function percentile(sortedValues: readonly number[], rank: number): number { + if (sortedValues.length === 1) { + return sortedValues[0] ?? 0; + } + + const position = ((sortedValues.length - 1) * rank) / 100; + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const lower = sortedValues[lowerIndex] ?? 0; + const upper = sortedValues[upperIndex] ?? lower; + return lower + (upper - lower) * (position - lowerIndex); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts new file mode 100644 index 000000000..14e65387d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; + +export const SYNTHETIC_M3U_SEED = 240_724; +export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; + +export interface SyntheticM3uFixture { + readonly body: string; + readonly bytes: number; + readonly channelCount: number; + readonly sha256: string; +} + +export function createSyntheticM3uFixture(): SyntheticM3uFixture { + const lines = new Array(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2); + lines[0] = '#EXTM3U'; + + for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) { + const index = offset + 1; + const group = (offset % 100) + 1; + const lineOffset = 1 + offset * 2; + const stableIndex = String(index).padStart(6, '0'); + + lines[lineOffset] = `#EXTINF:-1 group-title="Synthetic Group ${String( + group + ).padStart( + 3, + '0' + )}",Synthetic Channel ${SYNTHETIC_M3U_SEED}-${stableIndex}`; + lines[lineOffset + 1] = + `http://127.0.0.1/stream/${SYNTHETIC_M3U_SEED}/${index}`; + } + + const body = `${lines.join('\n')}\n`; + return Object.freeze({ + body, + bytes: Buffer.byteLength(body, 'utf8'), + channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + sha256: createHash('sha256').update(body, 'utf8').digest('hex'), + }); +} diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index aaf54a94e..54d2ddc9f 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -220,6 +220,29 @@ describe('main preload DB IPC contract', () => { ); }); + it('preserves a structured playlist cancellation result across the context bridge', async () => { + const api = getExposedApi(); + const payload = { + operationId: 'playlist-refresh-cancelled', + playlistId: 'playlist-1', + title: 'Large playlist', + url: 'http://127.0.0.1/large.m3u', + }; + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + mockIpcRenderer.invoke.mockResolvedValueOnce(cancelledResult); + + await expect(api.refreshPlaylist(payload)).resolves.toEqual( + cancelledResult + ); + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'PLAYLIST:REFRESH', + payload + ); + }); + it('keeps the legacy save-content progress bridge scoped to progress events', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index d25d11f9b..f1300f5a6 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -527,7 +527,94 @@ describe('playlist IPC events', () => { expect(worker.terminate).toHaveBeenCalled(); }); - it('routes refresh cancellation to the active worker and converts worker error responses to Error instances', async () => { + it('settles cancellation without waiting for a CPU-bound refresh worker', async () => { + const ipcEvent = createIpcEvent(); + const payload: PlaylistRefreshPayload = { + operationId: 'refresh-busy', + playlistId: 'playlist-busy', + title: 'Busy playlist', + filePath: '/playlists/busy.m3u', + }; + const refreshPromise = getHandler(PLAYLIST_REFRESH)(ipcEvent, payload); + const worker = mockWorkerInstances[0]; + let outcome: + | { error: unknown; status: 'rejected' } + | { status: 'resolved'; value: unknown } + | undefined; + void refreshPromise.then( + (value) => { + outcome = { status: 'resolved', value }; + }, + (error: unknown) => { + outcome = { error, status: 'rejected' }; + } + ); + + worker.emit('message', { type: 'ready' }); + worker.emit('message', { + event: { + operationId: payload.operationId, + phase: 'parsing', + playlistId: payload.playlistId, + status: 'progress', + } satisfies PlaylistRefreshEvent, + type: 'event', + }); + + let finishTermination: ((exitCode: number) => void) | undefined; + worker.terminate.mockImplementationOnce( + () => + new Promise((resolveTermination) => { + finishTermination = resolveTermination; + }) + ); + const cancelPromise = getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + payload.operationId + ); + await Promise.resolve(); + + expect(worker.removeAllListeners).toHaveBeenCalledTimes(1); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(worker.postMessage).toHaveBeenLastCalledWith({ + operationId: payload.operationId, + type: 'cancel', + }); + expect(outcome).toBeUndefined(); + expect(ipcEvent.sender.send).not.toHaveBeenCalledWith( + PLAYLIST_REFRESH_EVENT, + expect.objectContaining({ status: 'cancelled' }) + ); + + finishTermination?.(1); + await expect(cancelPromise).resolves.toEqual({ success: true }); + await Promise.resolve(); + + expect(outcome).toEqual({ + status: 'resolved', + value: { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + }, + }); + expect(ipcEvent.sender.send).toHaveBeenLastCalledWith( + PLAYLIST_REFRESH_EVENT, + { + operationId: payload.operationId, + phase: 'parsing', + playlistId: payload.playlistId, + status: 'cancelled', + } + ); + await expect( + getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + payload.operationId + ) + ).resolves.toEqual({ success: false }); + }); + + it('converts playlist refresh worker error responses to Error instances', async () => { const payload: PlaylistRefreshPayload = { operationId: 'refresh-error', playlistId: 'playlist-error', @@ -540,17 +627,6 @@ describe('playlist IPC events', () => { ); const worker = mockWorkerInstances[0]; - expect( - await getHandler(PLAYLIST_CANCEL_REFRESH)( - createIpcEvent(), - 'refresh-error' - ) - ).toEqual({ success: true }); - expect(worker.postMessage).toHaveBeenCalledWith({ - operationId: 'refresh-error', - type: 'cancel', - }); - const rejectedRefresh = expect(refreshPromise).rejects.toMatchObject({ message: 'Refresh failed', name: 'PlaylistRefreshFailure', @@ -568,13 +644,6 @@ describe('playlist IPC events', () => { }); await rejectedRefresh; - - expect( - await getHandler(PLAYLIST_CANCEL_REFRESH)( - createIpcEvent(), - 'refresh-error' - ) - ).toEqual({ success: false }); }); it('returns save dialog paths and writes files through the filesystem handler', async () => { diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index d177c7987..ddd273d5a 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -11,9 +11,11 @@ import { AUTO_UPDATE_PLAYLISTS, PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, + PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE, PLAYLIST_REFRESH_EVENT, ElectronBridgeTrustOptions, Playlist, + PlaylistRefreshCancelledResult, PlaylistRefreshEvent, PlaylistRefreshPayload, summarizeAutoUpdateOutcomes, @@ -40,10 +42,7 @@ export default class PlaylistEvents { const playlistWriteAuthorizer = new PlaylistWriteAuthorizer(); type ActivePlaylistRefresh = { - reject: (reason?: unknown) => void; - resolve: (value: Playlist) => void; - sender: WebContents; - worker: Worker; + cancel: () => Promise; }; const activePlaylistRefreshes = new Map(); @@ -170,76 +169,126 @@ ipcMain.handle( async (event, payload: PlaylistRefreshPayload) => { const worker = resolvePlaylistRefreshWorker(); - return await new Promise((resolve, reject) => { - const cleanup = async (): Promise => { - activePlaylistRefreshes.delete(payload.operationId); - worker.removeAllListeners(); - await worker.terminate().catch(() => undefined); - }; + return await new Promise( + (resolve, reject) => { + let cleanupPromise: Promise | null = null; + let lastPhase: PlaylistRefreshEvent['phase'] = payload.url + ? 'fetching' + : 'reading-file'; + let settled = false; - activePlaylistRefreshes.set(payload.operationId, { - worker, - sender: event.sender, - resolve, - reject, - }); + const cleanup = (): Promise => { + cleanupPromise ??= (async () => { + activePlaylistRefreshes.delete(payload.operationId); + worker.removeAllListeners(); + await worker.terminate().catch(() => undefined); + })(); + return cleanupPromise; + }; - worker.on( - 'message', - async (message: PlaylistRefreshWorkerMessage) => { - if (message.type === 'ready') { - worker.postMessage({ - type: 'request', - payload, - }); + const cancel = async (): Promise => { + if (settled) { return; } + settled = true; - if (message.type === 'event') { - emitPlaylistRefreshEvent(event.sender, message.event); - return; + try { + worker.postMessage({ + type: 'cancel', + operationId: payload.operationId, + }); + } catch { + // Termination below is authoritative even if cooperative + // cancellation cannot be delivered. } await cleanup(); + emitPlaylistRefreshEvent(event.sender, { + operationId: payload.operationId, + playlistId: payload.playlistId, + phase: lastPhase, + status: 'cancelled', + }); + resolve({ + operationId: payload.operationId, + type: PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE, + }); + }; - const response = - message as PlaylistRefreshWorkerResponseMessage; - if (response.success && response.result) { - resolve(response.result); + const activeRefresh: ActivePlaylistRefresh = { cancel }; + activePlaylistRefreshes.set(payload.operationId, activeRefresh); + + worker.on( + 'message', + async (message: PlaylistRefreshWorkerMessage) => { + if (settled) { + return; + } + + if (message.type === 'ready') { + worker.postMessage({ + type: 'request', + payload, + }); + return; + } + + if (message.type === 'event') { + lastPhase = message.event.phase ?? lastPhase; + emitPlaylistRefreshEvent( + event.sender, + message.event + ); + return; + } + + settled = true; + await cleanup(); + + const response = + message as PlaylistRefreshWorkerResponseMessage; + if (response.success && response.result) { + resolve(response.result); + return; + } + + reject( + createPlaylistRefreshError( + response.error ?? { + message: + 'Playlist refresh worker request failed', + } + ) + ); + } + ); + + worker.on('error', async (error) => { + if (settled) { + return; + } + settled = true; + await cleanup(); + reject(error); + }); + + worker.on('exit', async (code) => { + if (settled) { return; } + settled = true; + await cleanup(); reject( - createPlaylistRefreshError( - response.error ?? { - message: - 'Playlist refresh worker request failed', - } + new Error( + code === 0 + ? 'Playlist refresh worker exited unexpectedly' + : `Playlist refresh worker stopped with exit code ${code}` ) ); - } - ); - - worker.on('error', async (error) => { - await cleanup(); - reject(error); - }); - - worker.on('exit', async (code) => { - if (!activePlaylistRefreshes.has(payload.operationId)) { - return; - } - - await cleanup(); - reject( - new Error( - code === 0 - ? 'Playlist refresh worker exited unexpectedly' - : `Playlist refresh worker stopped with exit code ${code}` - ) - ); - }); - }); + }); + } + ); } ); @@ -251,10 +300,7 @@ ipcMain.handle( return { success: false }; } - activeRefresh.worker.postMessage({ - type: 'cancel', - operationId, - }); + await activeRefresh.cancel(); return { success: true }; } diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 15d8783b3..d312ae21a 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -1,3 +1,5 @@ +import type { WorkerPerformanceCaptureResult } from './worker-performance-capture'; + export const DB_WORKER_OPERATIONS = [ 'DB_HAS_CATEGORIES', 'DB_GET_CATEGORIES', @@ -138,6 +140,7 @@ export interface DbWorkerResponseMessage { success: boolean; result?: TResult; error?: SerializedWorkerError; + performance?: WorkerPerformanceCaptureResult; } export type DbWorkerIncomingMessage = diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index e702baa59..b3b4d968a 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -88,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( @@ -778,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); } @@ -920,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( @@ -934,11 +948,14 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { `Error handling ${message.operation}:`, error ); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, error: serializeError(error), + performance, }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index 37f9f0588..790d6ac55 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -22,6 +22,11 @@ import { } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; type ActiveRefreshState = { cancelled: boolean; @@ -186,12 +191,17 @@ parentPort.on( return; } + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); try { const result = await executeRefresh(message.payload); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', success: true, result, + performance, }); } catch (error) { const payload = message.payload; @@ -206,10 +216,13 @@ parentPort.on( }); } + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', success: false, error: serializeError(error), + performance, }); } } diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts index 05c7e2a80..601b9e62c 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts @@ -1,4 +1,8 @@ -import type { PlaylistRefreshEvent, PlaylistRefreshPayload } from '@iptvnator/shared/interfaces'; +import type { + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; +import type { WorkerPerformanceCaptureResult } from './worker-performance-capture'; export interface PlaylistRefreshWorkerRequestMessage { type: 'request'; @@ -28,6 +32,7 @@ export interface PlaylistRefreshWorkerResponseMessage { message: string; stack?: string; }; + performance?: WorkerPerformanceCaptureResult; } export type PlaylistRefreshWorkerIncomingMessage = diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts new file mode 100644 index 000000000..2801935c3 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -0,0 +1,48 @@ +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('is disabled unless explicitly opted in', () => { + delete process.env[PROFILING_ENV]; + + expect(startWorkerPerformanceCapture()).toBeNull(); + }); + + it('reports finite worker event-loop metrics when opted in', async () => { + process.env[PROFILING_ENV] = '1'; + + const capture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(capture); + const blockStartedAt = performance.now(); + while (performance.now() - blockStartedAt < 20) { + // Deliberately block the loop so the histogram contract is tested. + } + const result = await finishWorkerPerformanceCapture(capture); + + expect(result).toEqual({ + eventLoopDelay: { + maxMs: expect.any(Number), + p95Ms: expect.any(Number), + p99Ms: expect.any(Number), + }, + eventLoopUtilization: expect.any(Number), + }); + expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); + expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts new file mode 100644 index 000000000..db86f234d --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -0,0 +1,68 @@ +import { + monitorEventLoopDelay, + performance, + type IntervalHistogram, +} from 'node:perf_hooks'; + +const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + }; + eventLoopUtilization: number; +} + +interface WorkerPerformanceCapture { + eventLoopDelay: IntervalHistogram; + eventLoopUtilizationStart: ReturnType< + typeof performance.eventLoopUtilization + >; +} + +export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { + if (process.env[WORKER_PROFILING_ENV] !== '1') { + return null; + } + + const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); + eventLoopDelay.enable(); + return { + eventLoopDelay, + eventLoopUtilizationStart: performance.eventLoopUtilization(), + }; +} + +export async function armWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): Promise { + if (capture) { + await new Promise((resolvePromise) => + setTimeout(resolvePromise, 2) + ); + } +} + +export async function finishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): Promise { + if (!capture) { + return undefined; + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); + capture.eventLoopDelay.disable(); + const eventLoopUtilization = performance.eventLoopUtilization( + capture.eventLoopUtilizationStart + ); + return { + eventLoopDelay: { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }, + eventLoopUtilization: eventLoopUtilization.utilization, + }; +} diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index a376abbd3..48a4a468b 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -62,7 +62,16 @@ Two paths re-download an M3U playlist from its original source: - **Explicit refresh** — `PLAYLIST_REFRESH` runs in `playlist-refresh.worker.ts`, reports progress through `PLAYLIST_REFRESH_EVENT`, and is cancellable via - `PLAYLIST_CANCEL_REFRESH`. + `PLAYLIST_CANCEL_REFRESH`. Cancellation is owned by the main process: it first + sends the cooperative cancel message, then terminates the one-shot worker + without waiting for its event loop. The cancel IPC resolves only after the + worker has stopped, the correlated `cancelled` event has been emitted with the + last known phase, and a structured cancellation result is ready. That result + crosses both Electron IPC and the context bridge unchanged; + `PlaylistRefreshService` converts it into a renderer-local `AbortError`. + Relying on an error created in main or preload would lose its `name` at one of + those serialization boundaries. A cancelled refresh must not update the + renderer store or reach SQLite. - **Startup auto-update** — after `loadPlaylistsSuccess`, `AppComponent` sends `AUTO_UPDATE_PLAYLISTS` for every playlist with `autoRefresh === true`. The main process fulfils it in `playlist-auto-update.ts` on top of `playlist-source.ts`. @@ -87,6 +96,37 @@ dead source must never stall startup (issue #931): so refresh logging goes through `redactSensitiveData()` from `@iptvnator/shared/logging`. +### Refresh Cancellation Performance Regression + +The Electron E2E project includes a deterministic 100,000-channel cancellation +benchmark. It uses only a loopback synthetic M3U server, performs one warm-up, +five measured runs, and one diagnostic run, and writes summaries plus raw +profiles below the gitignored `dist/performance/` directory: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-refresh-cancel" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=after \ +pnpm nx run electron-backend-e2e:benchmark-m3u-refresh-cancellation +``` + +The output path must be an absolute, previously unused descendant of +`dist/performance/`. A formal run fails on a dirty worktree and records the +commit, source-state hash, OS/architecture, Node, Electron, and fixture identity +in its manifest. Commit the harness first and capture `baseline` from that clean +commit; commit the production change separately, rebuild, and capture `after` +with the same harness and machine. Set `IPTVNATOR_PERF_SMOKE=1` for one measured +run during harness development; smoke runs may be dirty and must not support +before/after claims. + +The target reserves and verifies CDP port 9222, freezes renderer long-task, +frame-gap, and heartbeat probes before forced post-GC heap collection, and +enables opt-in worker profiling. Worker event-loop delay is read from a +request-scoped `node:perf_hooks` capture; a worker terminated before it can flush +the capture reports the metric as unavailable rather than zero. Diagnostic CPU +profiles, heap snapshots, and Chromium traces are excluded from the five-run +headline distributions. + ### Reporting The Auto-Update Result Because auto-update isolates failures, it must also report them — otherwise a diff --git a/libs/services/src/lib/playlist-refresh.service.spec.ts b/libs/services/src/lib/playlist-refresh.service.spec.ts new file mode 100644 index 000000000..920db2940 --- /dev/null +++ b/libs/services/src/lib/playlist-refresh.service.spec.ts @@ -0,0 +1,49 @@ +import type { + ElectronBridgeApi, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; + +import { PlaylistRefreshService } from './playlist-refresh.service'; + +describe('PlaylistRefreshService', () => { + const originalElectron = window.electron; + const payload: PlaylistRefreshPayload = { + operationId: 'playlist-refresh-cancelled', + playlistId: 'playlist-1', + title: 'Large playlist', + url: 'http://127.0.0.1/large.m3u', + }; + + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + writable: true, + }); + }); + + it('creates a renderer-local AbortError from a cancellation result', async () => { + const unsubscribe = jest.fn(); + const electron = { + onPlaylistRefreshEvent: jest.fn(() => unsubscribe), + refreshPlaylist: jest.fn().mockResolvedValue({ + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + }), + } as unknown as ElectronBridgeApi; + Object.defineProperty(window, 'electron', { + configurable: true, + value: electron, + writable: true, + }); + + await expect( + new PlaylistRefreshService().refreshPlaylist(payload) + ).rejects.toMatchObject({ + message: + 'Playlist refresh "playlist-refresh-cancelled" was cancelled', + name: 'AbortError', + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/services/src/lib/playlist-refresh.service.ts b/libs/services/src/lib/playlist-refresh.service.ts index d4ab49d79..f73f36ab7 100644 --- a/libs/services/src/lib/playlist-refresh.service.ts +++ b/libs/services/src/lib/playlist-refresh.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { + isPlaylistRefreshCancelledResult, Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, @@ -30,7 +31,15 @@ export class PlaylistRefreshService { }); try { - return await window.electron.refreshPlaylist(payload); + const result = await window.electron.refreshPlaylist(payload); + if (isPlaylistRefreshCancelledResult(result)) { + const error = new Error( + `Playlist refresh "${result.operationId}" was cancelled` + ); + error.name = 'AbortError'; + throw error; + } + return result; } finally { unsubscribe?.(); } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index b36355207..08214323f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -21,6 +21,7 @@ import { XtreamBackupRecentlyViewedItem, } from './playlist-backup.interface'; import { + PlaylistRefreshCancelledResult, PlaylistRefreshEvent, PlaylistRefreshPayload, } from './playlist-refresh.interface'; @@ -671,7 +672,9 @@ export interface ElectronBridgeApi { url: string, method?: 'GET' | 'HEAD' ) => Promise; - refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; cancelPlaylistRefresh: ( operationId: string ) => Promise; diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index b02ee028f..6a153f952 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -27,3 +27,25 @@ export interface PlaylistRefreshPayload { url?: string; trustedInsecureTlsHosts?: string[]; } + +export const PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE = + 'playlist-refresh-cancelled' as const; + +export interface PlaylistRefreshCancelledResult { + operationId: string; + type: typeof PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE; +} + +export function isPlaylistRefreshCancelledResult( + value: unknown +): value is PlaylistRefreshCancelledResult { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Record; + return ( + candidate['type'] === PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE && + typeof candidate['operationId'] === 'string' + ); +} From 9ae53e451570538770e831e2838a776796284907 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:26:40 +0200 Subject: [PATCH 12/12] fix(playback): make the "Show subtitles" setting reach the web players (#1269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted subtitle preference only ever had an owner behind the default-off shared web-controls flag, so with the shipping controls it did nothing: Video.js never read it, ArtPlayer declared the input but never used it, and the HTML5 player only ran a one-shot pass after play() resolved — before hls.js had added its text tracks. No portal host bound the input at all, so it never reached Xtream or Stalker pages either. Extract the source-local track controllers into the adapter-free WebVideoSourceTracks and have WebVideoSourceControlsBridge wrap it, so both controls modes apply the preference through the same code. The preference-off players bind it directly (VjsLegacyTracks for Video.js), and WebPlayerViewComponent reads the preference from SettingsStore instead of an input so every host inherits it. The preference means different things depending on who renders the caption UI: shared controls stay authoritative for the session, while vendor chrome is source-default — the preference seeds each new source and is released once the media reports playing, so the engine own caption menu keeps working. Mode selection is an optional playbackStarted probe passed to the HLS, native and Shaka helpers; in that mode the HLS helper deselects the track rather than hiding it, since subtitleDisplay would silently override the vendor menu. Closes #1155 --- .changes/playback-subtitle-preference.md | 11 + AGENTS.md | 20 ++ CLAUDE.md | 2 +- docs/architecture/player-controls-contract.md | 54 +++- .../video-player/video-player.component.html | 1 - .../video-player.component.spec.ts | 1 - .../video-player/video-player.component.ts | 3 - ...art-player-source-session.captions.spec.ts | 109 ++++++++ ...art-player-source-session.spec-fixtures.ts | 4 +- .../art-player/art-player-source-session.ts | 17 ++ .../html-video-element-session.spec.ts | 51 ++-- .../html-video-element-session.ts | 25 +- ...ml-video-player.component.captions.spec.ts | 150 ++++++++++ ...o-player.component.shared-controls.spec.ts | 16 +- .../html-video-player.component.spec.ts | 17 -- .../html-video-player.component.ts | 33 ++- .../src/lib/vjs-player/vjs-legacy-tracks.ts | 66 +++++ .../vjs-player.component.captions.spec.ts | 264 ++++++++++++++++++ .../lib/vjs-player/vjs-player.component.ts | 37 ++- .../src/lib/vjs-player/vjs-text-tracks.ts | 13 + ...yer-view.component.shared-controls.spec.ts | 96 ++----- .../web-player-view.component.ts | 12 +- .../web-player-view.spec-stubs.ts | 70 +++++ .../web-video-hls-controls.ts | 45 ++- .../web-video-native-text-tracks.ts | 13 + .../web-video-shaka-controls.spec.ts | 37 ++- .../web-video-shaka-controls.ts | 17 +- .../web-video-source-controls.bridge.ts | 97 ++----- .../web-video-source-tracks.ts | 188 +++++++++++++ 29 files changed, 1193 insertions(+), 276 deletions(-) create mode 100644 .changes/playback-subtitle-preference.md create mode 100644 libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts create mode 100644 libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts diff --git a/.changes/playback-subtitle-preference.md b/.changes/playback-subtitle-preference.md new file mode 100644 index 000000000..8c320a9d1 --- /dev/null +++ b/.changes/playback-subtitle-preference.md @@ -0,0 +1,11 @@ +--- +type: fix +area: playback +issues: [1155] +--- + +The "Show subtitles" setting now works in the built-in players: turning it off +hides subtitles a stream switched on by itself, and the preference finally +applies on Xtream and Stalker pages too. Previously it only reached the M3U +player, and even there Video.js and ArtPlayer ignored it. The player's own +subtitle menu still overrides the setting for the current stream. diff --git a/AGENTS.md b/AGENTS.md index cdadea77b..014efff5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,26 @@ Key files: links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. +- `Settings.showCaptions` is deliberately outside this rollout gate: it is + engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both + modes — shared controls through their controls bridge, the preference-off + paths through the same helpers without an adapter (`WebVideoSourceTracks` for + HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference + as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it + from `SettingsStore` rather than a host input, so the M3U player, the + Xtream/Stalker live layouts, and the portal detail inline player all inherit + it (#1155). +- The modes differ in how long the preference is enforced. Shared controls are + authoritative for the session; user intent arrives through `setSubtitleTrack` + and wins until the source changes. Vendor chrome is source-default: the + preference seeds each new source and is released once the media element + reports `playing`, so the engine's own caption menu keeps working. The mode is + selected by the optional `playbackStarted` probe the legacy owners pass to all + three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper + deselects the track (`subtitleTrack = -1`) instead of hiding it, because + `subtitleDisplay` would silently override whatever the vendor menu picks. For + DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads, + so the helper only stops re-suppressing afterwards. - Embedded MPV ignores the web-player preference. Frame-copy always uses shared DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while native-view retains the legacy compositor-safe dock and external MPV/VLC diff --git a/CLAUDE.md b/CLAUDE.md index a8bfaedc1..cb8cbbe18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -773,7 +773,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 19468c508..9a8778b07 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -335,6 +335,44 @@ contract. It uses DOM/media events and accepts optional engine-specific track accessors through `WebVideoControlsOptions`, so the adapter itself stays usable in the PWA and does not import a concrete web engine. +### Caption preference in both modes + +The `Settings.showCaptions` preference is **not** part of the rollout gate. It +is engine state, not controls UI, so HTML5, Video.js, and ArtPlayer apply it +whether or not their host snapshot enables `WEB_PLAYER_SHARED_CONTROLS`. Shared +controls route it through their controls bridge; the preference-off paths use +the same helpers without an adapter — `WebVideoSourceTracks` for HTML5 and +ArtPlayer, `VjsLegacyTracks` for Video.js. Both apply the preference when a +source binds and re-apply it as the engine adds or switches text tracks, which +a one-shot check at playback start could not do (#1155). + +The two modes differ in **how long** the preference stays enforced, because +they differ in who owns the caption UI: + +- **Shared controls: authoritative.** The preference holds for the whole + session; user intent arrives through `setSubtitleTrack`, which records an + explicit override (including `-1` for off) that wins until the source changes. +- **Vendor chrome: source-default.** The engine still renders its own caption + menu, so the preference only seeds each new source and is released once the + media element reports `playing`. Enforcing it for the whole session would + make that menu inert. + +The mode is selected by passing a `playbackStarted` probe to the track helpers; +shared controls omit it. All three helpers take it — HLS, native text tracks, +and Shaka. For DASH the seed happens inside `ShakaVideoSession.start()` once the +manifest is loaded, so the helper only has to stop re-suppressing afterwards. `WebVideoSourceTracks` owns the probe for HTML5 and +ArtPlayer (a `playing` listener on the media element, reset on every +`setSource`); `VjsLegacyTracks` owns it for Video.js (the player's own `playing` +event, reset on every `clear`). In source-default mode the HLS helper also +_deselects_ the track (`subtitleTrack = -1`) instead of hiding it: hls.js +applies `subtitleDisplay` to whatever the vendor menu picks, so suppressing +display would silently override the user, and a `-1` assignment additionally +clears hls.js' own default-track selection so it cannot reselect one later. + +`WebPlayerViewComponent` reads the preference from `SettingsStore` rather than +from a host input, so every host — the M3U player, Xtream and Stalker live +layouts, and the portal detail inline player — gets it without wiring. + ### Standard element picture-in-picture Picture-in-picture is part of the existing default-off shared web-controls @@ -602,12 +640,16 @@ The neutral web-video source support shared by HTML5 and ArtPlayer lives in: libs/ui/playback/src/lib/web-video-support/ ├── web-video-hls-controls.ts ├── web-video-native-text-tracks.ts +├── web-video-source-tracks.ts └── web-video-source-controls.bridge.ts ``` -The bridge owns source-local HLS/native track projection, caption preference -and explicit subtitle-off state, MPEG-TS VOD duration correction, adapter -refresh, and exact track-list listener cleanup. +`WebVideoSourceTracks` owns source-local HLS/Shaka/native track projection, +caption preference and explicit subtitle-off state, and exact track-list +listener cleanup. It has no controls dependency, so the preference-off players +use it directly. `WebVideoSourceControlsBridge` wraps it for shared controls +and adds adapter attach/detach, adapter refresh, and MPEG-TS VOD duration +correction. The guarded HTML5 integration lives in: @@ -625,14 +667,16 @@ libs/ui/playback/src/lib/html-video-player/ `WebVideoControlsAdapter`. Its bridge/helper filenames re-export the neutral web-video support so existing imports and focused specs remain stable. `HtmlVideoElementSession` separately owns native video-event attachment, -persisted volume, start-time/time/ended propagation, and the preference-off -post-play caption behavior. +persisted volume, and start-time/time/ended propagation. Captions are not its +concern in either mode: the component binds `WebVideoSourceTracks` alongside +its controls bridge and feeds both the same active source. The guarded Video.js integration lives in: ```text libs/ui/playback/src/lib/vjs-player/ ├── vjs-audio-tracks.ts +├── vjs-legacy-tracks.ts ├── vjs-mpegts-session.ts ├── vjs-player-controls.bridge.ts ├── vjs-player-reset-coordinator.ts diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e529618be..bd30b8c03 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -82,7 +82,6 @@ : (playerSettings.player ?? null) " [volume]="volume()" - [showCaptions]="!!playerSettings.showCaptions" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index b4879d433..5279ae96a 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -117,7 +117,6 @@ class StubWebPlayerViewComponent { readonly playback = input(null); readonly playerOverride = input(null); readonly volume = input(1); - readonly showCaptions = input(false); readonly externalFallbackRequested = output(); } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index a887f4a39..dfeb98601 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -367,7 +367,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Selected video player options */ playerSettings: Partial = { player: VideoPlayer.VideoJs, - showCaptions: false, }; readonly isDesktop = this.runtime.isElectron; @@ -403,7 +402,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { effect(() => { this.playerSettings = { player: this.settingsStore.player(), - showCaptions: this.settingsStore.showCaptions(), }; }); @@ -739,7 +737,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.playerSettings = { player: (settings as Settings).player || VideoPlayer.VideoJs, - showCaptions: (settings as Settings).showCaptions || false, }; } }); diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts new file mode 100644 index 000000000..0e769e077 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts @@ -0,0 +1,109 @@ +import { + MockHls, + createSession, + hlsInstances, + initArtPlayerSourceSessionModule, + resetArtPlayerSourceFixtures, +} from './art-player-source-session.spec-fixtures'; + +/** + * Regression coverage for #1155: with ArtPlayer's own chrome (shared controls + * off, the shipping default) the `showCaptions` preference reached no engine at + * all, so an HLS rendition flagged DEFAULT/AUTOSELECT kept subtitles on screen. + * + * The preference seeds each source here rather than staying authoritative, so + * the engine's caption menu still works once playback is running. + */ +describe('ArtPlayerSourceSession caption preference without shared controls', () => { + beforeAll(async () => { + await initArtPlayerSourceSessionModule(); + }); + + beforeEach(() => { + resetArtPlayerSourceFixtures(); + }); + + it('deselects the HLS default subtitle track while the preference is off', () => { + const { session, player, video } = startHls(() => false); + + const hls = selectDefaultSubtitleTrack(hlsInstances[0]); + + expect(hls.subtitleTrack).toBe(-1); + // Display stays enabled: hls.js applies `subtitleDisplay` to whatever + // the vendor caption menu picks, so suppressing it would make the menu + // inert while the preference is off. + expect(hls.subtitleDisplay).toBe(true); + expect(player.video).toBe(video); + session.destroy(); + }); + + it('keeps a selection made after playback started', () => { + const { session, video } = startHls(() => false); + const hls = selectDefaultSubtitleTrack(hlsInstances[0]); + expect(hls.subtitleTrack).toBe(-1); + + video.dispatchEvent(new Event('playing')); + hls.subtitleTrack = 0; + hls.emit(MockHls.Events.SUBTITLE_TRACK_SWITCH); + + expect(hls.subtitleTrack).toBe(0); + session.destroy(); + }); + + it('keeps HLS subtitles selected while the preference is on', () => { + const { session } = startHls(() => true); + + const hls = selectDefaultSubtitleTrack(hlsInstances[0]); + + expect(hls.subtitleTrack).toBe(0); + session.destroy(); + }); + + it('re-seeds the preference for a replacement source', () => { + const { session, player, video } = startHls(() => false); + video.dispatchEvent(new Event('playing')); + + session.customType['m3u8']?.( + video, + 'https://example.test/second.m3u8', + player + ); + const replacement = selectDefaultSubtitleTrack(hlsInstances[1]); + + expect(replacement.subtitleTrack).toBe(-1); + session.destroy(); + }); + + it('stops touching the engine after the session is destroyed', () => { + const { session } = startHls(() => false); + const hls = hlsInstances[0]; + + session.destroy(); + const untouched = selectDefaultSubtitleTrack(hls); + + expect(untouched.subtitleTrack).toBe(0); + }); + + function startHls(showCaptions: () => boolean) { + const { session, player, video } = createSession({ + sharedControls: false, + showCaptions, + }); + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + return { session, player, video }; + } + + /** Mimics hls.js auto-selecting a DEFAULT/AUTOSELECT rendition. */ + function selectDefaultSubtitleTrack(hls: MockHls): MockHls { + hls.subtitleTracks = [{ name: 'English' }]; + hls.subtitleDisplay = true; + hls.subtitleTrack = 0; + hls.emit(MockHls.Events.SUBTITLE_TRACKS_UPDATED); + return hls; + } +}); diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts index 26a046c2c..cbc8b0ce5 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -131,12 +131,14 @@ export function resetArtPlayerSourceFixtures(): void { export function createSession({ sharedControls, isLive = true, + showCaptions = () => false, emitPlaybackIssue = () => undefined, getDrm, loadShaka, }: { sharedControls: boolean; isLive?: boolean; + showCaptions?: () => boolean; emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void; getDrm?: () => ChannelDrm | undefined; loadShaka?: ShakaModuleLoader; @@ -161,7 +163,7 @@ export function createSession({ sharedControls, controlsAdapter: adapter, isLive: () => isLive, - showCaptions: () => false, + showCaptions, emitPlaybackIssue, getDrm, loadShaka, diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index def6b1be7..eb6cedb3a 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -18,6 +18,7 @@ import { type WebVideoControlsSource, WebVideoSourceControlsBridge, } from '../web-video-support/web-video-source-controls.bridge'; +import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks'; import { addHlsAudioTrackSettings } from './art-player-audio-tracks'; export interface ArtPlayerSourceSessionConfig { @@ -49,6 +50,11 @@ export class ArtPlayerSourceSession { private player: Artplayer | null = null; private controlsBridge: WebVideoSourceControlsBridge | null = null; + /** + * Legacy (ArtPlayer chrome) counterpart of {@link controlsBridge}: keeps + * the `showCaptions` preference authoritative without a controls adapter. + */ + private captionTracks: WebVideoSourceTracks | null = null; private pendingControlsSource: WebVideoControlsSource = { kind: 'native' }; private hls: Hls | null = null; private hlsManifestListener: @@ -73,6 +79,12 @@ export class ArtPlayerSourceSession { this.player = player; if (!this.config.sharedControls) { + this.captionTracks = new WebVideoSourceTracks({ + video: player.video, + showCaptions: this.config.showCaptions, + vendorCaptionControls: true, + }); + this.captionTracks.setSource(this.pendingControlsSource); return; } @@ -89,6 +101,7 @@ export class ArtPlayerSourceSession { refreshInputs(): void { this.controlsBridge?.refreshInputs(); + this.captionTracks?.refreshInputs(); } resolveDuration(fallbackDuration: number): number { @@ -106,6 +119,8 @@ export class ArtPlayerSourceSession { this.destroyed = true; this.controlsBridge?.destroy(); this.controlsBridge = null; + this.captionTracks?.destroy(); + this.captionTracks = null; this.destroyHls(); this.destroyMpegTs(); this.shakaSession?.destroy(); @@ -215,6 +230,7 @@ export class ArtPlayerSourceSession { private prepareForSourceChange(): void { this.controlsBridge?.clearSource(); + this.captionTracks?.clearSource(); this.destroyHls(); this.destroyMpegTs(); this.shakaSession?.stop(); @@ -234,6 +250,7 @@ export class ArtPlayerSourceSession { private bindControlsSource(source: WebVideoControlsSource): void { this.pendingControlsSource = source; this.controlsBridge?.setSource(source); + this.captionTracks?.setSource(source); } private destroyHls(): void { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts index 4072cfa01..d1a44277b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts @@ -12,8 +12,6 @@ describe('HtmlVideoElementSession', () => { video, getChannelUrl: () => 'https://example.test/video.ts', getStartTime: () => 12, - showCaptions: () => true, - sharedControls: () => false, emitPlaybackIssue: (issue) => playbackIssues.push(issue), emitTimeUpdate: (value) => timeUpdates.push(value), emitPlaybackEnded: playbackEnded, @@ -74,7 +72,10 @@ describe('HtmlVideoElementSession', () => { session.destroy(); }); - it('suppresses legacy captions only after successful non-shared playback', async () => { + // Caption state belongs to WebVideoSourceTracks, which follows the active + // source engine. The old play()-time one-shot ran before hls.js had added + // its text tracks, which is what made the preference look like a no-op. + it('leaves text tracks alone when playback starts', async () => { const video = document.createElement('video'); const tracks = [ { mode: 'showing' as TextTrackMode }, @@ -85,41 +86,27 @@ describe('HtmlVideoElementSession', () => { value: tracks, }); jest.spyOn(video, 'play').mockResolvedValue(undefined); - const session = createSession(video, { - showCaptions: () => false, - sharedControls: () => false, - }); + const session = createSession(video); session.play(); await Promise.resolve(); - expect(tracks.map((track) => track.mode)).toEqual(['hidden', 'hidden']); + expect(tracks.map((track) => track.mode)).toEqual([ + 'showing', + 'showing', + ]); }); - it.each([ - { showCaptions: true, sharedControls: false }, - { showCaptions: false, sharedControls: true }, - ])( - 'keeps captions when showCaptions=$showCaptions and sharedControls=$sharedControls', - async ({ showCaptions, sharedControls }) => { - const video = document.createElement('video'); - const tracks = [{ mode: 'showing' as TextTrackMode }]; - Object.defineProperty(video, 'textTracks', { - configurable: true, - value: tracks, - }); - jest.spyOn(video, 'play').mockResolvedValue(undefined); - const session = createSession(video, { - showCaptions: () => showCaptions, - sharedControls: () => sharedControls, - }); + it('swallows autoplay rejections', async () => { + const video = document.createElement('video'); + jest.spyOn(video, 'play').mockRejectedValue( + new Error('autoplay blocked') + ); + const session = createSession(video); - session.play(); - await Promise.resolve(); - - expect(tracks[0].mode).toBe('showing'); - } - ); + expect(() => session.play()).not.toThrow(); + await Promise.resolve(); + }); }); function createSession( @@ -132,8 +119,6 @@ function createSession( video, getChannelUrl: () => undefined, getStartTime: () => 0, - showCaptions: () => true, - sharedControls: () => false, emitPlaybackIssue: jest.fn(), emitTimeUpdate: jest.fn(), emitPlaybackEnded: jest.fn(), diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 79ae2396b..3e320c19b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -12,8 +12,6 @@ export interface HtmlVideoElementSessionConfig { video: HTMLVideoElement; getChannelUrl: () => string | undefined; getStartTime: () => number; - showCaptions: () => boolean; - sharedControls: () => boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; emitPlaybackEnded: () => void; @@ -95,25 +93,10 @@ export class HtmlVideoElementSession { return; } - void playPromise - .then(() => { - if ( - !this.config.sharedControls() && - !this.config.showCaptions() - ) { - this.disableCaptions(); - } - }) - .catch(() => { - // Autoplay failures are surfaced by the surrounding player UI. - }); - } - - disableCaptions(): void { - const tracks = this.config.video.textTracks; - for (let index = 0; index < tracks.length; index += 1) { - tracks[index].mode = 'hidden'; - } + // Autoplay failures are surfaced by the surrounding player UI. Caption + // state is owned by WebVideoSourceTracks, which tracks the active + // source engine instead of guessing once when playback starts. + void playPromise.catch(() => undefined); } destroy(): void { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts new file mode 100644 index 000000000..b8396f8be --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.captions.spec.ts @@ -0,0 +1,150 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DataService } from '@iptvnator/services'; +import { Channel } from '@iptvnator/shared/interfaces'; +import { + createTextTrack, + FakeTextTrackList, +} from './html-video-player-controls.spec-fixtures'; +import { HtmlVideoPlayerComponent } from './html-video-player.component'; + +/** `.mp4` skips the hls.js/mpegts.js branches and binds the native source. */ +const NATIVE_SOURCE_URL = 'http://test.example/stream.mp4'; +const OTHER_SOURCE_URL = 'http://test.example/next.mp4'; + +const TEST_CHANNEL: Channel = { + id: '1234', + url: NATIVE_SOURCE_URL, + name: 'Test channel', + group: { title: 'News group' }, + http: { origin: '', referrer: '', 'user-agent': 'localhost' }, + radio: 'false', + tvg: { id: '', logo: '', name: '', rec: '', url: '' }, +}; + +/** + * Regression coverage for #1155: without shared controls (the shipping + * default) the `showCaptions` preference had no owner at all, so a track the + * source engine enabled by default kept subtitles on screen. + */ +describe('HtmlVideoPlayerComponent caption preference without shared controls', () => { + let component: HtmlVideoPlayerComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: DataService, + useValue: { + sendIpcEvent: jest.fn().mockResolvedValue(undefined), + }, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { setUserAgent: jest.fn().mockResolvedValue(true) }, + }); + fixture = TestBed.createComponent(HtmlVideoPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + jest.restoreAllMocks(); + }); + + it('suppresses initial and late default captions when the preference is off', () => { + const initial = createTextTrack({ kind: 'captions', mode: 'showing' }); + const textTracks = stubTextTracks([initial]); + setPreference(false); + + component.playChannel(TEST_CHANNEL); + + expect(initial.mode).toBe('hidden'); + + const late = createTextTrack({ kind: 'subtitles', mode: 'showing' }); + textTracks.add(late); + + expect(late.mode).toBe('hidden'); + }); + + it('leaves captions alone when the preference is on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + stubTextTracks([track]); + setPreference(true); + + component.playChannel(TEST_CHANNEL); + + expect(track.mode).toBe('showing'); + }); + + // The native controls stay visible in this mode, so the preference must + // seed the source and then get out of the user's way. + it('keeps a caption the user enables after playback started', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + const textTracks = stubTextTracks([track]); + setPreference(false); + component.playChannel(TEST_CHANNEL); + expect(track.mode).toBe('hidden'); + + component.videoPlayer.nativeElement.dispatchEvent( + new Event('playing') + ); + track.mode = 'showing'; + textTracks.emit('change'); + + expect(track.mode).toBe('showing'); + }); + + it('re-seeds the preference for the next channel', () => { + const first = createTextTrack({ kind: 'captions', mode: 'showing' }); + const textTracks = stubTextTracks([first]); + setPreference(false); + component.playChannel(TEST_CHANNEL); + component.videoPlayer.nativeElement.dispatchEvent( + new Event('playing') + ); + + const next = createTextTrack({ kind: 'captions', mode: 'showing' }); + textTracks.replaceSilently([next]); + component.playChannel({ ...TEST_CHANNEL, url: OTHER_SOURCE_URL }); + + expect(next.mode).toBe('hidden'); + }); + + it('restores suppressed captions when the preference is turned on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + stubTextTracks([track]); + setPreference(false); + component.playChannel(TEST_CHANNEL); + expect(track.mode).toBe('hidden'); + + setPreference(true); + + expect(track.mode).toBe('showing'); + }); + + function setPreference(showCaptions: boolean): void { + fixture.componentRef.setInput('showCaptions', showCaptions); + fixture.detectChanges(); + } + + function stubTextTracks(tracks: TextTrack[]): FakeTextTrackList { + const video = component.videoPlayer.nativeElement; + const textTracks = new FakeTextTrackList(tracks); + Object.defineProperty(video, 'textTracks', { + configurable: true, + value: textTracks.asTextTrackList(), + }); + jest.spyOn(video, 'play').mockResolvedValue(undefined); + jest.spyOn(video, 'load').mockImplementation(() => undefined); + return textTracks; + } +}); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts index 54009faea..33349e0e3 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts @@ -211,20 +211,24 @@ describe('HtmlVideoPlayerComponent shared controls host', () => { expect(setContext).toHaveBeenCalledTimes(1); }); - it('does not run legacy post-play caption suppression', async () => { + it('leaves caption state to the controls bridge when playback starts', async () => { const { component } = renderSharedControls( HtmlVideoPlayerComponent, fixtures ); - const disableCaptions = jest.spyOn( - component as HtmlVideoPlayerComponentInstance, - 'disableCaptions' - ); + const video = (component as HtmlVideoPlayerComponentInstance) + .videoPlayer.nativeElement; + const tracks = [{ mode: 'showing' as TextTrackMode }]; + Object.defineProperty(video, 'textTracks', { + configurable: true, + value: tracks, + }); + jest.spyOn(video, 'play').mockResolvedValue(undefined); component.handlePlayOperation(); await Promise.resolve(); - expect(disableCaptions).not.toHaveBeenCalled(); + expect(tracks[0].mode).toBe('showing'); }); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 3c2477389..63304da58 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -91,23 +91,6 @@ describe('HtmlVideoPlayerComponent', () => { expect(adapterAttach).not.toHaveBeenCalled(); }); - it('keeps legacy post-play caption suppression when shared controls are disabled', async () => { - const video = component.videoPlayer.nativeElement; - const tracks = [{ mode: 'showing' as TextTrackMode }]; - Object.defineProperty(video, 'textTracks', { - configurable: true, - value: tracks, - }); - jest.spyOn(video, 'play').mockResolvedValue(undefined); - fixture.componentRef.setInput('showCaptions', false); - fixture.detectChanges(); - - component.handlePlayOperation(); - await Promise.resolve(); - - expect(tracks[0].mode).toBe('hidden'); - }); - it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => { const el = component.videoPlayer.nativeElement; const removeSpy = jest.spyOn(el, 'removeEventListener'); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 4ebb45ae0..25f80a8ca 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -36,6 +36,7 @@ import { clearNativeVideoSources, setNativeVideoSource, } from '../web-video-support/web-video-native-source.util'; +import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks'; import { HtmlVideoElementSession } from './html-video-element-session'; import { emitFatalHlsPlaybackError, @@ -101,6 +102,12 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { private shakaSession: ShakaVideoSession | null = null; private controlsSource: HtmlVideoControlsSource | null = null; private controlsBridge: HtmlVideoPlayerControlsBridge | null = null; + /** + * Legacy (vendor-chrome) counterpart of {@link controlsBridge}: without + * shared controls there is no adapter to feed, but the `showCaptions` + * preference still has to reach the active source engine. + */ + private captionTracks: WebVideoSourceTracks | null = null; private videoSession: HtmlVideoElementSession | null = null; ngOnInit() { @@ -116,9 +123,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { showCaptions: () => this.showCaptions(), }); this.controlsBridge.attach(); - if (this.controlsSource) { - this.controlsBridge.setSource(this.controlsSource); - } + } else { + this.captionTracks = new WebVideoSourceTracks({ + video: this.videoPlayer.nativeElement, + showCaptions: () => this.showCaptions(), + vendorCaptionControls: true, + }); + } + if (this.controlsSource) { + this.bindControlsSource(this.controlsSource); } this.getVideoSession().attach(); } @@ -136,6 +149,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { } if (changes['isLive'] || changes['showCaptions']) { this.controlsBridge?.refreshInputs(); + this.captionTracks?.refreshInputs(); } if (changes['interactionEnabled']?.currentValue === false) { exitOwnedFullscreen( @@ -273,10 +287,12 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { private bindControlsSource(source: HtmlVideoControlsSource): void { this.controlsSource = source; this.controlsBridge?.setSource(source); + this.captionTracks?.setSource(source); } private clearControlsSource(): void { this.controlsBridge?.clearSource(); + this.captionTracks?.clearSource(); this.controlsSource = null; } @@ -307,13 +323,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { return this.shakaSession; } - /** - * Disables text based captions based on the global settings - */ - disableCaptions(): void { - this.getVideoSession().disableCaptions(); - } - /** * Handles promise based play operation */ @@ -342,6 +351,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ngOnDestroy(): void { this.controlsBridge?.destroy(); this.controlsBridge = null; + this.captionTracks?.destroy(); + this.captionTracks = null; this.controlsSource = null; this.videoSession?.destroy(); this.videoSession = null; @@ -356,8 +367,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { video: this.videoPlayer.nativeElement, getChannelUrl: () => this.channel?.url, getStartTime: () => this.startTime, - showCaptions: () => this.showCaptions(), - sharedControls: () => this.sharedControls, emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), emitTimeUpdate: (value) => this.timeUpdate.emit(value), emitPlaybackEnded: () => this.playbackEnded.emit(), diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts new file mode 100644 index 000000000..cb6a7cd0c --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-tracks.ts @@ -0,0 +1,66 @@ +import { VjsAudioTracks } from './vjs-audio-tracks'; +import type { VideoJsPlayer } from './vjs-player.types'; +import { VjsTextTracks } from './vjs-text-tracks'; + +export interface VjsLegacyTracksConfig { + player: VideoJsPlayer; + showCaptions: () => boolean; +} + +/** + * Audio and text track ownership for the legacy Video.js chrome. + * + * Shared controls route the same helpers through `VjsPlayerControlsBridge`; + * without them nothing applied the `showCaptions` preference to the player's + * text tracks, so provider defaults stayed on screen (#1155). + * + * Here the preference is a per-source default rather than an authoritative + * state: Video.js still renders its own captions menu, so enforcing the + * preference for the whole session would make that menu inert. The preference + * is applied while the source starts up and released once playback begins. + * There is no controls UI to notify, hence the no-op refresh. + */ +export class VjsLegacyTracks { + private readonly audioTracks: VjsAudioTracks; + private readonly textTracks: VjsTextTracks; + private readonly handlePlaying = () => { + this.playbackStarted = true; + }; + private playbackStarted = false; + + constructor(private readonly config: VjsLegacyTracksConfig) { + const refresh = () => undefined; + this.audioTracks = new VjsAudioTracks({ + player: config.player, + refresh, + }); + this.textTracks = new VjsTextTracks({ + player: config.player, + showCaptions: config.showCaptions, + refresh, + playbackStarted: () => this.playbackStarted, + }); + config.player.on('playing', this.handlePlaying); + } + + bind(): void { + this.audioTracks.bind(); + this.textTracks.bind(); + } + + refreshInputs(): void { + this.textTracks.refreshInputs(); + } + + clear(): void { + // Every source starts unsettled so its own defaults are seeded again. + this.playbackStarted = false; + this.audioTracks.clear(); + this.textTracks.clear(); + } + + destroy(): void { + this.clear(); + this.config.player.off('playing', this.handlePlaying); + } +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts new file mode 100644 index 000000000..6d8266f6c --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts @@ -0,0 +1,264 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; +import type { + VideoJsPlayer, + VideoJsTextTrack, + VideoJsTextTrackList, +} from './vjs-player.types'; + +const videoJsMock = jest.fn(); + +jest.unstable_mockModule('video.js', () => ({ default: videoJsMock })); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); +jest.unstable_mockModule('mpegts.js', () => ({ + default: { + Events: { ERROR: 'error' }, + createPlayer: jest.fn(), + isSupported: jest.fn(() => false), + }, +})); + +/** + * Regression coverage for #1155: with Video.js' own control bar (shared + * controls off, the shipping default) the `showCaptions` preference was never + * read, so a VHS rendition flagged DEFAULT kept subtitles on screen. + */ +describe('VjsPlayerComponent caption preference without shared controls', () => { + let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent; + let fixture: ComponentFixture; + let harness: ReturnType; + + beforeAll(async () => { + ({ VjsPlayerComponent } = await import('./vjs-player.component')); + }); + + beforeEach(async () => { + harness = createHarness(); + videoJsMock + .mockReset() + .mockImplementation( + (_target: Element, _options: unknown, ready: () => void) => { + harness.ready = ready; + return harness.player; + } + ); + await TestBed.configureTestingModule({ + imports: [VjsPlayerComponent], + }).compileComponents(); + fixture = TestBed.createComponent(VjsPlayerComponent); + }); + + afterEach(() => { + if (!fixture.componentRef.hostView.destroyed) { + fixture.destroy(); + } + localStorage.removeItem('volume'); + }); + + it('suppresses initial and late default captions while the preference is off', () => { + const initial = createTextTrack({ + kind: 'captions', + mode: 'showing', + }); + harness.tracks.replaceSilently([initial]); + render(false); + + expect(initial.mode).toBe('disabled'); + + const late = createTextTrack({ kind: 'subtitles', mode: 'showing' }); + harness.tracks.add(late); + + expect(late.mode).toBe('disabled'); + }); + + it('keeps captions displayed while the preference is on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(true); + + expect(track.mode).toBe('showing'); + }); + + // The Video.js control bar stays visible in this mode, so the preference + // must seed the source and then get out of the user's way. + it('keeps a caption the user enables after playback started', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(false); + expect(track.mode).toBe('disabled'); + + harness.emit('playing'); + track.mode = 'showing'; + harness.tracks.emit('change'); + + expect(track.mode).toBe('showing'); + }); + + it('restores the suppressed track when the preference is turned on', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(false); + expect(track.mode).toBe('disabled'); + + fixture.componentRef.setInput('showCaptions', true); + fixture.detectChanges(); + + expect(track.mode).toBe('showing'); + }); + + it('re-applies the preference to the text tracks of a new source', () => { + render(false); + + const replacement = createTextTrack({ + kind: 'subtitles', + mode: 'showing', + }); + harness.tracks.replaceSilently([replacement]); + fixture.componentRef.setInput('options', { + sources: [{ src: 'https://example.test/second.mp4' }], + }); + fixture.detectChanges(); + harness.emit('loadedmetadata'); + + expect(replacement.mode).toBe('disabled'); + }); + + it('stops managing text tracks once the component is destroyed', () => { + const track = createTextTrack({ kind: 'captions', mode: 'showing' }); + harness.tracks.replaceSilently([track]); + render(false); + + fixture.destroy(); + track.mode = 'showing'; + harness.tracks.emit('change'); + + expect(track.mode).toBe('showing'); + }); + + function render(showCaptions: boolean): void { + fixture.componentRef.setInput('showCaptions', showCaptions); + fixture.componentRef.setInput('options', { + sources: [{ src: 'https://example.test/first.mp4' }], + }); + fixture.detectChanges(); + harness.ready(); + fixture.detectChanges(); + } +}); + +function createTextTrack( + overrides: Partial = {} +): VideoJsTextTrack { + return { kind: 'subtitles', mode: 'disabled', ...overrides }; +} + +class FakeTextTrackList { + private tracks: VideoJsTextTrack[] = []; + private indexedLength = 0; + private readonly listeners = new Map< + string, + Set + >(); + + readonly addEventListener = jest.fn( + (event: string, listener: EventListenerOrEventListenerObject): void => { + const registered = this.listeners.get(event) ?? new Set(); + registered.add(listener); + this.listeners.set(event, registered); + } + ); + + readonly removeEventListener = jest.fn( + (event: string, listener: EventListenerOrEventListenerObject): void => { + this.listeners.get(event)?.delete(listener); + } + ); + + get length(): number { + return this.tracks.length; + } + + replaceSilently(tracks: VideoJsTextTrack[]): void { + this.tracks = [...tracks]; + this.syncIndexedTracks(); + } + + add(track: VideoJsTextTrack): void { + this.tracks.push(track); + this.syncIndexedTracks(); + this.emit('addtrack'); + } + + emit(eventName: 'addtrack' | 'removetrack' | 'change'): void { + const event = new Event(eventName); + for (const listener of this.listeners.get(eventName) ?? []) { + if (typeof listener === 'function') { + listener.call(this, event); + } else { + listener.handleEvent(event); + } + } + } + + asTextTrackList(): VideoJsTextTrackList { + return this as unknown as VideoJsTextTrackList; + } + + private syncIndexedTracks(): void { + for (let index = 0; index < this.indexedLength; index += 1) { + Reflect.deleteProperty(this, index); + } + this.tracks.forEach((track, index) => { + Object.defineProperty(this, index, { + configurable: true, + enumerable: true, + value: track, + }); + }); + this.indexedLength = this.tracks.length; + } +} + +function createHarness() { + const listeners = new Map void>>(); + const tracks = new FakeTextTrackList(); + const harness = { + tracks, + currentVideo: document.createElement('video'), + ready: () => undefined, + emit(event: string) { + for (const listener of listeners.get(event) ?? []) { + listener(); + } + }, + player: null as unknown as VideoJsPlayer, + }; + harness.player = { + audioTracks: jest.fn(() => null), + textTracks: jest.fn(() => tracks.asTextTrackList()), + currentTime: jest.fn(() => 0), + duration: jest.fn(() => 0), + error: jest.fn(() => null), + getChild: jest.fn(() => null), + on: jest.fn((event: string, listener: () => void) => { + const registered = listeners.get(event) ?? new Set<() => void>(); + registered.add(listener); + listeners.set(event, registered); + }), + off: jest.fn((event: string, listener: () => void) => { + listeners.get(event)?.delete(listener); + }), + pause: jest.fn(), + paused: jest.fn(() => true), + reset: jest.fn(), + src: jest.fn(), + tech: jest.fn(() => ({ el: () => harness.currentVideo })), + volume: jest.fn(() => 1), + dispose: jest.fn(), + qualitySelectorHls: jest.fn(), + aspectRatioPanel: jest.fn(), + } as unknown as VideoJsPlayer; + return harness; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 0c63496c7..9911dd61a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -31,11 +31,8 @@ import { } from '../player-controls'; import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; -import { - VjsAudioTracks, - logVjsAudioTracks, - setupVjsAudioTrackMenu, -} from './vjs-audio-tracks'; +import { logVjsAudioTracks, setupVjsAudioTrackMenu } from './vjs-audio-tracks'; +import { VjsLegacyTracks } from './vjs-legacy-tracks'; import { VjsMpegTsSession } from './vjs-mpegts-session'; import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge'; import { @@ -112,7 +109,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { debugVjsPlayer('Failed to reset Video.js player:', error), }); private controlsBridge: VjsPlayerControlsBridge | null = null; - private legacyAudioTracks: VjsAudioTracks | null = null; + /** Track ownership for the legacy chrome; null with shared controls. */ + private legacyTracks: VjsLegacyTracks | null = null; private desiredSource: VideoPlayerSource | null = null; private readyHandled = false; private destroyed = false; @@ -161,6 +159,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { } if (changes['showCaptions']) { this.controlsBridge?.refreshInputs(); + this.legacyTracks?.refreshInputs(); } if (changes['interactionEnabled']?.currentValue === false) { exitOwnedVjsFullscreen( @@ -187,8 +186,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.resetCoordinator.destroy(); this.controlsBridge?.destroy(); this.controlsBridge = null; - this.legacyAudioTracks?.clear(); - this.legacyAudioTracks = null; + this.legacyTracks?.destroy(); + this.legacyTracks = null; this.mpegTsSession.destroy(); this.videoSession.destroy(); if (this.player) { @@ -211,7 +210,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.volume() ); this.player.volume(this.volume()); - this.initializeLegacyAudioTracks(); + this.initializeLegacyTracks(); const video = this.bindCurrentTechVideo(); if (video && this.resetCoordinator.canApplyReadySource()) { this.activateSource(this.desiredSource, video); @@ -224,7 +223,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.player.currentTime(this.startTime()); } this.playbackIssue.emit(null); - this.legacyAudioTracks?.bind(); + this.legacyTracks?.bind(); logVjsAudioTracks(this.player); setupVjsAudioTrackMenu(this.player); this.controlsBridge?.refreshInputs(); @@ -271,7 +270,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { private readonly handlePlayerReset = () => { this.player.volume(this.resetCoordinator.handlePlayerReset()); this.controlsBridge?.clearSource(); - this.legacyAudioTracks?.clear(); + this.legacyTracks?.clear(); this.mpegTsSession.destroy(); this.restoreDesiredSourceAfterReset(true); }; @@ -283,7 +282,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { private changeSource(source: VideoPlayerSource | null): void { this.playbackIssue.emit(null); this.controlsBridge?.clearSource(); - this.legacyAudioTracks?.clear(); + this.legacyTracks?.clear(); this.mpegTsSession.destroy(); this.desiredSource = source; this.resetCoordinator.clearSourceApplied(); @@ -325,7 +324,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.player.src(source); this.controlsBridge?.setSource(); - this.legacyAudioTracks?.bind(); + this.legacyTracks?.bind(); this.resetCoordinator.markSourceApplied(); } @@ -338,7 +337,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { return; } this.controlsBridge?.setSource(); - this.legacyAudioTracks?.bind(); + this.legacyTracks?.bind(); if (this.mpegTsSession.isSupportedSource(source.src)) { debugVjsPlayer('Using mpegts.js for TS stream:', source.src); this.mpegTsSession.start(source.src, video); @@ -368,15 +367,15 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { return video; } - private initializeLegacyAudioTracks(): void { - if (this.sharedControls || this.legacyAudioTracks) { + private initializeLegacyTracks(): void { + if (this.sharedControls || this.legacyTracks) { return; } - this.legacyAudioTracks = new VjsAudioTracks({ + this.legacyTracks = new VjsLegacyTracks({ player: this.player, - refresh: () => undefined, + showCaptions: () => this.showCaptions(), }); - this.legacyAudioTracks.bind(); + this.legacyTracks.bind(); } private bindPlayerEvents(): void { diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts b/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts index 95937a78b..608e2740a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-text-tracks.ts @@ -21,6 +21,14 @@ export interface VjsTextTracksConfig { player: Pick; showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe. Its presence + * switches the caption preference from authoritative (shared controls, the + * default) to source-default: the preference seeds each new source and + * stops being enforced once the probe returns true, so the Video.js + * captions menu keeps working. Shared controls omit it. + */ + playbackStarted?: () => boolean; } export class VjsTextTracks { @@ -169,6 +177,11 @@ export class VjsTextTracks { } if (!this.config.showCaptions()) { + if (this.config.playbackStarted?.()) { + // Source-default mode: the Video.js captions menu owns the + // selection once playback is running. + return; + } const selectedTrack = tracks.find( ({ track }) => track.mode === 'showing' )?.track; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index dcd83c1d2..71b8b09b3 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -1,5 +1,5 @@ import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, input, output, signal } from '@angular/core'; +import { signal } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, @@ -19,6 +19,12 @@ import { import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; import { type PlaybackDiagnostic, PlaybackDiagnosticCode, @@ -32,69 +38,8 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); -@Component({ selector: 'app-vjs-player', template: '' }) -class StubVjsPlayerComponent { - readonly options = input(); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ selector: 'app-html-video-player', template: '' }) -class StubHtmlVideoPlayerComponent { - readonly channel = input(); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ selector: 'app-art-player', template: '' }) -class StubArtPlayerComponent { - readonly channel = input(); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ selector: 'app-embedded-mpv-player', template: '' }) -class StubEmbeddedMpvPlayerComponent { - readonly playback = input.required(); - readonly mediaTitle = input(null); - readonly recordingFolder = input(''); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - const webPlayerSharedControls = signal(false); +const showCaptionsSetting = signal(false); describe('WebPlayerViewComponent shared web controls metadata', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; @@ -108,6 +53,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { beforeEach(async () => { webPlayerSharedControls.set(false); + showCaptionsSetting.set(false); await TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Playthrough, @@ -125,7 +71,10 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { }, { provide: SettingsStore, - useValue: { webPlayerSharedControls }, + useValue: { + webPlayerSharedControls, + showCaptions: showCaptionsSetting, + }, }, ], }) @@ -212,8 +161,23 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { expect(htmlPlayer.isLive()).toBe(false); }); + // Regression for #1155: portal hosts never bound a showCaptions input, so + // the preference has to come from the settings store instead. + it('reads the caption preference from settings without a host binding', async () => { + showCaptionsSetting.set(true); + + const htmlPlayer = await renderHtmlPlayer(); + + expect(htmlPlayer.showCaptions()).toBe(true); + + showCaptionsSetting.set(false); + fixture.detectChanges(); + + expect(htmlPlayer.showCaptions()).toBe(false); + }); + it('passes resolved playback metadata and caption preference to Video.js', async () => { - fixture.componentRef.setInput('showCaptions', true); + showCaptionsSetting.set(true); const vjsPlayer = await renderVjsPlayer({ isLive: false }); @@ -225,7 +189,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { }); it('passes resolved playback metadata and diagnostic interaction state to ArtPlayer', async () => { - fixture.componentRef.setInput('showCaptions', true); + showCaptionsSetting.set(true); const artPlayer = await renderArtPlayer({ isLive: false }); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index c09a866f9..e1c8a0c34 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -85,13 +85,13 @@ function resolveWebPlayerSharedControls(): boolean { export class WebPlayerViewComponent { storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsStore = inject(SettingsStore); streamUrl = input.required(); title = input(''); playback = input(null); startTime = input(0); volume = input(1); - showCaptions = input(false); playerOverride = input(null); seriesNavigation = input(null); /** Display-ready title lines for the fullscreen overlay; hosts with richer @@ -110,6 +110,16 @@ export class WebPlayerViewComponent { Settings | undefined >; + /** + * Subtitle preference for the built-in web players. Read from the settings + * store instead of an input so every host (M3U, Xtream, Stalker, portal + * detail pages) gets it without having to wire it through — the missing + * bindings were why the setting looked like a no-op (#1155). + */ + readonly showCaptions = computed( + () => this.settingsStore.showCaptions?.() ?? false + ); + channel!: Channel; vjsOptions!: { isLive: boolean; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts new file mode 100644 index 000000000..553ad2646 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -0,0 +1,70 @@ +import { Component, input, output } from '@angular/core'; +import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; + +/** + * Player stand-ins for WebPlayerViewComponent specs. They mirror the real + * component inputs so a spec can assert what the host hands each engine + * without pulling in video.js, ArtPlayer or the Electron bridge. + */ + +@Component({ selector: 'app-vjs-player', template: '' }) +export class StubVjsPlayerComponent { + readonly options = input(); + readonly mediaTitle = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly interactionEnabled = input(true); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +@Component({ selector: 'app-html-video-player', template: '' }) +export class StubHtmlVideoPlayerComponent { + readonly channel = input(); + readonly mediaTitle = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly isLive = input(true); + readonly interactionEnabled = input(true); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +@Component({ selector: 'app-art-player', template: '' }) +export class StubArtPlayerComponent { + readonly channel = input(); + readonly mediaTitle = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly isLive = input(true); + readonly interactionEnabled = input(true); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +@Component({ selector: 'app-embedded-mpv-player', template: '' }) +export class StubEmbeddedMpvPlayerComponent { + readonly playback = input.required(); + readonly mediaTitle = input(null); + readonly recordingFolder = input(''); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts b/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts index d1e5d47aa..30fe1862c 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-hls-controls.ts @@ -4,6 +4,15 @@ import type { PlayerTrack } from '../player-controls/player-controls.model'; export interface WebVideoHlsControlsConfig { showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe. Its presence + * switches the caption preference from authoritative (shared controls, the + * default) to source-default: the preference seeds each new source and + * stops being enforced once the probe returns true, so the engine's own + * caption menu keeps working. Shared controls omit it — they own the + * caption UI themselves and route user intent through `setSubtitleTrack`. + */ + playbackStarted?: () => boolean; } export class WebVideoHlsControls { @@ -125,14 +134,25 @@ export class WebVideoHlsControls { } if (!this.config.showCaptions()) { + const selectedTrack = this.readSelectedSubtitleTrack(); + if (this.config.playbackStarted) { + if (this.config.playbackStarted() || selectedTrack === null) { + return; + } + // Deselect instead of hiding: hls.js keeps `subtitleDisplay` + // authoritative over the track the vendor caption menu picks, + // so suppressing display would make that menu inert. A `-1` + // assignment also clears hls.js' own default-track selection, + // so it will not reselect one behind the user's back. + this.suppressedSubtitleTrack = selectedTrack; + this.setSubtitleTrackValue(-1); + return; + } + // HLS may choose its default track while display is suppressed. // Explicit user-off remains owned by subtitleOverride === -1 above. - if ( - Number.isInteger(this.hls.subtitleTrack) && - this.hls.subtitleTrack >= 0 && - this.hls.subtitleTrack < this.hls.subtitleTracks.length - ) { - this.suppressedSubtitleTrack = this.hls.subtitleTrack; + if (selectedTrack !== null) { + this.suppressedSubtitleTrack = selectedTrack; } this.setSubtitleDisplay(false); return; @@ -149,6 +169,19 @@ export class WebVideoHlsControls { } } + /** Current subtitle index, or null when nothing valid is selected. */ + private readSelectedSubtitleTrack(): number | null { + const hls = this.hls; + if (!hls) { + return null; + } + return Number.isInteger(hls.subtitleTrack) && + hls.subtitleTrack >= 0 && + hls.subtitleTrack < hls.subtitleTracks.length + ? hls.subtitleTrack + : null; + } + private setSubtitleTrackValue(id: number): void { if (this.hls && this.hls.subtitleTrack !== id) { this.hls.subtitleTrack = id; diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts b/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts index 744f1a781..2da4d985c 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts @@ -11,6 +11,14 @@ export interface WebVideoNativeTextTracksConfig { video: HTMLVideoElement; showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe. Its presence + * switches the caption preference from authoritative (shared controls, the + * default) to source-default: the preference seeds each new source and + * stops being enforced once the probe returns true, so the native caption + * menu keeps working. Shared controls omit it. + */ + playbackStarted?: () => boolean; } export class WebVideoNativeTextTracks { @@ -138,6 +146,11 @@ export class WebVideoNativeTextTracks { } if (!this.config.showCaptions()) { + if (this.config.playbackStarted?.()) { + // Source-default mode: the native caption menu owns the + // selection once playback is running. + return; + } for (const { track } of tracks) { if (track.mode !== 'showing') { continue; diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts index a59da9f04..6dcd6c642 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts @@ -9,7 +9,7 @@ import { WebVideoShakaControls } from './web-video-shaka-controls'; describe('WebVideoShakaControls', () => { const video = {} as HTMLVideoElement; - function createHarness() { + function createHarness(playbackStarted?: () => boolean) { const environment = createFakeShakaEnvironment({ onCreate: (player) => { player.textTracks = [ @@ -33,10 +33,45 @@ describe('WebVideoShakaControls', () => { const controls = new WebVideoShakaControls({ showCaptions: () => state.showCaptions, refresh: () => undefined, + playbackStarted, }); return { environment, session, controls, state }; } + // Vendor-chrome hosts seed the source through ShakaVideoSession.start() + // and then stop enforcing, mirroring the HLS and native helpers. + it('stops re-suppressing text once playback started in source-default mode', async () => { + const settled = { value: false }; + const { environment, session, controls } = createHarness( + () => settled.value + ); + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + expect(player.textTracks[0].active).toBe(false); + + settled.value = true; + player.selectTextTrack(player.textTracks[0]); + controls.refreshInputs(); + + expect(player.textTracks[0].active).toBe(true); + }); + + it('still seeds the source before playback started', async () => { + const { environment, session, controls } = createHarness(() => false); + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + player.selectTextTrack(player.textTracks[0]); + controls.refreshInputs(); + + expect(player.textTracks[0].active).toBe(false); + }); + it('restores the suppressed caption track when the preference turns on', async () => { const { environment, session, controls, state } = createHarness(); controls.bind(session); diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts index d66173bd4..78292aa70 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts @@ -8,6 +8,14 @@ import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; export interface WebVideoShakaControlsConfig { showCaptions: () => boolean; refresh: () => void; + /** + * Vendor-chrome hosts pass a "playback started" probe, mirroring the HLS + * and native helpers. `ShakaVideoSession` already seeds the preference once + * the manifest is loaded, so re-suppressing on every later Shaka event + * would only override selections the host cannot see. Shared controls own + * the caption UI and omit it, staying authoritative. + */ + playbackStarted?: () => boolean; } const SUBTITLES_OFF = -1; @@ -117,9 +125,14 @@ export class WebVideoShakaControls { // off, and reselect the suppressed track when it turns back on. if (this.config.showCaptions()) { session.restoreSuppressedTextTrack(); - } else { - session.suppressTextTracks(); + return; } + if (this.config.playbackStarted?.()) { + // Source-default mode: the session already seeded this source at + // load time, so leave the running selection alone. + return; + } + session.suppressTextTracks(); } private getPlayer(): ShakaPlayerLike | null { diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts index 9368a48be..a1a92cc3a 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts @@ -1,16 +1,11 @@ -import type Hls from 'hls.js'; import type { PlayerTrack } from '../player-controls/player-controls.model'; import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter'; -import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; -import { WebVideoHlsControls } from './web-video-hls-controls'; -import { WebVideoNativeTextTracks } from './web-video-native-text-tracks'; -import { WebVideoShakaControls } from './web-video-shaka-controls'; +import { + type WebVideoControlsSource, + WebVideoSourceTracks, +} from './web-video-source-tracks'; -export type WebVideoControlsSource = - | { kind: 'native' } - | { kind: 'mpegts' } - | { kind: 'hls'; hls: Hls } - | { kind: 'shaka'; session: ShakaVideoSession }; +export type { WebVideoControlsSource }; export interface WebVideoSourceControlsBridgeConfig { video: HTMLVideoElement; @@ -21,28 +16,16 @@ export interface WebVideoSourceControlsBridgeConfig { export class WebVideoSourceControlsBridge { private readonly config: WebVideoSourceControlsBridgeConfig; - private readonly hlsControls: WebVideoHlsControls; - private readonly shakaControls: WebVideoShakaControls; - private readonly nativeTextTracks: WebVideoNativeTextTracks; - private source: WebVideoControlsSource | null = null; + private readonly tracks: WebVideoSourceTracks; private attached = false; private destroyed = false; constructor(config: WebVideoSourceControlsBridgeConfig) { this.config = config; - const refresh = () => this.config.adapter.refresh(); - this.hlsControls = new WebVideoHlsControls({ - showCaptions: config.showCaptions, - refresh, - }); - this.shakaControls = new WebVideoShakaControls({ - showCaptions: config.showCaptions, - refresh, - }); - this.nativeTextTracks = new WebVideoNativeTextTracks({ + this.tracks = new WebVideoSourceTracks({ video: config.video, showCaptions: config.showCaptions, - refresh, + refresh: () => this.config.adapter.refresh(), }); } @@ -67,15 +50,7 @@ export class WebVideoSourceControlsBridge { return; } - this.clearActiveSource(); - this.source = source; - if (source.kind === 'hls') { - this.hlsControls.bind(source.hls); - } else if (source.kind === 'shaka') { - this.shakaControls.bind(source.session); - } else { - this.nativeTextTracks.bind(); - } + this.tracks.setSource(source); this.config.adapter.refresh(); } @@ -84,13 +59,7 @@ export class WebVideoSourceControlsBridge { return; } - if (this.source?.kind === 'hls') { - this.hlsControls.refreshInputs(); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.refreshInputs(); - } else if (this.source) { - this.nativeTextTracks.refreshInputs(); - } + this.tracks.refreshInputs(); this.config.adapter.refresh(); } @@ -99,8 +68,7 @@ export class WebVideoSourceControlsBridge { return; } - this.clearActiveSource(); - this.source = null; + this.tracks.clearSource(); this.config.adapter.refresh(); } @@ -110,6 +78,7 @@ export class WebVideoSourceControlsBridge { } this.clearSource(); + this.tracks.destroy(); if (this.attached) { this.config.adapter.detach(); this.attached = false; @@ -118,7 +87,7 @@ export class WebVideoSourceControlsBridge { } readDuration(): number { - if (this.source?.kind !== 'mpegts' || this.config.isLive()) { + if (this.tracks.sourceKind !== 'mpegts' || this.config.isLive()) { return NaN; } @@ -142,51 +111,19 @@ export class WebVideoSourceControlsBridge { } private getAudioTracks(): PlayerTrack[] { - if (this.source?.kind === 'hls') { - return this.hlsControls.getAudioTracks(); - } - if (this.source?.kind === 'shaka') { - return this.shakaControls.getAudioTracks(); - } - return []; + return this.tracks.getAudioTracks(); } private setAudioTrack(id: number): void { - if (this.source?.kind === 'hls') { - this.hlsControls.setAudioTrack(id); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.setAudioTrack(id); - } + this.tracks.setAudioTrack(id); } private getSubtitleTracks(): PlayerTrack[] { - if (this.source?.kind === 'hls') { - return this.hlsControls.getSubtitleTracks(); - } - if (this.source?.kind === 'shaka') { - return this.shakaControls.getSubtitleTracks(); - } - return this.source ? this.nativeTextTracks.getSubtitleTracks() : []; + return this.tracks.getSubtitleTracks(); } private setSubtitleTrack(id: number): void { - if (this.source?.kind === 'hls') { - this.hlsControls.setSubtitleTrack(id); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.setSubtitleTrack(id); - } else if (this.source) { - this.nativeTextTracks.setSubtitleTrack(id); - } - } - - private clearActiveSource(): void { - if (this.source?.kind === 'hls') { - this.hlsControls.clear(); - } else if (this.source?.kind === 'shaka') { - this.shakaControls.clear(); - } else if (this.source) { - this.nativeTextTracks.clear(); - } + this.tracks.setSubtitleTrack(id); } private readFinitePositive(read: () => number): number { diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts new file mode 100644 index 000000000..105703cf2 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts @@ -0,0 +1,188 @@ +import type Hls from 'hls.js'; +import type { PlayerTrack } from '../player-controls/player-controls.model'; +import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; +import { WebVideoHlsControls } from './web-video-hls-controls'; +import { WebVideoNativeTextTracks } from './web-video-native-text-tracks'; +import { WebVideoShakaControls } from './web-video-shaka-controls'; + +export type WebVideoControlsSource = + | { kind: 'native' } + | { kind: 'mpegts' } + | { kind: 'hls'; hls: Hls } + | { kind: 'shaka'; session: ShakaVideoSession }; + +export interface WebVideoSourceTracksConfig { + video: HTMLVideoElement; + showCaptions: () => boolean; + /** + * Notifies the owning controls UI that track state changed. Legacy players + * have no shared controls to refresh and leave it unset — they use this + * class purely to apply the caption preference. + */ + refresh?: () => void; + /** + * Set by hosts that keep the engine's own caption UI (the preference-off + * players). The caption preference then seeds each new source instead of + * staying authoritative, so the vendor caption menu keeps working once + * playback is running. Shared controls own the caption UI and omit it. + */ + vendorCaptionControls?: boolean; +} + +/** + * Owns the per-source audio/subtitle track controllers of a web video engine. + * + * It is deliberately free of any controls-UI dependency so both the shared + * controls bridge and the legacy (vendor-chrome) players apply the + * `showCaptions` preference through the exact same code path. + */ +export class WebVideoSourceTracks { + private readonly config: WebVideoSourceTracksConfig; + private readonly hlsControls: WebVideoHlsControls; + private readonly shakaControls: WebVideoShakaControls; + private readonly nativeTextTracks: WebVideoNativeTextTracks; + private source: WebVideoControlsSource | null = null; + private playbackStarted = false; + private playingListener: (() => void) | null = null; + private destroyed = false; + + constructor(config: WebVideoSourceTracksConfig) { + this.config = config; + const refresh = () => config.refresh?.(); + const playbackStarted = config.vendorCaptionControls + ? () => this.playbackStarted + : undefined; + this.hlsControls = new WebVideoHlsControls({ + showCaptions: config.showCaptions, + refresh, + playbackStarted, + }); + this.shakaControls = new WebVideoShakaControls({ + showCaptions: config.showCaptions, + refresh, + playbackStarted, + }); + this.nativeTextTracks = new WebVideoNativeTextTracks({ + video: config.video, + showCaptions: config.showCaptions, + refresh, + playbackStarted, + }); + if (config.vendorCaptionControls) { + const listener = () => { + this.playbackStarted = true; + }; + this.playingListener = listener; + config.video.addEventListener('playing', listener); + } + } + + get sourceKind(): WebVideoControlsSource['kind'] | null { + return this.source?.kind ?? null; + } + + setSource(source: WebVideoControlsSource): void { + if (this.destroyed) { + return; + } + + this.clearActiveSource(); + this.source = source; + // A new source starts unsettled so its own defaults are seeded again. + this.playbackStarted = false; + if (source.kind === 'hls') { + this.hlsControls.bind(source.hls); + } else if (source.kind === 'shaka') { + this.shakaControls.bind(source.session); + } else { + this.nativeTextTracks.bind(); + } + } + + refreshInputs(): void { + if (this.destroyed) { + return; + } + + if (this.source?.kind === 'hls') { + this.hlsControls.refreshInputs(); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.refreshInputs(); + } else if (this.source) { + this.nativeTextTracks.refreshInputs(); + } + } + + clearSource(): void { + if (this.destroyed) { + return; + } + + this.clearActiveSource(); + this.source = null; + } + + destroy(): void { + if (this.destroyed) { + return; + } + + this.clearSource(); + if (this.playingListener) { + this.config.video.removeEventListener( + 'playing', + this.playingListener + ); + this.playingListener = null; + } + this.destroyed = true; + } + + getAudioTracks(): PlayerTrack[] { + if (this.source?.kind === 'hls') { + return this.hlsControls.getAudioTracks(); + } + if (this.source?.kind === 'shaka') { + return this.shakaControls.getAudioTracks(); + } + return []; + } + + setAudioTrack(id: number): void { + if (this.source?.kind === 'hls') { + this.hlsControls.setAudioTrack(id); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.setAudioTrack(id); + } + } + + getSubtitleTracks(): PlayerTrack[] { + if (this.source?.kind === 'hls') { + return this.hlsControls.getSubtitleTracks(); + } + if (this.source?.kind === 'shaka') { + return this.shakaControls.getSubtitleTracks(); + } + return this.source ? this.nativeTextTracks.getSubtitleTracks() : []; + } + + setSubtitleTrack(id: number): void { + if (this.source?.kind === 'hls') { + this.hlsControls.setSubtitleTrack(id); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.setSubtitleTrack(id); + } else if (this.source) { + this.nativeTextTracks.setSubtitleTrack(id); + } + } + + private clearActiveSource(): void { + if (this.source?.kind === 'hls') { + this.hlsControls.clear(); + } else if (this.source?.kind === 'shaka') { + this.shakaControls.clear(); + } else if (this.source) { + this.nativeTextTracks.clear(); + } + } +}